This commit is contained in:
@@ -11,7 +11,7 @@ import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
|
||||
|
||||
// Name of the ClusterSecretStore the generated manifests reference.
|
||||
|
||||
const STORE_NAME = "vantage-store";
|
||||
|
||||
function CopyBlock({ label, yaml }: { label: string; yaml: string }) {
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function ServerConsolePage() {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const dprRef = useRef(1);
|
||||
|
||||
// Inject the vendored Guacamole client script once.
|
||||
|
||||
useEffect(() => {
|
||||
const s = document.createElement("script");
|
||||
s.src = "/lib/guacamole-common.js";
|
||||
@@ -45,7 +45,7 @@ export default function ServerConsolePage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Disconnect on unmount.
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
connectionRef.current?.disconnect();
|
||||
@@ -90,8 +90,8 @@ export default function ServerConsolePage() {
|
||||
}
|
||||
|
||||
const { token, ws_path } = await api.connectConsole(body);
|
||||
// Defer the actual openConsole until after the form is unmounted so the
|
||||
// container measures at full height (see effect below).
|
||||
|
||||
|
||||
setPending({ token, wsPath: ws_path });
|
||||
setConnected(true);
|
||||
} catch (e) {
|
||||
@@ -101,8 +101,8 @@ export default function ServerConsolePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Runs after `connected` flips and the connection form is gone, so the
|
||||
// container now occupies its full flex height.
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !pending || !containerRef.current) return;
|
||||
|
||||
@@ -111,9 +111,9 @@ export default function ServerConsolePage() {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
dprRef.current = dpr;
|
||||
// Request the remote at device-pixel resolution with a fixed 96 dpi, then
|
||||
// scale the display back down by dpr. Folding dpr into `dpi` instead makes
|
||||
// the remote enlarge everything, which reads as a zoomed-in view.
|
||||
|
||||
|
||||
|
||||
const connectData =
|
||||
`token=${encodeURIComponent(pending.token)}` +
|
||||
`&width=${Math.floor(rect.width * dpr)}` +
|
||||
@@ -125,10 +125,10 @@ export default function ServerConsolePage() {
|
||||
setPending(null);
|
||||
}, [connected, pending]);
|
||||
|
||||
// Apply zoom live without reconnecting: resize the remote to a resolution
|
||||
// that, once scaled to fit the container, yields the requested zoom. Higher
|
||||
// zoom = fewer remote pixels rendered larger. Display always fits the
|
||||
// container exactly, so no scrollbars appear.
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
@@ -10,7 +10,7 @@ const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
|
||||
const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
|
||||
|
||||
// Config fields required per channel type.
|
||||
|
||||
const CONFIG_FIELDS: Record<ChannelType, string[]> = {
|
||||
webhook: ["url"],
|
||||
slack: ["url"],
|
||||
|
||||
@@ -53,8 +53,8 @@ function MembersCard() {
|
||||
const { mutate: changeRole, error: roleError } = useMutation({
|
||||
mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next),
|
||||
onSuccess: invalidate,
|
||||
// A rejected change (last owner, owner-only grant) leaves the select showing
|
||||
// the value the server refused — refetch so the row snaps back to the truth.
|
||||
|
||||
|
||||
onError: invalidate,
|
||||
});
|
||||
|
||||
@@ -65,8 +65,8 @@ function MembersCard() {
|
||||
|
||||
const actionError = (roleError ?? removeError) as Error | null;
|
||||
|
||||
// The server lets only an owner grant or change the owner role. Mirror that
|
||||
// here so admins aren't offered controls that can only 403.
|
||||
|
||||
|
||||
const isOwner = user?.role === "owner";
|
||||
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
|
||||
|
||||
@@ -112,7 +112,7 @@ function MembersCard() {
|
||||
<Tbody>
|
||||
{users.map((u: OrgUser) => {
|
||||
const isSelf = u.user_id === user?.user_id;
|
||||
// Own row stays read-only, and only owners may act on owners.
|
||||
|
||||
const locked = isSelf || (u.role === "owner" && !isOwner);
|
||||
return (
|
||||
<Tr key={u.user_id}>
|
||||
@@ -242,7 +242,7 @@ function OIDCCard() {
|
||||
setIssuer(cfg.issuer ?? "");
|
||||
setClientId(cfg.client_id ?? "");
|
||||
setEnabled(cfg.enabled);
|
||||
// The secret is never returned; leave the field blank to mean "unchanged".
|
||||
|
||||
setClientSecret("");
|
||||
}, [cfg]);
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
// /api/settings requires owner|admin and 403s for members, so don't even ask.
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: api.getSettings,
|
||||
@@ -168,8 +168,8 @@ export default function SettingsPage() {
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!settings) return;
|
||||
// Preserve legacy alert/email values (managed via Notification Channels now);
|
||||
// only the offline threshold and log retention are edited here.
|
||||
|
||||
|
||||
save({
|
||||
alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
|
||||
email: settings.email,
|
||||
|
||||
@@ -23,9 +23,9 @@ function AdhocBadge() {
|
||||
return <span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-signal/15 text-signal">ad-hoc</span>;
|
||||
}
|
||||
|
||||
// Stable snapshot of only the fields the editor controls. Excludes volatile
|
||||
// server-echo fields (e.g. updated_at) that would otherwise change on every
|
||||
// save and cause autosave to loop forever.
|
||||
|
||||
|
||||
|
||||
function snapshotOf(w: Workflow): string {
|
||||
return JSON.stringify({
|
||||
name: w.name,
|
||||
@@ -81,11 +81,11 @@ export default function WorkflowBuilder() {
|
||||
setWf(loaded);
|
||||
savedSnapshotRef.current = snapshotOf(loaded);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [loaded]);
|
||||
|
||||
// Lazily fetch the keys for every secret group so the inspector's
|
||||
// secret-ref checklist can offer "group/KEY" options.
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!secretGroups) return;
|
||||
secretGroups.forEach((g: SecretGroupSummary) => {
|
||||
@@ -101,17 +101,17 @@ export default function WorkflowBuilder() {
|
||||
setGroupKeys((prev) => ({ ...prev, [g.group]: [] }));
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [secretGroups]);
|
||||
|
||||
// Keep a ref to the latest workflow so an in-flight save can tell whether
|
||||
// the user edited again while the request was on the wire.
|
||||
|
||||
|
||||
wfRef.current = wf;
|
||||
|
||||
// Autosave: debounce 800ms after any change to the workflow (step added,
|
||||
// removed, reordered, or edited) and persist. Diffing the serialized state
|
||||
// against the last saved snapshot skips no-op saves and the initial load.
|
||||
// Must stay above the early return below so hook order is stable.
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!wf || savedSnapshotRef.current === null) return;
|
||||
if (snapshotOf(wf) === savedSnapshotRef.current) return;
|
||||
@@ -119,10 +119,10 @@ export default function WorkflowBuilder() {
|
||||
save();
|
||||
}, 800);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [wf]);
|
||||
|
||||
// Re-render every 15s so the "Saved … ago" label stays current.
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastSaved) return;
|
||||
const iv = setInterval(() => setTick((n) => n + 1), 15000);
|
||||
@@ -141,8 +141,8 @@ export default function WorkflowBuilder() {
|
||||
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
|
||||
|
||||
const save = async () => {
|
||||
// Never run two saves concurrently: a request in flight would race the
|
||||
// next one. The finally block re-triggers if edits landed meanwhile.
|
||||
|
||||
|
||||
if (savingRef.current) return;
|
||||
const current = wfRef.current;
|
||||
if (!current) return;
|
||||
@@ -158,14 +158,14 @@ export default function WorkflowBuilder() {
|
||||
return;
|
||||
}
|
||||
if (wfRef.current && snapshotOf(wfRef.current) === snapshot) {
|
||||
// Nothing changed while the request was in flight: adopt the
|
||||
// server echo as the new saved baseline.
|
||||
|
||||
|
||||
savedSnapshotRef.current = snapshotOf(updated);
|
||||
setWf(updated);
|
||||
} else {
|
||||
// The user edited again mid-flight. Keep their newer state and
|
||||
// mark only the SENT snapshot as saved, so the effect re-fires
|
||||
// and persists the remaining changes.
|
||||
|
||||
|
||||
|
||||
savedSnapshotRef.current = snapshot;
|
||||
}
|
||||
setLastSaved(new Date());
|
||||
@@ -174,8 +174,8 @@ export default function WorkflowBuilder() {
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
// If edits arrived during the save (or a concurrent save was
|
||||
// skipped), persist them on the next tick.
|
||||
|
||||
|
||||
if (wfRef.current && snapshotOf(wfRef.current) !== savedSnapshotRef.current) {
|
||||
setTimeout(() => save(), 0);
|
||||
}
|
||||
@@ -581,8 +581,8 @@ export default function WorkflowBuilder() {
|
||||
open={editWorkflowOpen}
|
||||
workflow={wf}
|
||||
onSaved={(w) => {
|
||||
// The modal already persisted w; sync the snapshot so
|
||||
// autosave doesn't fire a redundant follow-up save.
|
||||
|
||||
|
||||
savedSnapshotRef.current = snapshotOf(w);
|
||||
setWf(w);
|
||||
}}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, ServerRun, StepRun, WorkflowRun } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
// ---- status vocabulary ----------------------------------------------------
|
||||
|
||||
|
||||
type CellKind = "done" | "fail" | "run" | "wait" | "skip" | "warn";
|
||||
|
||||
@@ -23,7 +23,7 @@ function cellKind(status: string): CellKind {
|
||||
case "cancelled":
|
||||
return "warn";
|
||||
default:
|
||||
return "wait"; // queued / pending / missing
|
||||
return "wait";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const cellClass: Record<CellKind, string> = {
|
||||
warn: "bg-warning/15 text-warning",
|
||||
};
|
||||
|
||||
// ---- run-level status pill ------------------------------------------------
|
||||
|
||||
|
||||
type PillKind = "running" | "success" | "failed" | "neutral";
|
||||
|
||||
@@ -84,7 +84,7 @@ function StatusPill({ status, small }: { status: string; small?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- time helpers ---------------------------------------------------------
|
||||
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 0) ms = 0;
|
||||
@@ -104,7 +104,7 @@ function stepDuration(st: StepRun, running: boolean, now: number): string {
|
||||
return fmtDuration(end - start);
|
||||
}
|
||||
|
||||
// ---- live log terminal ----------------------------------------------------
|
||||
|
||||
|
||||
function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) {
|
||||
const [text, setText] = useState("");
|
||||
@@ -155,9 +155,9 @@ function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) {
|
||||
);
|
||||
}
|
||||
|
||||
// LogLines renders the raw server-run log, parsing each line's leading UTC
|
||||
// timestamp ([2026-07-20T12:04:02.000Z]) and rendering it in the viewer's local
|
||||
// timezone. Event markers (===== …) are highlighted so the run's shape scans.
|
||||
|
||||
|
||||
|
||||
const TS_RE = /^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\]\s?(.*)$/;
|
||||
|
||||
function LogLines({ text }: { text: string }) {
|
||||
@@ -189,7 +189,7 @@ function LogLines({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- step list ------------------------------------------------------------
|
||||
|
||||
|
||||
function StepList({ server, now }: { server: ServerRun; now: number }) {
|
||||
const running = server.status === "running";
|
||||
@@ -228,7 +228,7 @@ function StepList({ server, now }: { server: ServerRun; now: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- execution matrix (signature) -----------------------------------------
|
||||
|
||||
|
||||
interface Column {
|
||||
order: number;
|
||||
@@ -295,7 +295,7 @@ function ExecutionMatrix({ run, columns, selected, onSelect }: { run: WorkflowRu
|
||||
);
|
||||
}
|
||||
|
||||
// ---- page -----------------------------------------------------------------
|
||||
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -320,7 +320,7 @@ export default function RunDetail() {
|
||||
|
||||
const running = run?.status === "running";
|
||||
|
||||
// tick the elapsed clock while running
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
@@ -329,7 +329,7 @@ export default function RunDetail() {
|
||||
|
||||
const columns = useMemo(() => (run ? buildColumns(run) : []), [run]);
|
||||
|
||||
// default selection: first running server, else first server
|
||||
|
||||
const selectedServer = useMemo(() => {
|
||||
if (!run || run.server_runs.length === 0) return null;
|
||||
if (selected) {
|
||||
|
||||
Reference in New Issue
Block a user