mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
shell-redesign V1 attempt-3 Phase 4: Drawer Viewers + Wiring
Per qitem dispatch. Phase 3 + bounce-fix merged at main 59cee0e;
Phase 4 fills the drawer viewer types and wires click destinations.
P4-1: 4 drawer viewer types (NEW components/drawer-viewers/):
- QueueItemViewer.tsx — qitem header + metadata (StatusPip per state)
+ body preview (~30 lines + show-full toggle) + Related refs.
- FileViewer.tsx — markdown (via existing MarkdownViewer) / YAML / JSON
/ text / image / binary fallback per content-drawer.md L88-L108.
- SubSpecPreview.tsx — referenced spec header + manifest excerpt + open-in-center.
- SeatDetailViewer.tsx — wraps existing NodeDetailPanel for the drawer
(V1.5 — per content-drawer.md L137-139, replaces auto-open
RigDetailPanel pattern with explicit manual-trigger).
P4-2: 4 drawer trigger components (NEW components/drawer-triggers/):
- QueueItemTrigger / FileReferenceTrigger / SubSpecTrigger /
SeatDetailTrigger. Tiny wrappers around clickable elements that
call setSelection with the corresponding viewer kind via the
existing DrawerSelectionContext.
- Available for surfaces to import; full wiring into LibraryReview /
RigSpecDisplay / FeedCard / TopologyTreeView at site-of-use is
Phase 5 polish per scope reduction (consumed surfaces continue to
work; the triggers are exposed primitives).
DrawerSelection union extended (SharedDetailDrawer.tsx):
- Added 4 new kinds: 'qitem' (QueueItemViewerData payload) / 'file'
(FileViewerData) / 'sub-spec' (SubSpecPreviewData) / 'seat-detail'
(rigId + logicalId).
- Removed 'rig' kind (P4-5; legacy auto-open RigDetailPanel pattern
retired).
- Existing 'node' / 'system' / 'discovery' kinds preserved for
back-compat with current callers.
P4-3: AuthorAgentTag cmux launcher
- New hook hooks/useCmuxLaunch.ts wraps existing daemon endpoint
POST /api/rigs/:rigId/nodes/:logicalId/focus (shipped pre-Phase-2
per architecture.md L88; SC-29 honored — no new endpoint).
- AuthorAgentTag.tsx: plain click → cmuxLaunch.mutate (foregrounds
seat in cmux); Cmd/Ctrl/Shift + middle-click → standard Link
navigation to /topology/seat/$rigId/$logicalId.
P4-4: Advisor / Operator config-driven click handlers
- AppShell Rail reads agents.advisor_session + agents.operator_session
from useSettings.
- New helper resolveChatTo(session, role): when configured →
/topology/seat/$rigId/$logicalId; when unset →
/settings#agents-{role}-session (per universal-shell.md L80
one-click navigation).
- Defaults from canon (advisor: advisor-lead@openrig-velocity;
operator: empty).
P4-5: RigDetailPanel legacy retire
- DELETED packages/ui/src/components/RigDetailPanel.tsx.
- DELETED packages/ui/src/components/Dashboard.tsx (legacy orphan;
Phase 2 deletion list missed it; Phase 3 dashboard/Dashboard.tsx
is the canonical surface).
- 'rig' kind removed from DrawerSelection union (SharedDetailDrawer.tsx).
- Explorer.tsx legacy RigBranch helper: rig click no longer fires
setSelection — Link routing only (consistent with Phase 3
TopologyTreeView).
- RigGraph.tsx pod-group click is no-op (was setSelection rig); pods
reachable via Explorer tree's /topology/pod link.
- Tests deleted: dashboard.test.tsx (legacy), rig-detail-panel.test.tsx
(component deleted), scaffold.test.tsx (imported deleted Dashboard).
- rig-graph.test.tsx pod-group test renamed + assertion flipped
(expect(setSelection).not.toHaveBeenCalled()) to enforce the
retire as a regression.
P4-6 subscription toggles DEFERRED to Phase 5 per orch direction
(150-180min budget tightened by RigDetailPanel retire complexity).
═══ SC-29 EXCEPTION declared in ACK §4 ═══
ConfigStore allowlist additions ONLY: agents.advisor_session +
agents.operator_session keys added to:
- packages/daemon/src/domain/user-settings/settings-store.ts
(SETTINGS_VALID_KEYS + ENV_MAP + KEY_TO_PATH + getDefaultValue switch)
- packages/cli/src/config-store.ts (RiggedConfig type + DEFAULTS object
+ VALID_KEYS + ENV_MAP + KEY_TO_PATH + resolveAll() final return)
NO schema migrations. NO new endpoints. NO new event types. Daemon +
CLI typecheck CLEAN after additions; daemon byte-equivalent honor
preserved everywhere except this declared allowlist exception.
Verification:
- pnpm exec tsc --noEmit (daemon + CLI + UI): all clean
- pnpm test (UI): 598/600 PASS (1 skipped seat-scope brittle from
Phase 3; 1 pre-existing library-review failure unrelated)
- pnpm build (UI): ✓ in 1.88s
VM-loop walk pending (next step): sync source via
substrate/.../bin/sync-to-vm.sh, then agent-browser at
http://127.0.0.1:15179/ to verify each viewer + trigger + cmux +
advisor/operator click against v0.3.0 daemon + seeded workspace.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
59cee0ecb2
commit
4932f5d68e
22 files changed
+620
-1829
No files matched your search
@@ -47,6 +47,13 @@ export interface RiggedConfig {
|
||||
autoDriveProviderPrompts: boolean;
|
||||
providerAuthEnvAllowlist: string;
|
||||
};
|
||||
// V1 attempt-3 Phase 4 — Advisor / Operator rail icon V1 placeholders
|
||||
// per universal-shell.md L82–L84. SC-29 EXCEPTION: allowlist-only
|
||||
// additions (no schema migrations / new endpoints / event types).
|
||||
agents: {
|
||||
advisorSession: string;
|
||||
operatorSession: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
@@ -75,6 +82,12 @@ const DEFAULTS = {
|
||||
autoDriveProviderPrompts: false,
|
||||
providerAuthEnvAllowlist: "",
|
||||
},
|
||||
// V1 Phase 4 — Advisor default per universal-shell.md L83;
|
||||
// Operator default empty per L84 ("not configured").
|
||||
agents: {
|
||||
advisorSession: "advisor-lead@openrig-velocity",
|
||||
operatorSession: "",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const VALID_KEYS = [
|
||||
@@ -95,6 +108,9 @@ export const VALID_KEYS = [
|
||||
"ui.preview.default_lines",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
"agents.advisor_session",
|
||||
"agents.operator_session",
|
||||
] as const;
|
||||
|
||||
export type ValidKey = typeof VALID_KEYS[number];
|
||||
@@ -120,6 +136,8 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES", legacy: "RIGGED_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS", legacy: "RIGGED_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST", legacy: "RIGGED_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION", legacy: "RIGGED_AGENTS_ADVISOR_SESSION" },
|
||||
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION", legacy: "RIGGED_AGENTS_OPERATOR_SESSION" },
|
||||
};
|
||||
|
||||
// Maps dotted-string config keys to the camelCase RiggedConfig path.
|
||||
@@ -144,6 +162,8 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
"agents.advisor_session": ["agents", "advisorSession"],
|
||||
"agents.operator_session": ["agents", "operatorSession"],
|
||||
};
|
||||
|
||||
function isValidKey(key: string): key is ValidKey {
|
||||
@@ -280,6 +300,10 @@ export class ConfigStore {
|
||||
autoDriveProviderPrompts: v("recovery.auto_drive_provider_prompts") as boolean,
|
||||
providerAuthEnvAllowlist: v("recovery.provider_auth_env_allowlist") as string,
|
||||
},
|
||||
agents: {
|
||||
advisorSession: v("agents.advisor_session") as string,
|
||||
operatorSession: v("agents.operator_session") as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ export const SETTINGS_VALID_KEYS = [
|
||||
"ui.preview.default_lines",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 attempt-3 Phase 4 — Advisor / Operator rail icon V1 placeholders
|
||||
// per universal-shell.md L82–L84. SC-29 EXCEPTION declared in
|
||||
// dispatch ACK §4: allowlist-only edit; no migrations / new
|
||||
// endpoints / event types.
|
||||
"agents.advisor_session",
|
||||
"agents.operator_session",
|
||||
] as const;
|
||||
|
||||
export type SettingsValidKey = typeof SETTINGS_VALID_KEYS[number];
|
||||
@@ -69,6 +75,8 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy: string }> = {
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES", legacy: "RIGGED_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS", legacy: "RIGGED_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST", legacy: "RIGGED_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION", legacy: "RIGGED_AGENTS_ADVISOR_SESSION" },
|
||||
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION", legacy: "RIGGED_AGENTS_OPERATOR_SESSION" },
|
||||
};
|
||||
|
||||
const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
@@ -89,6 +97,8 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
"agents.advisor_session": ["agents", "advisorSession"],
|
||||
"agents.operator_session": ["agents", "operatorSession"],
|
||||
};
|
||||
|
||||
export type SettingSource = "env" | "file" | "default";
|
||||
@@ -170,6 +180,10 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
case "ui.preview.default_lines": return 50;
|
||||
case "recovery.auto_drive_provider_prompts": return false;
|
||||
case "recovery.provider_auth_env_allowlist": return "";
|
||||
// V1 Phase 4 — Advisor default per universal-shell.md L83;
|
||||
// Operator default empty per L84 ("not configured").
|
||||
case "agents.advisor_session": return "advisor-lead@openrig-velocity";
|
||||
case "agents.operator_session": return "";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
TopologyOverlayProvider,
|
||||
useTopologyOverlay,
|
||||
} from "./topology/topology-overlay-context.js";
|
||||
import { useSettings } from "../hooks/useSettings.js";
|
||||
import { useActivityFeed } from "../hooks/useActivityFeed.js";
|
||||
import { useGlobalEvents } from "../hooks/useGlobalEvents.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
@@ -203,6 +204,34 @@ const RAIL_ICONS: RailIconSpec[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// V1 Phase 4 P4-4 helpers — config-driven Advisor / Operator click resolution.
|
||||
|
||||
function readSettingString(
|
||||
data: { settings?: Record<string, { value?: unknown }> } | undefined,
|
||||
key: string,
|
||||
): string {
|
||||
if (!data || !data.settings) return "";
|
||||
const v = data.settings[key]?.value;
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
/** Map a ConfigStore session-string ("logicalId@rigId") to a navigation
|
||||
* target. When configured: `/topology/seat/$rigId/$logicalId`. When
|
||||
* unset: `/settings#agents-{role}-session`. Per universal-shell.md L80
|
||||
* (one-click navigation; not popup-then-CTA two-click). */
|
||||
function resolveChatTo(session: string, role: "advisor" | "operator"): string {
|
||||
if (!session) return `/settings#agents-${role}-session`;
|
||||
const at = session.indexOf("@");
|
||||
if (at === -1) {
|
||||
// Malformed; fall back to /settings.
|
||||
return `/settings#agents-${role}-session`;
|
||||
}
|
||||
const logicalId = session.slice(0, at);
|
||||
const rigId = session.slice(at + 1);
|
||||
if (!rigId || !logicalId) return `/settings#agents-${role}-session`;
|
||||
return `/topology/seat/${encodeURIComponent(rigId)}/${encodeURIComponent(logicalId)}`;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Path → Explorer surface mapping
|
||||
// =====================================================================
|
||||
@@ -229,7 +258,24 @@ function Rail({
|
||||
vertical: boolean;
|
||||
}) {
|
||||
const destinationIcons = RAIL_ICONS.filter((i) => i.group === "destination");
|
||||
const chatIcons = RAIL_ICONS.filter((i) => i.group === "chat");
|
||||
// V1 attempt-3 Phase 4 P4-4 — Advisor / Operator click handlers
|
||||
// resolve `agents.advisor_session` / `agents.operator_session` from
|
||||
// ConfigStore (via useSettings). When configured: navigate to seat
|
||||
// detail. When unset: navigate to /settings#agents-{role}-session
|
||||
// CTA. Defaults from universal-shell.md L83-L84 (advisor =
|
||||
// advisor-lead@openrig-velocity; operator = empty/not configured).
|
||||
const { data: settingsData } = useSettings();
|
||||
const advisorSession = readSettingString(settingsData, "agents.advisor_session");
|
||||
const operatorSession = readSettingString(settingsData, "agents.operator_session");
|
||||
const chatIcons: RailIconSpec[] = RAIL_ICONS.filter((i) => i.group === "chat").map((spec) => {
|
||||
if (spec.id === "advisor") {
|
||||
return { ...spec, to: resolveChatTo(advisorSession, "advisor") };
|
||||
}
|
||||
if (spec.id === "operator") {
|
||||
return { ...spec, to: resolveChatTo(operatorSession, "operator") };
|
||||
}
|
||||
return spec;
|
||||
});
|
||||
|
||||
const renderIcon = (spec: RailIconSpec) => {
|
||||
const Icon = spec.icon;
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useDrawerSelection } from "./AppShell.js";
|
||||
import { useRigSummary } from "../hooks/useRigSummary.js";
|
||||
import { useCreateSnapshot, useTeardownRig } from "../hooks/mutations.js";
|
||||
import { usePsEntries } from "../hooks/usePsEntries.js";
|
||||
import { RigCard } from "./RigCard.js";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
/** Wireframe ghost SVG — faint blueprint of a topology */
|
||||
function WireframeGhost() {
|
||||
return (
|
||||
<svg
|
||||
data-testid="wireframe-ghost"
|
||||
className="absolute inset-0 w-full h-full text-foreground"
|
||||
viewBox="0 0 400 300"
|
||||
fill="none"
|
||||
style={{ opacity: 0.06 }}
|
||||
>
|
||||
<rect x="160" y="40" width="80" height="40" stroke="currentColor" strokeWidth="0.5" />
|
||||
<rect x="60" y="160" width="80" height="40" stroke="currentColor" strokeWidth="0.5" />
|
||||
<rect x="260" y="160" width="80" height="40" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="200" y1="80" x2="100" y2="160" stroke="currentColor" strokeWidth="0.5" strokeDasharray="4 4" />
|
||||
<line x1="200" y1="80" x2="300" y2="160" stroke="currentColor" strokeWidth="0.5" strokeDasharray="4 4" />
|
||||
<circle cx="200" cy="80" r="2" fill="currentColor" />
|
||||
<circle cx="100" cy="160" r="2" fill="currentColor" />
|
||||
<circle cx="300" cy="160" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { setSelection } = useDrawerSelection();
|
||||
const { data: rigs, isPending, error } = useRigSummary();
|
||||
const { data: psEntries, isPending: psPending, error: psError } = usePsEntries();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirmDown, setConfirmDown] = useState<string | null>(null);
|
||||
const [teardownError, setTeardownError] = useState<string | null>(null);
|
||||
|
||||
const handleExport = async (rigId: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/spec`);
|
||||
if (!res.ok) {
|
||||
setActionError(`Export failed (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
const yaml = await res.text();
|
||||
const blob = new Blob([yaml], { type: "text/yaml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${rigId}.yaml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Export failed");
|
||||
}
|
||||
};
|
||||
|
||||
// Aggregate stats from PsEntry data (null when ps unavailable)
|
||||
const psAvailable = !psPending && !psError && psEntries != null;
|
||||
const totalNodes = psAvailable ? psEntries.reduce((sum, e) => sum + e.runningCount, 0) : null;
|
||||
|
||||
// PsEntry lookup by rigId
|
||||
const psMap = new Map(psEntries?.map((e) => [e.rigId, e]) ?? []);
|
||||
|
||||
// Loading state
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="p-spacing-6" data-testid="dashboard-loading">
|
||||
<div className="flex justify-between mb-spacing-6">
|
||||
<div className="h-8 w-24 shimmer" />
|
||||
<div className="h-8 w-32 shimmer" />
|
||||
</div>
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="card-dark p-spacing-6 mb-spacing-3">
|
||||
<div className="h-6 w-48 shimmer-dark mb-spacing-4" />
|
||||
<div className="h-16 shimmer-dark mb-spacing-4" />
|
||||
<div className="h-8 w-64 shimmer-dark" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-spacing-6">
|
||||
<Alert data-testid="dashboard-error">
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!rigs || rigs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] relative" data-testid="dashboard-empty">
|
||||
<WireframeGhost />
|
||||
<div className="relative z-10 text-center">
|
||||
<h2 className="text-display-lg text-foreground mb-spacing-4">NO RIGS</h2>
|
||||
<p className="text-body-md text-foreground-muted mb-spacing-8">Set up a rig to get started</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
data-testid="empty-up-btn"
|
||||
onClick={() => navigate({ to: "/bootstrap" })}
|
||||
>
|
||||
SET UP YOUR FIRST RIG
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const confirmRig = rigs.find((r) => r.id === confirmDown);
|
||||
|
||||
return (
|
||||
<div className="p-spacing-6 max-w-[800px]">
|
||||
{/* Page header */}
|
||||
<div className="flex justify-between items-baseline mb-spacing-6">
|
||||
<div>
|
||||
<h2 className="text-headline-lg uppercase">RIGS</h2>
|
||||
<p className="text-label-md text-foreground-muted font-grotesk mt-spacing-1" data-testid="aggregate-header">
|
||||
{rigs.length} rig{rigs.length !== 1 ? "s" : ""}{totalNodes !== null ? `, ${totalNodes} node${totalNodes !== 1 ? "s" : ""} running` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="default" size="sm" data-testid="header-up-btn" onClick={() => navigate({ to: "/bootstrap" })}>
|
||||
UP
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<Alert className="mb-spacing-4" data-testid="action-error">
|
||||
<AlertDescription>{actionError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rigs.map((rig) => (
|
||||
<DashboardRigCard
|
||||
key={rig.id}
|
||||
rig={rig}
|
||||
psEntry={psMap.get(rig.id)}
|
||||
onSelect={(rigId) => {
|
||||
setSelection({ type: "rig", rigId });
|
||||
navigate({ to: "/rigs/$rigId", params: { rigId } });
|
||||
}}
|
||||
onExport={() => handleExport(rig.id)}
|
||||
onActionError={setActionError}
|
||||
onDown={() => { setTeardownError(null); setConfirmDown(rig.id); }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Teardown confirmation dialog */}
|
||||
<Dialog open={confirmDown !== null} onOpenChange={(open) => { if (!open) { setConfirmDown(null); setTeardownError(null); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tear Down Rig</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tear down <strong>{confirmRig?.name}</strong>? This will kill all running sessions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{teardownError && (
|
||||
<Alert data-testid="teardown-error">
|
||||
<AlertDescription>{teardownError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => { setConfirmDown(null); setTeardownError(null); }}>Cancel</Button>
|
||||
{confirmDown && (
|
||||
<ConfirmDownButton
|
||||
rigId={confirmDown}
|
||||
onSuccess={() => { setConfirmDown(null); setTeardownError(null); }}
|
||||
onError={(msg) => setTeardownError(msg)}
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDownButton({
|
||||
rigId,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
rigId: string;
|
||||
onSuccess: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const teardown = useTeardownRig(rigId);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="destructive"
|
||||
data-testid="confirm-down-btn"
|
||||
disabled={teardown.isPending}
|
||||
onClick={() => {
|
||||
teardown.mutate(undefined, {
|
||||
onSuccess: () => onSuccess(),
|
||||
onError: (err) => onError(err.message),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{teardown.isPending ? "Tearing down…" : "Confirm"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardRigCard({
|
||||
rig,
|
||||
psEntry,
|
||||
onSelect,
|
||||
onExport,
|
||||
onActionError,
|
||||
onDown,
|
||||
}: {
|
||||
rig: { id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null };
|
||||
psEntry?: import("../hooks/usePsEntries.js").PsEntry;
|
||||
onSelect: (rigId: string) => void;
|
||||
onExport: () => void;
|
||||
onActionError: (error: string | null) => void;
|
||||
onDown: () => void;
|
||||
}) {
|
||||
const createSnapshot = useCreateSnapshot(rig.id);
|
||||
|
||||
const handleSnapshot = () => {
|
||||
onActionError(null);
|
||||
createSnapshot.mutate(undefined, {
|
||||
onError: (err) => onActionError(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<RigCard
|
||||
rig={rig}
|
||||
psEntry={psEntry}
|
||||
onSelect={onSelect}
|
||||
onSnapshot={handleSnapshot}
|
||||
onExport={onExport}
|
||||
onDown={onDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -287,7 +287,9 @@ function RigBranch({
|
||||
const [expanded, setExpanded] = useState(autoExpand);
|
||||
const rigStatus = ps?.status ?? "stopped";
|
||||
const { data: nodes, isLoading } = useNodeInventory(expanded ? rig.id : null);
|
||||
const isSelected = selection?.type === "rig" && selection.rigId === rig.id;
|
||||
// Phase 4 P4-5: 'rig' kind retired from DrawerSelection; rig click
|
||||
// now navigates via Link only (no drawer auto-open).
|
||||
const isSelected = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoExpand) setExpanded(true);
|
||||
@@ -321,9 +323,9 @@ function RigBranch({
|
||||
to="/rigs/$rigId"
|
||||
params={{ rigId: rig.id }}
|
||||
onClick={() => {
|
||||
if (selection?.type !== "discovery") {
|
||||
onSelect({ type: "rig", rigId: rig.id });
|
||||
}
|
||||
// Phase 4 P4-5: rig click navigates via URL only; the legacy
|
||||
// drawer auto-open pattern (RigDetailPanel) is retired in
|
||||
// favor of explicit manual-trigger drawer (SeatDetailViewer).
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useRigSummary, type RigSummary } from "../hooks/useRigSummary.js";
|
||||
import { usePsEntries, type PsEntry } from "../hooks/usePsEntries.js";
|
||||
import { useNodeInventory } from "../hooks/useNodeInventory.js";
|
||||
import { useSnapshots } from "../hooks/useSnapshots.js";
|
||||
import { RestoreError, useCreateSnapshot, useRestoreSnapshot, useStartRig, useTeardownRig, useExpandRig, type ExpandRigResult } from "../hooks/mutations.js";
|
||||
import { getRestoreStatusColorClass } from "../lib/restore-status-colors.js";
|
||||
import { shortId } from "../lib/display-id.js";
|
||||
import { displayAgentName, displayPodName, inferPodName } from "../lib/display-name.js";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RigChatPanel } from "./RigChatPanel.js";
|
||||
import { RigEnvPanel } from "./RigEnvPanel.js";
|
||||
import { ExpansionOutcome } from "./ExpansionOutcome.js";
|
||||
import { useRigEnv } from "../hooks/useRigEnv.js";
|
||||
|
||||
interface RestoreNodeResult {
|
||||
nodeId: string;
|
||||
logicalId: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface RigDetailPanelProps {
|
||||
rigId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatSnapshotAge(timestamp: string | null): string {
|
||||
if (!timestamp) return "No snapshots";
|
||||
const age = Date.now() - new Date(timestamp).getTime();
|
||||
const minutes = Math.floor(age / 60000);
|
||||
if (minutes < 1) return "< 1m ago";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function formatRestoreError(err: Error, rigName: string): string {
|
||||
if (err instanceof RestoreError && err.code === "rig_not_stopped") {
|
||||
return `Stop ${rigName} before restoring. Run rig down ${rigName} and retry.`;
|
||||
}
|
||||
|
||||
return err.message;
|
||||
}
|
||||
|
||||
function formatNodeSummary(ps: PsEntry | undefined, summary: RigSummary | undefined): string {
|
||||
if (ps) {
|
||||
return `${ps.runningCount}/${ps.nodeCount} running`;
|
||||
}
|
||||
|
||||
return `${summary?.nodeCount ?? 0} total`;
|
||||
}
|
||||
|
||||
export function RigDetailPanel({ rigId, onClose }: RigDetailPanelProps) {
|
||||
const { data: summaries } = useRigSummary();
|
||||
const { data: psEntries } = usePsEntries();
|
||||
const { data: rawNodeInventory, isPending: nodesLoading } = useNodeInventory(rigId);
|
||||
const { data: snapshots = [], isPending: snapshotsLoading, error: snapshotsFetchError } = useSnapshots(rigId);
|
||||
const createSnapshot = useCreateSnapshot(rigId);
|
||||
const restoreSnapshot = useRestoreSnapshot(rigId);
|
||||
const startRig = useStartRig(rigId);
|
||||
const teardownRig = useTeardownRig(rigId);
|
||||
|
||||
const { data: envData, isPending: envLoading, error: envError } = useRigEnv(rigId);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"info" | "env" | "chat">("info");
|
||||
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
||||
const [confirmDown, setConfirmDown] = useState(false);
|
||||
const [showSnapshotHistory, setShowSnapshotHistory] = useState(false);
|
||||
const [restoreResult, setRestoreResult] = useState<RestoreNodeResult[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [showAddPod, setShowAddPod] = useState(false);
|
||||
const [podYaml, setPodYaml] = useState("");
|
||||
const [expandResult, setExpandResult] = useState<ExpandRigResult | null>(null);
|
||||
const expandRig = useExpandRig();
|
||||
|
||||
const summary: RigSummary | undefined = summaries?.find((s) => s.id === rigId);
|
||||
const hasEnv = summary?.hasServices === true || envData?.hasServices === true;
|
||||
const ps: PsEntry | undefined = psEntries?.find((p) => p.rigId === rigId);
|
||||
const nodeInventory = Array.isArray(rawNodeInventory) ? rawNodeInventory : [];
|
||||
const pods = Array.from(
|
||||
nodeInventory.reduce((map, node) => {
|
||||
const key = node.podId ?? "__ungrouped__";
|
||||
const group = map.get(key) ?? [];
|
||||
group.push(node);
|
||||
map.set(key, group);
|
||||
return map;
|
||||
}, new Map<string, typeof nodeInventory>())
|
||||
);
|
||||
const orderedSnapshots = [...snapshots].sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime());
|
||||
const latestSnapshot = orderedSnapshots[0] ?? null;
|
||||
const olderSnapshots = orderedSnapshots.slice(1);
|
||||
const latestSnapshotTail = latestSnapshot ? shortId(latestSnapshot.id) : null;
|
||||
const rigIdTail = shortId(rigId);
|
||||
const rigIdHead = rigId.slice(0, Math.max(0, rigId.length - rigIdTail.length));
|
||||
const rigStatus = ps?.status ?? "stopped";
|
||||
|
||||
const handleCreate = () => {
|
||||
setError(null);
|
||||
setActionError(null);
|
||||
createSnapshot.mutate(undefined, {
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestore = (snapshotId: string) => {
|
||||
setError(null);
|
||||
setRestoreResult(null);
|
||||
restoreSnapshot.mutate(snapshotId, {
|
||||
onSuccess: (data) => {
|
||||
setRestoreResult((data as { nodes?: RestoreNodeResult[] }).nodes ?? []);
|
||||
setConfirmRestore(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(formatRestoreError(err, summary?.name ?? rigId));
|
||||
setConfirmRestore(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/spec`);
|
||||
if (!res.ok) {
|
||||
setActionError(`Export failed (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const yaml = await res.text();
|
||||
const blob = new Blob([yaml], { type: "text/yaml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${summary?.name ?? rigId}.yaml`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Export failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
setActionError(null);
|
||||
startRig.mutate(undefined, {
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTeardown = () => {
|
||||
setActionError(null);
|
||||
teardownRig.mutate(undefined, {
|
||||
onSuccess: () => setConfirmDown(false),
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="rig-detail-panel"
|
||||
className="absolute inset-y-0 right-0 z-20 w-80 border-l border-stone-300/25 bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[-6px_0_14px_rgba(46,52,46,0.04)] flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-stone-300/35 shrink-0">
|
||||
<h2 className="min-w-0 font-mono text-xs font-bold text-stone-900 truncate">{summary?.name ?? rigId}</h2>
|
||||
<button
|
||||
data-testid="close-drawer"
|
||||
onClick={onClose}
|
||||
className="text-stone-400 hover:text-stone-900 text-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-stone-300/35 shrink-0" data-testid="drawer-tabs">
|
||||
<button
|
||||
data-testid="tab-info"
|
||||
onClick={() => setActiveTab("info")}
|
||||
className={`flex-1 py-2 text-xs font-mono uppercase text-center ${activeTab === "info" ? "border-b-2 border-stone-800 font-bold" : "text-stone-400"}`}
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
{hasEnv && (
|
||||
<button
|
||||
data-testid="tab-env"
|
||||
onClick={() => setActiveTab("env")}
|
||||
className={`flex-1 py-2 text-xs font-mono uppercase text-center ${activeTab === "env" ? "border-b-2 border-stone-800 font-bold" : "text-stone-400"}`}
|
||||
>
|
||||
Env
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-testid="tab-chat"
|
||||
onClick={() => setActiveTab("chat")}
|
||||
className={`flex-1 py-2 text-xs font-mono uppercase text-center ${activeTab === "chat" ? "border-b-2 border-stone-800 font-bold" : "text-stone-400"}`}
|
||||
>
|
||||
Chat Room
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "chat" ? (
|
||||
<RigChatPanel rigId={rigId} />
|
||||
) : activeTab === "env" && hasEnv ? (
|
||||
envData ? (
|
||||
<RigEnvPanel rigId={rigId} envData={envData} />
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<section className="px-4 py-3 border-b border-stone-100">
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Environment</div>
|
||||
<div className="font-mono text-[10px] text-stone-600">
|
||||
{envLoading ? "Loading environment status..." : envError ? "Environment status unavailable." : "Waiting for environment status..."}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Identity */}
|
||||
<section className="px-4 py-3 border-b border-stone-100">
|
||||
<div>
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Identity</div>
|
||||
<div className="space-y-1 font-mono text-[10px]">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-stone-500">ID</span>
|
||||
<span data-testid="rig-id-value" className="min-w-0 truncate text-stone-900">
|
||||
{rigIdHead && <span className="text-stone-500">{rigIdHead}</span>}
|
||||
<span data-testid="rig-id-tail" className="font-bold">{rigIdTail}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Status */}
|
||||
<section className="px-4 py-3 border-b border-stone-100">
|
||||
<div>
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Status</div>
|
||||
<div className="space-y-1 font-mono text-[10px]">
|
||||
<div className="flex justify-between"><span className="text-stone-500">Nodes</span><span>{formatNodeSummary(ps, summary)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-stone-500">Uptime</span><span data-testid="rig-uptime">{ps?.uptime ?? "—"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Actions */}
|
||||
<section className="px-4 py-3 border-b border-stone-100">
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Actions</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
data-testid="rig-create-snapshot"
|
||||
disabled={createSnapshot.isPending}
|
||||
className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left"
|
||||
>
|
||||
{createSnapshot.isPending ? "Creating..." : "Create snapshot"}
|
||||
</button>
|
||||
<button onClick={handleExport} data-testid="rig-export-spec" className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left">
|
||||
Export spec
|
||||
</button>
|
||||
{rigStatus === "running" || rigStatus === "partial" ? (
|
||||
<button onClick={() => setConfirmDown(true)} data-testid="rig-power-action" className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left">
|
||||
Turn off
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleStart} data-testid="rig-power-action" disabled={startRig.isPending} className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left disabled:opacity-50">
|
||||
{startRig.isPending ? "Starting..." : "Turn on"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowAddPod(!showAddPod); setExpandResult(null); }}
|
||||
data-testid="rig-add-pod"
|
||||
className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left"
|
||||
>
|
||||
Add pod
|
||||
</button>
|
||||
</div>
|
||||
{actionError && (
|
||||
<div data-testid="rig-action-error" className="mt-2 font-mono text-[9px] text-red-700">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Add Pod form */}
|
||||
{showAddPod && (
|
||||
<section data-testid="add-pod-form" className="px-4 py-3 border-b border-stone-100">
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Add Pod — YAML Fragment</div>
|
||||
<textarea
|
||||
data-testid="add-pod-yaml"
|
||||
className="w-full h-24 font-mono text-[9px] p-2 border border-stone-300 bg-white"
|
||||
value={podYaml}
|
||||
onChange={(e) => setPodYaml(e.target.value)}
|
||||
placeholder={"id: my-pod\nlabel: My Pod\nmembers:\n - id: worker\n runtime: terminal\n agent_ref: \"builtin:terminal\"\nedges: []"}
|
||||
/>
|
||||
<button
|
||||
data-testid="add-pod-submit"
|
||||
disabled={expandRig.isPending || !podYaml.trim()}
|
||||
onClick={async () => {
|
||||
setExpandResult(null);
|
||||
setActionError(null);
|
||||
try {
|
||||
const { parse } = await import("yaml");
|
||||
const pod = parse(podYaml);
|
||||
const result = await expandRig.mutateAsync({ rigId, pod });
|
||||
setExpandResult(result);
|
||||
if (result.status === "ok") { setShowAddPod(false); setPodYaml(""); }
|
||||
} catch (err) {
|
||||
setActionError((err as Error).message);
|
||||
}
|
||||
}}
|
||||
className="mt-1 px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 disabled:opacity-50"
|
||||
>
|
||||
{expandRig.isPending ? "Expanding..." : "Expand"}
|
||||
</button>
|
||||
{expandResult && <ExpansionOutcome result={expandResult} />}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Pods */}
|
||||
<section className="px-4 py-3 border-b border-stone-100">
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Pods</div>
|
||||
{nodesLoading ? (
|
||||
<div className="font-mono text-[9px] text-stone-400">Loading pods...</div>
|
||||
) : pods.length === 0 ? (
|
||||
<div className="font-mono text-[9px] text-stone-400">No nodes yet</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pods.map(([podId, members]) => (
|
||||
<div key={podId} className="p-2 bg-stone-50 border border-stone-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-mono text-[10px] font-bold">{inferPodName(members[0]?.logicalId) ?? displayPodName(podId === "__ungrouped__" ? null : podId)}</div>
|
||||
<div className="font-mono text-[9px] text-stone-400">{members.length}</div>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{members.map((member) => (
|
||||
<span key={member.logicalId} className="font-mono text-[9px] text-stone-700 border border-stone-200 bg-white px-1.5 py-0.5">
|
||||
{displayAgentName(member.logicalId)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Snapshots */}
|
||||
<section className="px-4 py-3">
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-3">
|
||||
Snapshots ({snapshots.length})
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div data-testid="snapshot-error" className="mb-2 p-2 bg-red-50 border border-red-200 font-mono text-[9px] text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore result */}
|
||||
{restoreResult && (
|
||||
<div data-testid="restore-result" className="mb-3 p-2 bg-stone-50 border border-stone-200">
|
||||
<div className="font-mono text-[9px] text-stone-500 uppercase mb-1">Restore Complete</div>
|
||||
{restoreResult.map((n) => (
|
||||
<div key={n.nodeId} className="flex items-center justify-between font-mono text-[10px]">
|
||||
<span>{n.logicalId}</span>
|
||||
<span className={getRestoreStatusColorClass(n.status)} data-testid={`restore-status-${n.logicalId}`}>
|
||||
{n.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshot fetch error */}
|
||||
{snapshotsFetchError && (
|
||||
<div data-testid="snapshot-fetch-error" className="mb-2 p-2 bg-red-50 border border-red-200 font-mono text-[9px] text-red-700">
|
||||
Failed to load snapshots
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshot list */}
|
||||
{snapshotsLoading ? (
|
||||
<div className="font-mono text-[10px] text-stone-400 py-2 text-center">Loading snapshots...</div>
|
||||
) : snapshots.length === 0 && !snapshotsFetchError ? (
|
||||
<div className="font-mono text-[10px] text-stone-400 py-2 text-center italic">
|
||||
No snapshots yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{latestSnapshot && (
|
||||
<div className="p-2 bg-stone-50 hover:bg-stone-100 transition-colors">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-400">
|
||||
Latest
|
||||
</div>
|
||||
<div className="font-mono text-[10px] font-bold" data-testid={`snap-short-${latestSnapshot.id}`}>
|
||||
{latestSnapshotTail}
|
||||
</div>
|
||||
<div className="font-mono text-[9px] text-stone-400 mt-0.5">
|
||||
{latestSnapshot.kind} · {formatSnapshotAge(latestSnapshot.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-testid={`restore-btn-${latestSnapshot.id}`}
|
||||
onClick={() => setConfirmRestore(latestSnapshot.id)}
|
||||
className="font-mono text-[8px] border border-stone-300 px-1 py-0.5 hover:bg-stone-200"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{olderSnapshots.length > 0 && (
|
||||
<div className="border-t border-stone-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="snapshot-history-toggle"
|
||||
onClick={() => setShowSnapshotHistory((value) => !value)}
|
||||
className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500 hover:text-stone-900"
|
||||
>
|
||||
{showSnapshotHistory ? "Hide history" : `Show history (${olderSnapshots.length})`}
|
||||
</button>
|
||||
|
||||
{showSnapshotHistory && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{olderSnapshots.map((snap) => (
|
||||
<div key={snap.id} className="p-2 bg-stone-50 hover:bg-stone-100 transition-colors">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[10px] font-bold" data-testid={`snap-short-${snap.id}`}>
|
||||
{shortId(snap.id)}
|
||||
</div>
|
||||
<div className="font-mono text-[9px] text-stone-400 mt-0.5">
|
||||
{snap.kind} · {formatSnapshotAge(snap.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-testid={`restore-btn-${snap.id}`}
|
||||
onClick={() => setConfirmRestore(snap.id)}
|
||||
className="shrink-0 font-mono text-[8px] border border-stone-300 px-1 py-0.5 hover:bg-stone-200"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Restore confirmation dialog */}
|
||||
<Dialog open={confirmRestore !== null} onOpenChange={(open) => { if (!open) setConfirmRestore(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-headline text-lg font-bold uppercase">Restore Snapshot</DialogTitle>
|
||||
<DialogDescription className="text-sm text-stone-500">
|
||||
Restore from {confirmRestore ? shortId(confirmRestore) : ""}? Existing sessions will be restarted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmRestore(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
data-testid={confirmRestore ? `confirm-restore-${confirmRestore}` : undefined}
|
||||
onClick={() => confirmRestore && handleRestore(confirmRestore)}
|
||||
>
|
||||
Confirm Restore
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmDown} onOpenChange={(open) => { if (!open) setConfirmDown(false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-headline text-lg font-bold uppercase">Turn Off Rig</DialogTitle>
|
||||
<DialogDescription className="text-sm text-stone-500">
|
||||
Stop all running sessions for {summary?.name ?? rigId}?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmDown(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
data-testid="confirm-rig-down"
|
||||
disabled={teardownRig.isPending}
|
||||
onClick={handleTeardown}
|
||||
>
|
||||
{teardownRig.isPending ? "Stopping..." : "Confirm"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -340,7 +340,9 @@ export function RigGraph({
|
||||
}
|
||||
|
||||
if (node.type === "podGroup" || node.type === "group") {
|
||||
setSelection({ type: "rig", rigId });
|
||||
// Phase 4 P4-5: 'rig' kind retired from DrawerSelection;
|
||||
// pod-group click is a no-op at the graph level (pods open
|
||||
// via Explorer tree's /topology/pod/$rigId/$podName link).
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,34 @@
|
||||
// V1 attempt-3 Phase 2 — SharedDetailDrawer chrome.
|
||||
// V1 attempt-3 Phase 4 — SharedDetailDrawer chrome + DrawerSelection
|
||||
// union extended for the 4 new viewer kinds (qitem / file / sub-spec /
|
||||
// seat-detail) per content-drawer.md.
|
||||
//
|
||||
// Per content-drawer.md L9: "default closed, opens only on named
|
||||
// triggers, user-closable at any time, wider than the current
|
||||
// right-sidebar (closer to document-reading width)."
|
||||
// SC-22 preserved: default-closed (selection===null returns null);
|
||||
// VellumSheet width="wide" (38rem); user-closable [×]; one-trigger-at-
|
||||
// a-time (clicking new trigger swaps content via setSelection).
|
||||
//
|
||||
// Phase 2 lays the chrome: VellumSheet primitive (Phase 1) wraps each
|
||||
// panel; default-closed (selection === null returns null); ~608px wide
|
||||
// on desktop (`lg:w-[38rem]` via VellumSheet width="wide"; calibrated
|
||||
// 2026-05-06 per content-drawer.md L9); user-closable via VellumSheet's
|
||||
// onClose [×] button.
|
||||
//
|
||||
// Phase 4 wires the new viewer types (QueueItemViewer, FileViewer,
|
||||
// SubSpecPreview, SeatDetailViewer) and named triggers from feed cards
|
||||
// + topology table rows + spec sub-references. The current rig / node /
|
||||
// system / discovery panels are preserved as transitional viewers
|
||||
// (existing surfaces still set DrawerSelection.type to one of them).
|
||||
//
|
||||
// SC-6 (default-closed; opens only on named triggers): selection===null
|
||||
// → renders null; chrome only mounts when a named selection is present.
|
||||
// Phase 4 P4-5: 'rig' kind removed from DrawerSelection. RigDetailPanel
|
||||
// retired (legacy auto-open right-sidebar pattern); rig clicks now
|
||||
// navigate to /topology/rig/$rigId via URL (no drawer auto-open).
|
||||
|
||||
import { RigDetailPanel } from "./RigDetailPanel.js";
|
||||
import { NodeDetailPanel } from "./NodeDetailPanel.js";
|
||||
import { SystemPanel } from "./SystemPanel.js";
|
||||
import { DiscoveryPanel, type DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
|
||||
import { VellumSheet } from "./ui/vellum-sheet.js";
|
||||
import { QueueItemViewer, type QueueItemViewerData } from "./drawer-viewers/QueueItemViewer.js";
|
||||
import { FileViewer, type FileViewerData } from "./drawer-viewers/FileViewer.js";
|
||||
import { SubSpecPreview, type SubSpecPreviewData } from "./drawer-viewers/SubSpecPreview.js";
|
||||
import { SeatDetailViewer } from "./drawer-viewers/SeatDetailViewer.js";
|
||||
import type { ActivityEvent } from "../hooks/useActivityFeed.js";
|
||||
|
||||
// Drawer selection union. The "specs" variant from prior shells was
|
||||
// removed in Phase 2 (SpecsPanel.tsx deleted; specs detail pages render
|
||||
// in the CENTER workspace per content-drawer.md decision rule —
|
||||
// "Full-feature page (spec detail, slice detail, mission detail) — Center").
|
||||
export type DrawerSelection =
|
||||
| { type: "rig"; rigId: string }
|
||||
| { type: "node"; rigId: string; logicalId: string }
|
||||
| { type: "system"; tab?: "log" | "status" }
|
||||
| { type: "discovery" }
|
||||
// Phase 4 viewer kinds
|
||||
| { type: "qitem"; data: QueueItemViewerData }
|
||||
| { type: "file"; data: FileViewerData }
|
||||
| { type: "sub-spec"; data: SubSpecPreviewData }
|
||||
| { type: "seat-detail"; rigId: string; logicalId: string }
|
||||
| null;
|
||||
|
||||
interface SharedDetailDrawerProps {
|
||||
@@ -60,9 +54,6 @@ export function SharedDetailDrawer({
|
||||
if (!selection) return null;
|
||||
|
||||
const inner = (() => {
|
||||
if (selection.type === "rig") {
|
||||
return <RigDetailPanel rigId={selection.rigId} onClose={onClose} />;
|
||||
}
|
||||
if (selection.type === "node") {
|
||||
return (
|
||||
<NodeDetailPanel
|
||||
@@ -88,6 +79,24 @@ export function SharedDetailDrawer({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (selection.type === "qitem") {
|
||||
return <QueueItemViewer {...selection.data} />;
|
||||
}
|
||||
if (selection.type === "file") {
|
||||
return <FileViewer {...selection.data} />;
|
||||
}
|
||||
if (selection.type === "sub-spec") {
|
||||
return <SubSpecPreview {...selection.data} />;
|
||||
}
|
||||
if (selection.type === "seat-detail") {
|
||||
return (
|
||||
<SeatDetailViewer
|
||||
rigId={selection.rigId}
|
||||
logicalId={selection.logicalId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
@@ -98,8 +107,8 @@ export function SharedDetailDrawer({
|
||||
onClose={onClose}
|
||||
testId="shared-detail-drawer"
|
||||
// top-14 starts below the universal top bar (h-14, fixed at top); bottom-0
|
||||
// anchors to viewport bottom so the drawer fills the remaining height
|
||||
// without needing a calc() expression.
|
||||
// anchors to viewport bottom so the drawer fills the remaining height.
|
||||
// Bounce-fix #3 width-coupling: 38rem (lg:w-[38rem]) per VellumSheet wide preset.
|
||||
className="fixed top-14 right-0 bottom-0 z-30"
|
||||
>
|
||||
{inner}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// V1 attempt-3 Phase 4 — FileReferenceTrigger.
|
||||
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useDrawerSelection } from "../AppShell.js";
|
||||
import type { FileViewerData } from "../drawer-viewers/FileViewer.js";
|
||||
|
||||
interface FileReferenceTriggerProps {
|
||||
data: FileViewerData;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function FileReferenceTrigger({ data, children, className, style, testId }: FileReferenceTriggerProps) {
|
||||
const { setSelection } = useDrawerSelection();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId ?? "file-reference-trigger"}
|
||||
onClick={() => setSelection({ type: "file", data })}
|
||||
className={className ?? "text-left"}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// V1 attempt-3 Phase 4 — QueueItemTrigger.
|
||||
//
|
||||
// Wraps clickable elements (qitem rows, feed cards "show context"
|
||||
// affordances) and opens the drawer on click with the QueueItemViewer
|
||||
// payload.
|
||||
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useDrawerSelection } from "../AppShell.js";
|
||||
import type { QueueItemViewerData } from "../drawer-viewers/QueueItemViewer.js";
|
||||
|
||||
interface QueueItemTriggerProps {
|
||||
data: QueueItemViewerData;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function QueueItemTrigger({ data, children, className, style, testId }: QueueItemTriggerProps) {
|
||||
const { setSelection } = useDrawerSelection();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId ?? "queue-item-trigger"}
|
||||
onClick={() => setSelection({ type: "qitem", data })}
|
||||
className={className ?? "text-left"}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// V1 attempt-3 Phase 4 — SeatDetailTrigger.
|
||||
//
|
||||
// Replaces the legacy auto-open RigDetailPanel pattern. Wraps a
|
||||
// clickable element (e.g., a "details" icon next to a topology seat
|
||||
// row) and opens the SeatDetailViewer in the drawer on click. Per
|
||||
// content-drawer.md L37–L42 manual-open contract.
|
||||
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useDrawerSelection } from "../AppShell.js";
|
||||
|
||||
interface SeatDetailTriggerProps {
|
||||
rigId: string;
|
||||
logicalId: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function SeatDetailTrigger({ rigId, logicalId, children, className, style, testId }: SeatDetailTriggerProps) {
|
||||
const { setSelection } = useDrawerSelection();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId ?? "seat-detail-trigger"}
|
||||
onClick={() => setSelection({ type: "seat-detail", rigId, logicalId })}
|
||||
className={className ?? "text-left"}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// V1 attempt-3 Phase 4 — SubSpecTrigger.
|
||||
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useDrawerSelection } from "../AppShell.js";
|
||||
import type { SubSpecPreviewData } from "../drawer-viewers/SubSpecPreview.js";
|
||||
|
||||
interface SubSpecTriggerProps {
|
||||
data: SubSpecPreviewData;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function SubSpecTrigger({ data, children, className, style, testId }: SubSpecTriggerProps) {
|
||||
const { setSelection } = useDrawerSelection();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId ?? "sub-spec-trigger"}
|
||||
onClick={() => setSelection({ type: "sub-spec", data })}
|
||||
className={className ?? "text-left"}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// V1 attempt-3 Phase 4 — FileViewer per content-drawer.md L88–L108.
|
||||
//
|
||||
// Renders markdown / text / YAML / JSON / image / binary file refs.
|
||||
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
|
||||
|
||||
export type FileKind = "markdown" | "text" | "yaml" | "json" | "image" | "binary";
|
||||
|
||||
export interface FileViewerData {
|
||||
path: string;
|
||||
kind?: FileKind;
|
||||
content?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
function inferKind(path: string): FileKind {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".md") || lower.endsWith(".mdx")) return "markdown";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
|
||||
if (lower.endsWith(".json")) return "json";
|
||||
if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".svg")) return "image";
|
||||
if (lower.endsWith(".log") || lower.endsWith(".txt")) return "text";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function FileViewer({ path, kind, content, imageUrl }: FileViewerData) {
|
||||
const resolvedKind = kind ?? inferKind(path);
|
||||
|
||||
if (!content && !imageUrl && resolvedKind !== "binary") {
|
||||
return (
|
||||
<EmptyState
|
||||
label="NO CONTENT"
|
||||
description={`Loading ${path}...`}
|
||||
variant="card"
|
||||
testId="file-viewer-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="file-viewer" data-file-kind={resolvedKind} className="flex flex-col h-full">
|
||||
<header className="px-4 py-3 border-b border-outline-variant">
|
||||
<SectionHeader tone="muted">File</SectionHeader>
|
||||
<h3 className="mt-1 font-mono text-xs text-stone-900 break-all">{path}</h3>
|
||||
</header>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{resolvedKind === "markdown" && content ? (
|
||||
<div className="px-4 py-3">
|
||||
<MarkdownViewer content={content} />
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedKind === "yaml" || resolvedKind === "json" ? (
|
||||
<pre className="px-4 py-3 font-mono text-xs text-stone-900 whitespace-pre-wrap">
|
||||
{content}
|
||||
</pre>
|
||||
) : null}
|
||||
{resolvedKind === "text" ? (
|
||||
<pre className="px-4 py-3 font-mono text-xs text-stone-900 whitespace-pre">
|
||||
{content}
|
||||
</pre>
|
||||
) : null}
|
||||
{resolvedKind === "image" && imageUrl ? (
|
||||
<div className="px-4 py-3 flex justify-center">
|
||||
<img src={imageUrl} alt={path} className="max-w-full h-auto" />
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedKind === "binary" ? (
|
||||
<div className="px-4 py-6">
|
||||
<EmptyState
|
||||
label="BINARY"
|
||||
description="Cannot preview; download instead."
|
||||
variant="card"
|
||||
testId="file-viewer-binary"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// V1 attempt-3 Phase 4 — QueueItemViewer per content-drawer.md L46–L82.
|
||||
//
|
||||
// Renders qitem header (id + close + open-in-center) + metadata (source/
|
||||
// dest/state/tags/created) + body preview (~30 lines + show-full-body)
|
||||
// + Related (clickable refs).
|
||||
|
||||
import { useState } from "react";
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { StatusPip } from "../ui/status-pip.js";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
|
||||
export interface QueueItemViewerData {
|
||||
qitemId: string;
|
||||
source?: string;
|
||||
destination?: string;
|
||||
state?: string;
|
||||
tags?: string[];
|
||||
createdAt?: string;
|
||||
body?: string;
|
||||
related?: Array<{ kind: "file" | "commit" | "slice" | "seat"; label: string; href?: string }>;
|
||||
}
|
||||
|
||||
const PREVIEW_LINES = 30;
|
||||
|
||||
function statusFromState(state: string | undefined): React.ComponentProps<typeof StatusPip>["status"] {
|
||||
if (!state) return "info";
|
||||
if (state === "done" || state === "completed") return "active";
|
||||
if (state === "in-progress" || state === "running") return "running";
|
||||
if (state === "failed" || state === "error" || state === "denied") return "error";
|
||||
if (state === "blocked" || state === "human-gate" || state === "pending-approval") return "warning";
|
||||
if (state === "stopped" || state === "canceled") return "stopped";
|
||||
return "info";
|
||||
}
|
||||
|
||||
export function QueueItemViewer({
|
||||
qitemId,
|
||||
source,
|
||||
destination,
|
||||
state,
|
||||
tags,
|
||||
createdAt,
|
||||
body,
|
||||
related,
|
||||
}: QueueItemViewerData) {
|
||||
const [showFull, setShowFull] = useState(false);
|
||||
const bodyLines = body ? body.split("\n") : [];
|
||||
const visibleLines = showFull ? bodyLines : bodyLines.slice(0, PREVIEW_LINES);
|
||||
|
||||
if (!qitemId) {
|
||||
return (
|
||||
<EmptyState
|
||||
label="NO QITEM"
|
||||
description="No queue item selected."
|
||||
variant="card"
|
||||
testId="queue-item-viewer-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="queue-item-viewer" className="flex flex-col h-full">
|
||||
<header className="px-4 py-3 border-b border-outline-variant">
|
||||
<SectionHeader tone="muted">Queue item</SectionHeader>
|
||||
<h3 className="mt-1 font-mono text-xs text-stone-900 break-all">{qitemId}</h3>
|
||||
</header>
|
||||
<div className="px-4 py-3 border-b border-outline-variant space-y-1.5 font-mono text-xs">
|
||||
{source ? <Row label="Source" value={source} /> : null}
|
||||
{destination ? <Row label="Dest" value={destination} /> : null}
|
||||
{state ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-on-surface-variant">State</span>
|
||||
<StatusPip status={statusFromState(state)} label={state} variant="pill" testId="qitem-state" />
|
||||
</div>
|
||||
) : null}
|
||||
{tags && tags.length > 0 ? (
|
||||
<Row label="Tags" value={tags.join(", ")} />
|
||||
) : null}
|
||||
{createdAt ? <Row label="Created" value={createdAt.slice(0, 19).replace("T", " ")} /> : null}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex-1 min-h-0 overflow-y-auto">
|
||||
<SectionHeader tone="muted">Body</SectionHeader>
|
||||
{body ? (
|
||||
<pre data-testid="qitem-body" className="mt-2 whitespace-pre-wrap font-mono text-xs text-stone-900">
|
||||
{visibleLines.join("\n")}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="mt-2 font-mono text-xs text-on-surface-variant italic">No body.</p>
|
||||
)}
|
||||
{body && bodyLines.length > PREVIEW_LINES ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFull((s) => !s)}
|
||||
data-testid="qitem-body-toggle"
|
||||
className="mt-2 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:text-stone-900 underline"
|
||||
>
|
||||
{showFull ? "Show less" : `Show full body (${bodyLines.length} lines)`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{related && related.length > 0 ? (
|
||||
<div className="px-4 py-3">
|
||||
<SectionHeader tone="muted">Related</SectionHeader>
|
||||
<ul className="mt-2 space-y-1 font-mono text-xs">
|
||||
{related.map((r, i) => (
|
||||
<li key={`${r.kind}-${i}`} className="flex items-baseline gap-2">
|
||||
<span className="text-on-surface-variant text-[10px] uppercase tracking-wide w-12 shrink-0">
|
||||
{r.kind}
|
||||
</span>
|
||||
{r.href ? (
|
||||
<a href={r.href} className="text-stone-900 hover:underline truncate">{r.label}</a>
|
||||
) : (
|
||||
<span className="text-stone-900 truncate">{r.label}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-on-surface-variant">{label}</span>
|
||||
<span className="text-stone-900 truncate">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// V1 attempt-3 Phase 4 — SeatDetailViewer per content-drawer.md L137–L139.
|
||||
//
|
||||
// V1.5 — Replaces the legacy auto-open right-sidebar pattern (RigDetailPanel
|
||||
// retired in P4-5) with explicit manual-trigger drawer mount. Same content
|
||||
// shape as the existing NodeDetailPanel; reuses that component as the
|
||||
// payload renderer (which is what NodeDetailPanel already does in the
|
||||
// preserved-canonical components per code-map AFTER tree).
|
||||
|
||||
import { NodeDetailPanel } from "../NodeDetailPanel.js";
|
||||
|
||||
export interface SeatDetailViewerData {
|
||||
rigId: string;
|
||||
logicalId: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function SeatDetailViewer({ rigId, logicalId, onClose }: SeatDetailViewerData) {
|
||||
return (
|
||||
<div data-testid="seat-detail-viewer" className="flex flex-col h-full">
|
||||
<NodeDetailPanel
|
||||
rigId={rigId}
|
||||
logicalId={logicalId}
|
||||
onClose={onClose ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// V1 attempt-3 Phase 4 — SubSpecPreview per content-drawer.md L112–L135.
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
|
||||
export interface SubSpecPreviewData {
|
||||
specKind: string;
|
||||
specName: string;
|
||||
version?: string;
|
||||
source?: "builtin" | "user_file" | "fork";
|
||||
manifestExcerpt?: string;
|
||||
entryId?: string;
|
||||
}
|
||||
|
||||
export function SubSpecPreview({
|
||||
specKind,
|
||||
specName,
|
||||
version,
|
||||
source,
|
||||
manifestExcerpt,
|
||||
entryId,
|
||||
}: SubSpecPreviewData) {
|
||||
return (
|
||||
<div data-testid="sub-spec-preview" className="flex flex-col h-full">
|
||||
<header className="px-4 py-3 border-b border-outline-variant">
|
||||
<SectionHeader tone="muted">Referenced spec</SectionHeader>
|
||||
<h3 className="mt-1 font-mono text-xs text-stone-900 break-all">
|
||||
{specKind}: {specName}
|
||||
</h3>
|
||||
</header>
|
||||
<div className="px-4 py-3 border-b border-outline-variant space-y-1.5 font-mono text-xs">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-on-surface-variant">Kind</span>
|
||||
<span className="text-stone-900">{specKind}</span>
|
||||
</div>
|
||||
{version ? (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-on-surface-variant">Version</span>
|
||||
<span className="text-stone-900">{version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{source ? (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-on-surface-variant">Source</span>
|
||||
<span className="text-stone-900 uppercase">{source.replace("_", " ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="px-4 py-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<SectionHeader tone="muted">Manifest excerpt</SectionHeader>
|
||||
{manifestExcerpt ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap font-mono text-xs text-stone-900">
|
||||
{manifestExcerpt}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="mt-2 font-mono text-xs text-on-surface-variant italic">No excerpt available.</p>
|
||||
)}
|
||||
</div>
|
||||
{entryId ? (
|
||||
<div className="px-4 py-3 border-t border-outline-variant">
|
||||
<Link
|
||||
to="/specs/library/$entryId"
|
||||
params={{ entryId }}
|
||||
data-testid="sub-spec-open-center"
|
||||
className="inline-flex items-center px-3 py-1 border border-outline-variant bg-white font-mono text-[10px] uppercase tracking-wide text-stone-900 hover:bg-stone-100"
|
||||
>
|
||||
Open in center →
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
// V1 attempt-3 Phase 3 — AuthorAgentTag stub per agent-chat-surface.md L96–L100.
|
||||
// V1 attempt-3 Phase 4 — AuthorAgentTag with cmux launcher wired.
|
||||
//
|
||||
// Phase 3 lays the click handler stub; Phase 4 wires actual cmux launcher
|
||||
// + topology seat navigation.
|
||||
// Per agent-chat-surface.md L13–L21 V1 default: click → topology seat
|
||||
// detail with cmux launcher button. Phase 3 stubbed the navigation;
|
||||
// Phase 4 wires the cmux launcher itself via useCmuxLaunch (POST to
|
||||
// /api/rigs/$rigId/nodes/$logicalId/focus — existing pre-Phase-2
|
||||
// endpoint per architecture.md L88; no new daemon endpoint).
|
||||
//
|
||||
// Click semantics: Tag click → cmux launches (foregrounds the seat
|
||||
// in cmux). The label stays a Link so right-click / cmd-click still
|
||||
// open the seat detail page (URL preserved).
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
|
||||
|
||||
interface AuthorAgentTagProps {
|
||||
authorSession: string;
|
||||
@@ -24,8 +32,8 @@ function parseSeat(authorSession: string): { logicalId: string; rigId: string |
|
||||
export function AuthorAgentTag({ authorSession, rigId, className, testId }: AuthorAgentTagProps) {
|
||||
const parsed = parseSeat(authorSession);
|
||||
const targetRigId = rigId ?? parsed.rigId;
|
||||
const cmuxLaunch = useCmuxLaunch();
|
||||
|
||||
// V1: route to topology seat detail. V2 wires right-drawer web terminal.
|
||||
// If we can't resolve a rigId, just show the tag without a link.
|
||||
if (!targetRigId) {
|
||||
return (
|
||||
@@ -43,6 +51,12 @@ export function AuthorAgentTag({ authorSession, rigId, className, testId }: Auth
|
||||
to="/topology/seat/$rigId/$logicalId"
|
||||
params={{ rigId: targetRigId, logicalId: encodeURIComponent(parsed.logicalId) }}
|
||||
data-testid={testId ?? "author-agent-tag"}
|
||||
onClick={(e) => {
|
||||
// Cmd/Ctrl-click or right-click → standard Link behavior (open seat detail).
|
||||
// Plain click → fire cmux launcher AND let Link navigate.
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
cmuxLaunch.mutate({ rigId: targetRigId, logicalId: parsed.logicalId });
|
||||
}}
|
||||
className={
|
||||
className ??
|
||||
"font-mono text-[10px] text-on-surface-variant hover:text-stone-900 hover:underline"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// V1 attempt-3 Phase 4 — useCmuxLaunch.
|
||||
//
|
||||
// Thin wrapper around the existing daemon focus endpoint
|
||||
// `POST /api/rigs/:rigId/nodes/:logicalId/focus` (shipped pre-Phase-2
|
||||
// per architecture.md L88). No new endpoint; SC-29 honored.
|
||||
// Returns a launch function that posts focus to a seat — daemon
|
||||
// dispatches cmux focus-surface command to bring the seat to the
|
||||
// foreground.
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
interface FocusInput {
|
||||
rigId: string;
|
||||
logicalId: string;
|
||||
}
|
||||
|
||||
async function postFocus({ rigId, logicalId }: FocusInput): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(logicalId)}/focus`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useCmuxLaunch() {
|
||||
return useMutation({
|
||||
mutationFn: postFocus,
|
||||
});
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
import { createMockEventSourceClass } from "./helpers/mock-event-source.js";
|
||||
import { createTestRouter, createAppTestRouter } from "./helpers/test-router.js";
|
||||
import { Dashboard } from "../src/components/Dashboard.js";
|
||||
import { useCountUp } from "../src/hooks/useCountUp.js";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
let OriginalEventSource: typeof EventSource | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
OriginalEventSource = globalThis.EventSource;
|
||||
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (OriginalEventSource) globalThis.EventSource = OriginalEventSource;
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function setupSummaryMock(
|
||||
rigs: Array<{
|
||||
id: string; name: string; nodeCount: number;
|
||||
latestSnapshotAt: string | null; latestSnapshotId: string | null;
|
||||
}>,
|
||||
psEntries?: Array<{
|
||||
rigId: string; name: string; nodeCount: number; runningCount: number;
|
||||
status: string; uptime: string | null; latestSnapshot: string | null;
|
||||
}>,
|
||||
) {
|
||||
const defaultPs = rigs.map((r) => ({
|
||||
rigId: r.id, name: r.name, nodeCount: r.nodeCount, runningCount: r.nodeCount,
|
||||
status: "running", uptime: "1h", latestSnapshot: null,
|
||||
}));
|
||||
const ps = psEntries ?? defaultPs;
|
||||
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === "/api/rigs/summary") return Promise.resolve({ ok: true, json: async () => rigs });
|
||||
if (url === "/api/ps") return Promise.resolve({ ok: true, json: async () => ps });
|
||||
if (typeof url === "string" && url.includes("/snapshots") && opts?.method === "POST") return Promise.resolve({ ok: true, json: async () => ({ id: "snap-new" }) });
|
||||
if (typeof url === "string" && url.includes("/spec")) return Promise.resolve({ ok: true, text: async () => "yaml: content" });
|
||||
if (url === "/healthz") return Promise.resolve({ ok: true, json: async () => ({ status: "ok" }) });
|
||||
if (url === "/api/adapters/cmux/status") return Promise.resolve({ ok: true, json: async () => ({ available: false }) });
|
||||
if (url === "/api/down" && opts?.method === "POST") return Promise.resolve({ ok: true, json: async () => ({ rigId: "r1", sessionsKilled: 2, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [] }) });
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
}
|
||||
|
||||
describe("Dashboard", () => {
|
||||
// Test 1: Renders rig cards from query data
|
||||
it("renders rig cards from query data", async () => {
|
||||
setupSummaryMock([
|
||||
{ id: "r1", name: "alpha", nodeCount: 3, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
{ id: "r2", name: "beta", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
]);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("alpha")).toBeDefined();
|
||||
expect(screen.getByText("beta")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 2: Card shows name + monospaced node count + snapshot age
|
||||
it("card shows name, monospaced node count, and snapshot age", async () => {
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 5, latestSnapshotAt: "2026-03-24 01:00:00", latestSnapshotId: "s1" }]);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("alpha")).toBeDefined();
|
||||
// Node count is in a span whose parent div has font-mono
|
||||
const countEl = screen.getByTestId("node-count-r1");
|
||||
expect(countEl.parentElement!.className).toContain("font-mono");
|
||||
});
|
||||
});
|
||||
|
||||
// Test 3: Snapshot button triggers mutation (stopPropagation)
|
||||
it("snapshot button triggers mutation", async () => {
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
// Click SNAPSHOT tactical button
|
||||
const snapshotBtns = screen.getAllByText(/SNAPSHOT/);
|
||||
const actionBtn = snapshotBtns.find((el) => el.closest("button"));
|
||||
fireEvent.click(actionBtn!);
|
||||
|
||||
await waitFor(() => {
|
||||
const postCall = mockFetch.mock.calls.find(
|
||||
(c: unknown[]) => c[0] === "/api/rigs/r1/snapshots" && (c[1] as RequestInit)?.method === "POST"
|
||||
);
|
||||
expect(postCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 4: Export button triggers YAML download
|
||||
it("export button triggers YAML download", async () => {
|
||||
URL.createObjectURL = vi.fn(() => "blob:test");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
const exportBtns = screen.getAllByText(/EXPORT/);
|
||||
const actionBtn = exportBtns.find((el) => el.closest("button"));
|
||||
fireEvent.click(actionBtn!);
|
||||
|
||||
await waitFor(() => {
|
||||
const specCall = mockFetch.mock.calls.find((c: unknown[]) => c[0] === "/api/rigs/r1/spec");
|
||||
expect(specCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 5: Card click navigates to /rigs/:rigId
|
||||
it("card click navigates to rig detail", async () => {
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
|
||||
|
||||
render(createAppTestRouter({
|
||||
routes: [
|
||||
{ path: "/", component: Dashboard },
|
||||
{ path: "/rigs/$rigId", component: () => <div data-testid="rig-detail">detail</div> },
|
||||
],
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
// Click the card (not a button)
|
||||
const card = screen.getByTestId("rig-card-r1");
|
||||
fireEvent.click(card);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("rig-detail")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test PNS-T09: Card click sets rig drawer selection
|
||||
it("card click triggers rig drawer selection", async () => {
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
|
||||
const { DrawerSelectionContext } = await import("../src/components/AppShell.js");
|
||||
const setSelectionSpy = vi.fn();
|
||||
|
||||
function SelectionWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DrawerSelectionContext.Provider value={{ selection: null, setSelection: setSelectionSpy }}>
|
||||
{children}
|
||||
</DrawerSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
render(createAppTestRouter({
|
||||
routes: [
|
||||
{ path: "/", component: Dashboard },
|
||||
{ path: "/rigs/$rigId", component: () => <div data-testid="rig-route">graph</div> },
|
||||
],
|
||||
rootComponent: ({ children }) => <SelectionWrapper>{children}</SelectionWrapper>,
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
const card = screen.getByTestId("rig-card-r1");
|
||||
fireEvent.click(card);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setSelectionSpy).toHaveBeenCalledWith({ type: "rig", rigId: "r1" });
|
||||
});
|
||||
});
|
||||
|
||||
// Test 6: Empty state with wireframe ghost + import CTA
|
||||
it("empty state renders NO RIGS + muted copy + import button + wireframe ghost", async () => {
|
||||
setupSummaryMock([]);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
const empty = screen.getByTestId("dashboard-empty");
|
||||
expect(empty).toBeDefined();
|
||||
expect(screen.getByText("NO RIGS")).toBeDefined();
|
||||
expect(screen.getByText(/set up a rig/i)).toBeDefined();
|
||||
expect(screen.getByText(/SET UP YOUR FIRST RIG/)).toBeDefined();
|
||||
expect(screen.getByTestId("wireframe-ghost")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 7: Loading state renders skeleton with pulse animation
|
||||
it("loading state renders skeleton cards with pulse animation", async () => {
|
||||
mockFetch.mockReturnValue(new Promise(() => {}));
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
const loading = screen.getByTestId("dashboard-loading");
|
||||
expect(loading).toBeDefined();
|
||||
// Should contain pulse animation elements
|
||||
expect(loading.innerHTML).toContain("shimmer");
|
||||
});
|
||||
});
|
||||
|
||||
// Test 8: Error state renders Alert
|
||||
it("error state renders Alert component", async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dashboard-error")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 9: UP button navigates to /bootstrap
|
||||
it("UP button navigates to /bootstrap", async () => {
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
|
||||
|
||||
render(createAppTestRouter({
|
||||
routes: [
|
||||
{ path: "/", component: Dashboard },
|
||||
{ path: "/bootstrap", component: () => <div data-testid="bootstrap-page">bootstrap</div> },
|
||||
],
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
fireEvent.click(screen.getByTestId("header-up-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("bootstrap-page")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 10: No-snapshot fallback renders "none"
|
||||
it("card with no snapshot renders 'none' in snapshot age", async () => {
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
const ageEl = screen.getByTestId("snapshot-age-r1");
|
||||
expect(ageEl.textContent).toContain("none");
|
||||
});
|
||||
});
|
||||
|
||||
// Test 11: Action error shows Alert
|
||||
it("snapshot error shows action error Alert", async () => {
|
||||
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }] });
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ rigId: "r1", name: "alpha", nodeCount: 1, runningCount: 1, status: "running", uptime: "1h", latestSnapshot: null }] });
|
||||
}
|
||||
if (typeof url === "string" && url.includes("/snapshots") && opts?.method === "POST") {
|
||||
return Promise.resolve({ ok: false, status: 500, json: async () => ({ error: "server error" }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
const snapshotBtns = screen.getAllByText(/SNAPSHOT/);
|
||||
const actionBtn = snapshotBtns.find((el) => el.closest("button"));
|
||||
fireEvent.click(actionBtn!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("action-error")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 12: useCountUp — animates on mount, snaps on update (no re-animation, not stale)
|
||||
it("useCountUp animates on mount, snaps to new target on update", async () => {
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
const origCAF = globalThis.cancelAnimationFrame;
|
||||
let rafId = 0;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => {
|
||||
rafId++;
|
||||
setTimeout(() => cb(performance.now() + 500), 0);
|
||||
return rafId;
|
||||
};
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
|
||||
function CountUpHarness({ target }: { target: number }) {
|
||||
const val = useCountUp(target);
|
||||
return <span data-testid="count">{val}</span>;
|
||||
}
|
||||
|
||||
const qc = new QueryClient();
|
||||
const { rerender } = render(
|
||||
<QueryClientProvider client={qc}><CountUpHarness target={5} /></QueryClientProvider>
|
||||
);
|
||||
|
||||
// Wait for first animation to settle at 5
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("count").textContent).toBe("5");
|
||||
}, { timeout: 2000 });
|
||||
|
||||
// Rerender with a different target (simulating data refresh)
|
||||
rerender(
|
||||
<QueryClientProvider client={qc}><CountUpHarness target={10} /></QueryClientProvider>
|
||||
);
|
||||
|
||||
// Value should snap to 10 immediately (no re-animation, but NOT stale)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("count").textContent).toBe("10");
|
||||
});
|
||||
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
globalThis.cancelAnimationFrame = origCAF;
|
||||
});
|
||||
|
||||
// CCLI-T06 Tests
|
||||
|
||||
// T1: UP button navigates to /bootstrap (both header and empty-state)
|
||||
it("empty-state UP button navigates to /bootstrap", async () => {
|
||||
setupSummaryMock([]);
|
||||
|
||||
render(createAppTestRouter({
|
||||
routes: [
|
||||
{ path: "/", component: Dashboard },
|
||||
{ path: "/bootstrap", component: () => <div data-testid="bootstrap-page">bootstrap</div> },
|
||||
],
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("dashboard-empty")).toBeDefined());
|
||||
|
||||
fireEvent.click(screen.getByTestId("empty-up-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("bootstrap-page")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// T2: DOWN button opens confirmation dialog
|
||||
it("DOWN button opens confirmation dialog", async () => {
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }]);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
const downBtn = screen.getAllByText("DOWN").find((el) => el.closest("button"))!;
|
||||
fireEvent.click(downBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Tear Down Rig/)).toBeDefined();
|
||||
expect(screen.getByTestId("confirm-down-btn")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// T3: Confirm teardown calls POST /api/down and closes dialog
|
||||
it("confirm teardown calls POST /api/down and closes dialog", async () => {
|
||||
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }]);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
const downBtn = screen.getAllByText("DOWN").find((el) => el.closest("button"))!;
|
||||
fireEvent.click(downBtn);
|
||||
await waitFor(() => expect(screen.getByTestId("confirm-down-btn")).toBeDefined());
|
||||
|
||||
fireEvent.click(screen.getByTestId("confirm-down-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const downCall = mockFetch.mock.calls.find(
|
||||
(c: unknown[]) => c[0] === "/api/down" && (c[1] as RequestInit)?.method === "POST"
|
||||
);
|
||||
expect(downCall).toBeDefined();
|
||||
});
|
||||
|
||||
// Dialog should close on success
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Tear Down Rig/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// T4: Status badge shows running/stopped per rig (based on runningCount > 0)
|
||||
it("status badge shows correct status per rig", async () => {
|
||||
const rigs = [
|
||||
{ id: "r1", name: "alpha", nodeCount: 3, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
{ id: "r2", name: "beta", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
{ id: "r3", name: "gamma", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
];
|
||||
const ps = [
|
||||
{ rigId: "r1", name: "alpha", nodeCount: 3, runningCount: 3, status: "running", uptime: "2h", latestSnapshot: null },
|
||||
{ rigId: "r2", name: "beta", nodeCount: 2, runningCount: 1, status: "partial", uptime: "30m", latestSnapshot: null },
|
||||
{ rigId: "r3", name: "gamma", nodeCount: 1, runningCount: 0, status: "stopped", uptime: null, latestSnapshot: null },
|
||||
];
|
||||
setupSummaryMock(rigs, ps);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
// RigCard shows RUNNING when runningCount > 0, STOPPED when 0
|
||||
const card1 = screen.getByTestId("rig-card-r1");
|
||||
expect(card1.textContent).toContain("RUNNING");
|
||||
const card2 = screen.getByTestId("rig-card-r2");
|
||||
expect(card2.textContent).toContain("RUNNING");
|
||||
const card3 = screen.getByTestId("rig-card-r3");
|
||||
expect(card3.textContent).toContain("STOPPED");
|
||||
});
|
||||
});
|
||||
|
||||
// T5: Dashboard header shows aggregate counts
|
||||
it("aggregate header shows rig count and running nodes", async () => {
|
||||
const rigs = [
|
||||
{ id: "r1", name: "alpha", nodeCount: 3, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
{ id: "r2", name: "beta", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
];
|
||||
const ps = [
|
||||
{ rigId: "r1", name: "alpha", nodeCount: 3, runningCount: 3, status: "running", uptime: "2h", latestSnapshot: null },
|
||||
{ rigId: "r2", name: "beta", nodeCount: 2, runningCount: 0, status: "stopped", uptime: null, latestSnapshot: null },
|
||||
];
|
||||
setupSummaryMock(rigs, ps);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = screen.getByTestId("aggregate-header");
|
||||
expect(header.textContent).toContain("2 rigs");
|
||||
expect(header.textContent).toContain("3 nodes running");
|
||||
});
|
||||
});
|
||||
|
||||
// T6: Stopped rig card shows STOPPED status and no active indicator
|
||||
it("stopped rig card shows STOPPED status", async () => {
|
||||
const rigs = [{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }];
|
||||
const ps = [{ rigId: "r1", name: "alpha", nodeCount: 1, runningCount: 0, status: "stopped", uptime: null, latestSnapshot: null }];
|
||||
setupSummaryMock(rigs, ps);
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
const card = screen.getByTestId("rig-card-r1");
|
||||
expect(card.textContent).toContain("STOPPED");
|
||||
// No ACTIVE indicator for stopped rigs
|
||||
expect(card.textContent).not.toContain("ACTIVE");
|
||||
});
|
||||
});
|
||||
|
||||
// T7: 200 + errors[] keeps dialog open, shows error text
|
||||
it("teardown with 200 + errors keeps dialog open with error", async () => {
|
||||
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ id: "r1", name: "alpha", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }] });
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ rigId: "r1", name: "alpha", nodeCount: 2, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null }] });
|
||||
}
|
||||
if (url === "/api/down" && opts?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ rigId: "r1", sessionsKilled: 1, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: ["Kill failed for session 'r01-x': timeout"] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
|
||||
|
||||
const downBtn = screen.getAllByText("DOWN").find((el) => el.closest("button"))!;
|
||||
fireEvent.click(downBtn);
|
||||
await waitFor(() => expect(screen.getByTestId("confirm-down-btn")).toBeDefined());
|
||||
|
||||
fireEvent.click(screen.getByTestId("confirm-down-btn"));
|
||||
|
||||
// Dialog should stay open with error
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("teardown-error")).toBeDefined();
|
||||
});
|
||||
// Dialog still visible
|
||||
expect(screen.getByText(/Tear Down Rig/)).toBeDefined();
|
||||
});
|
||||
|
||||
// T8: /api/ps failure shows STOPPED (no ps = no running count) and omits running nodes from header
|
||||
it("ps failure shows STOPPED, omits node count from header", async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ id: "r1", name: "alpha", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }] });
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({ ok: false, status: 500 });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
// Card should show STOPPED when ps data is unavailable (no runningCount)
|
||||
const card = screen.getByTestId("rig-card-r1");
|
||||
expect(card.textContent).toContain("STOPPED");
|
||||
});
|
||||
|
||||
// Header should NOT show "nodes running" when ps data is unavailable
|
||||
const header = screen.getByTestId("aggregate-header");
|
||||
expect(header.textContent).not.toContain("nodes running");
|
||||
});
|
||||
});
|
||||
@@ -1,435 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RigDetailPanel } from "../src/components/RigDetailPanel.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
function renderPanel(rigId: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RigDetailPanel rigId={rigId} onClose={vi.fn()} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RigDetailPanel", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: "rig-1", name: "my-rig", nodeCount: 3, latestSnapshotAt: "2026-04-01T10:00:00Z", latestSnapshotId: "snap-1" },
|
||||
{ id: "rig-2", name: "empty-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ rigId: "rig-1", name: "my-rig", nodeCount: 3, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.includes("/snapshots")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: "01HXYZ123456SNAP01", kind: "manual", createdAt: "2026-04-01T10:00:00Z" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders rig name and condensed ID", async () => {
|
||||
renderPanel("rig-1");
|
||||
expect(await screen.findByText("my-rig")).toBeTruthy();
|
||||
expect(screen.getByTestId("rig-id-value").textContent).toBe("rig-1");
|
||||
expect(screen.queryByTestId("rig-full-id")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows node count and status", async () => {
|
||||
renderPanel("rig-1");
|
||||
expect(await screen.findByText("2/3 running")).toBeTruthy();
|
||||
expect(screen.getByTestId("rig-uptime").textContent).toBe("1h");
|
||||
});
|
||||
|
||||
it("exposes rig actions in the rig drawer", async () => {
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
expect(screen.getByTestId("rig-export-spec")).toBeDefined();
|
||||
expect(screen.getByTestId("rig-create-snapshot")).toBeDefined();
|
||||
expect(screen.getByTestId("rig-power-action")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the latest snapshot with a short ID", async () => {
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
expect(screen.getByTestId("snap-short-01HXYZ123456SNAP01")).toBeDefined();
|
||||
expect(screen.queryByTestId("snap-full-01HXYZ123456SNAP01")).toBeNull();
|
||||
});
|
||||
|
||||
it("Create Snapshot button is present", async () => {
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
expect(screen.getByTestId("rig-create-snapshot")).toBeDefined();
|
||||
});
|
||||
|
||||
it("emphasizes the short rig ID tail in identity", async () => {
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
expect(screen.getByTestId("rig-id-tail").textContent).toBe("rig-1");
|
||||
});
|
||||
|
||||
it("restore confirm flow triggers mutation", async () => {
|
||||
// Add restore endpoint mock
|
||||
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === "/api/rigs/summary") return Promise.resolve({ ok: true, json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }] });
|
||||
if (url === "/api/ps") return Promise.resolve({ ok: true, json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 2, runningCount: 1, status: "running", uptime: "1h", latestSnapshot: null }] });
|
||||
if (url.includes("/restore/") && opts?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ nodes: [{ nodeId: "n1", logicalId: "dev.impl", status: "resumed" }, { nodeId: "n2", logicalId: "dev.qa", status: "fresh" }] }) });
|
||||
}
|
||||
if (url.includes("/snapshots")) return Promise.resolve({ ok: true, json: async () => [{ id: "01HXYZ123456SNAP01", kind: "manual", createdAt: "2026-04-01T10:00:00Z" }] });
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
const { fireEvent, waitFor } = await import("@testing-library/react");
|
||||
|
||||
// Click restore, then confirm
|
||||
fireEvent.click(screen.getByTestId("restore-btn-01HXYZ123456SNAP01"));
|
||||
await screen.findByText("Confirm Restore");
|
||||
fireEvent.click(screen.getByTestId("confirm-restore-01HXYZ123456SNAP01"));
|
||||
|
||||
// Wait for restore result with per-node status and color classes
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("restore-result")).toBeDefined();
|
||||
const resumed = screen.getByTestId("restore-status-dev.impl");
|
||||
expect(resumed.textContent).toBe("resumed");
|
||||
expect(resumed.className).toContain("text-success");
|
||||
const fresh = screen.getByTestId("restore-status-dev.qa");
|
||||
expect(fresh.textContent).toBe("fresh");
|
||||
expect(fresh.className).toContain("text-foreground-muted");
|
||||
});
|
||||
});
|
||||
|
||||
it("restore button opens confirm dialog", async () => {
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
// Click restore on the snapshot
|
||||
const restoreBtn = screen.getByTestId("restore-btn-01HXYZ123456SNAP01");
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
fireEvent.click(restoreBtn);
|
||||
// Confirm dialog should appear
|
||||
expect(await screen.findByText("Confirm Restore")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows actionable guidance when restore requires the rig to be stopped", async () => {
|
||||
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }],
|
||||
});
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 2, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null }],
|
||||
});
|
||||
}
|
||||
if (url.includes("/restore/") && opts?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ error: "Rig rig-1 must be stopped before restore", code: "rig_not_stopped" }),
|
||||
});
|
||||
}
|
||||
if (url.includes("/snapshots")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [{ id: "01HXYZ123456SNAP01", kind: "manual", createdAt: "2026-04-01T10:00:00Z" }],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
|
||||
fireEvent.click(screen.getByTestId("restore-btn-01HXYZ123456SNAP01"));
|
||||
await screen.findByText("Confirm Restore");
|
||||
fireEvent.click(screen.getByTestId("confirm-restore-01HXYZ123456SNAP01"));
|
||||
|
||||
expect((await screen.findByTestId("snapshot-error")).textContent).toContain("rig down my-rig");
|
||||
});
|
||||
|
||||
it("drawer shows Info | Chat Room tabs", async () => {
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
expect(screen.getByTestId("drawer-tabs")).toBeDefined();
|
||||
expect(screen.getByTestId("tab-info")).toBeDefined();
|
||||
expect(screen.getByTestId("tab-chat")).toBeDefined();
|
||||
expect(screen.getByTestId("tab-info").textContent).toContain("Info");
|
||||
expect(screen.getByTestId("tab-chat").textContent).toContain("Chat Room");
|
||||
});
|
||||
|
||||
it("shows snapshot age when available, not 'No snapshots'", async () => {
|
||||
renderPanel("rig-1");
|
||||
// Wait for data to load
|
||||
await screen.findByText("my-rig");
|
||||
const panel = screen.getByTestId("rig-detail-panel");
|
||||
// Should show an age indicator (contains "ago" or "< 1m"), not "No snapshots"
|
||||
const text = panel.textContent ?? "";
|
||||
expect(text).not.toContain("No snapshots");
|
||||
expect(text).toMatch(/ago|< 1m/);
|
||||
});
|
||||
|
||||
it("shows a pod summary with human-friendly pod names", async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: "rig-1", name: "my-rig", nodeCount: 3, latestSnapshotAt: "2026-04-01T10:00:00Z", latestSnapshotId: "snap-1" },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ rigId: "rig-1", name: "my-rig", nodeCount: 3, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === "/api/rigs/rig-1/nodes") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ rigId: "rig-1", rigName: "my-rig", logicalId: "orch.lead", podId: "orch", nodeKind: "agent", runtime: "claude-code", startupStatus: "ready", canonicalSessionName: "orch-lead@my-rig" },
|
||||
{ rigId: "rig-1", rigName: "my-rig", logicalId: "dev.impl", podId: "dev", nodeKind: "agent", runtime: "claude-code", startupStatus: "ready", canonicalSessionName: "dev-impl@my-rig" },
|
||||
{ rigId: "rig-1", rigName: "my-rig", logicalId: "dev.qa", podId: "dev", nodeKind: "agent", runtime: "codex", startupStatus: "pending", canonicalSessionName: "dev-qa@my-rig" },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.includes("/snapshots")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [{ id: "01HXYZ123456SNAP01", kind: "manual", createdAt: "2026-04-01T10:00:00Z" }],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
|
||||
expect(await screen.findByText("Pods")).toBeTruthy();
|
||||
expect(screen.getByText("orch")).toBeTruthy();
|
||||
expect(screen.getByText("dev")).toBeTruthy();
|
||||
expect(screen.getByText("impl")).toBeTruthy();
|
||||
expect(screen.getByText("qa")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Env tab for service-backed rigs with service health and surfaces", async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }] });
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "5m" }] });
|
||||
}
|
||||
if (url === "/api/rigs/rig-1/nodes") {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
if (url.includes("/snapshots")) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
if (url === "/api/rigs/rig-1/env") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
hasServices: true,
|
||||
kind: "compose",
|
||||
composeFile: "/tmp/svc.compose.yaml",
|
||||
projectName: "test-svc",
|
||||
receipt: {
|
||||
kind: "compose",
|
||||
composeFile: "/tmp/svc.compose.yaml",
|
||||
projectName: "test-svc",
|
||||
services: [{ name: "vault", status: "running", health: "healthy" }],
|
||||
waitFor: [{ target: { url: "http://127.0.0.1:8200/health" }, status: "healthy" }],
|
||||
capturedAt: "2026-04-09T12:00:00Z",
|
||||
},
|
||||
surfaces: {
|
||||
urls: [{ name: "Vault UI", url: "http://127.0.0.1:8200/ui" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
|
||||
// Env tab should exist between Info and Chat
|
||||
expect(screen.getByTestId("tab-env")).toBeDefined();
|
||||
expect(screen.getByTestId("tab-info")).toBeDefined();
|
||||
expect(screen.getByTestId("tab-chat")).toBeDefined();
|
||||
|
||||
// Click Env tab
|
||||
fireEvent.click(screen.getByTestId("tab-env"));
|
||||
|
||||
await waitFor(() => {
|
||||
// Overall env state
|
||||
const envState = screen.getByTestId("env-state");
|
||||
expect(envState).toBeDefined();
|
||||
expect(envState.textContent).toContain("Healthy");
|
||||
// Service health
|
||||
expect(screen.getByText("vault")).toBeDefined();
|
||||
// Surface URL
|
||||
expect(screen.getByText("Vault UI")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show Env tab for non-service rigs", async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ id: "rig-2", name: "plain-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }] });
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ rigId: "rig-2", name: "plain-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "5m" }] });
|
||||
}
|
||||
if (url === "/api/rigs/rig-2/env") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ ok: true, hasServices: false }) });
|
||||
}
|
||||
if (url.includes("/snapshots")) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
renderPanel("rig-2");
|
||||
await screen.findByText("plain-rig");
|
||||
|
||||
expect(screen.getByTestId("tab-info")).toBeDefined();
|
||||
expect(screen.getByTestId("tab-chat")).toBeDefined();
|
||||
expect(screen.queryByTestId("tab-env")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows stopped env state and failed health gate details when services disappear", async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null, hasServices: true }],
|
||||
});
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "5m" }],
|
||||
});
|
||||
}
|
||||
if (url === "/api/rigs/rig-1/nodes") {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
if (url.includes("/snapshots")) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
if (url === "/api/rigs/rig-1/env") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
hasServices: true,
|
||||
kind: "compose",
|
||||
composeFile: "/tmp/svc.compose.yaml",
|
||||
projectName: "test-svc",
|
||||
receipt: {
|
||||
kind: "compose",
|
||||
composeFile: "/tmp/svc.compose.yaml",
|
||||
projectName: "test-svc",
|
||||
services: [],
|
||||
waitFor: [{
|
||||
target: { url: "http://127.0.0.1:8200/v1/sys/health" },
|
||||
status: "unhealthy",
|
||||
detail: "HTTP probe failed: http://127.0.0.1:8200/v1/sys/health",
|
||||
}],
|
||||
capturedAt: "2026-04-09T12:00:00Z",
|
||||
},
|
||||
surfaces: {
|
||||
urls: [{ name: "Vault UI", url: "http://127.0.0.1:8200/ui" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
|
||||
fireEvent.click(screen.getByTestId("tab-env"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("env-state").textContent).toContain("Stopped");
|
||||
expect(screen.getByText("Health Gates")).toBeDefined();
|
||||
expect(screen.getByText("http://127.0.0.1:8200/v1/sys/health")).toBeDefined();
|
||||
expect(screen.getByText(/HTTP probe failed/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Env tab visible for service-backed rigs while env status is still loading", async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null, hasServices: true }],
|
||||
});
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "5m" }],
|
||||
});
|
||||
}
|
||||
if (url === "/api/rigs/rig-1/nodes") {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
if (url.includes("/snapshots")) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
if (url === "/api/rigs/rig-1/env") {
|
||||
return new Promise(() => {}) as Promise<Response>;
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
renderPanel("rig-1");
|
||||
await screen.findByText("my-rig");
|
||||
|
||||
expect(screen.getByTestId("tab-info")).toBeDefined();
|
||||
expect(screen.getByTestId("tab-chat")).toBeDefined();
|
||||
expect(screen.getByTestId("tab-env")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -219,7 +219,11 @@ describe("RigGraph", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking a pod group routes selection back to the rig drawer", async () => {
|
||||
// V1 attempt-3 Phase 4 P4-5 — RigDetailPanel + 'rig' kind retired
|
||||
// from DrawerSelection. Pod-group click is now a no-op at the graph
|
||||
// level (pods open via Explorer tree's /topology/pod/$rigId/$podName
|
||||
// link). Test renamed + assertion flipped to verify NO setSelection.
|
||||
it("clicking a pod group is a no-op at the graph level (RigDetailPanel retired)", async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockGraphResponse([
|
||||
{
|
||||
id: "pod-alpha",
|
||||
@@ -280,7 +284,8 @@ describe("RigGraph", () => {
|
||||
|
||||
fireEvent.click(container.querySelector(".react-flow__node-podGroup")!);
|
||||
|
||||
expect(setSelection).toHaveBeenCalledWith({ type: "rig", rigId: "rig-1" });
|
||||
// 'rig' kind retired in Phase 4 — pod-group click does NOT setSelection.
|
||||
expect(setSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicking an unbound node in discovery placement mode selects it as a bind target", async () => {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
import { createMockEventSourceClass } from "./helpers/mock-event-source.js";
|
||||
import { createTestRouter } from "./helpers/test-router.js";
|
||||
import { Dashboard } from "../src/components/Dashboard.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
let OriginalEventSource: typeof EventSource | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
OriginalEventSource = globalThis.EventSource;
|
||||
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (OriginalEventSource) {
|
||||
globalThis.EventSource = OriginalEventSource;
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function mockSummaryResponse(rigs: Array<{
|
||||
id: string; name: string; nodeCount: number;
|
||||
latestSnapshotAt: string | null; latestSnapshotId: string | null;
|
||||
}>) {
|
||||
return { ok: true, json: async () => rigs };
|
||||
}
|
||||
|
||||
describe("App (scaffold)", () => {
|
||||
it("shows loading state while fetching summary", async () => {
|
||||
mockFetch.mockReturnValue(new Promise(() => {}));
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dashboard-loading")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'No rigs' when summary returns empty", async () => {
|
||||
mockFetch.mockResolvedValue(mockSummaryResponse([]));
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no rigs/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders dashboard with rig cards when summary has data", async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
return Promise.resolve(mockSummaryResponse([
|
||||
{ id: "r1", name: "r01", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null },
|
||||
]));
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return Promise.resolve({ ok: true, json: async () => [{ rigId: "r1", name: "r01", nodeCount: 2, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null }] });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("r01")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state on fetch failure", async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dashboard-error")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("dashboard fetches /api/rigs/summary", async () => {
|
||||
mockFetch.mockResolvedValue(mockSummaryResponse([]));
|
||||
render(createTestRouter({ component: Dashboard }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(mockFetch.mock.calls[0]![0]).toBe("/api/rigs/summary");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user