diff --git a/docs/screenshots/fleet-dashboard-dark.png b/docs/screenshots/fleet-dashboard-dark.png new file mode 100644 index 0000000..2fd00a4 Binary files /dev/null and b/docs/screenshots/fleet-dashboard-dark.png differ diff --git a/docs/screenshots/fleet-dashboard-light.png b/docs/screenshots/fleet-dashboard-light.png new file mode 100644 index 0000000..736cf6d Binary files /dev/null and b/docs/screenshots/fleet-dashboard-light.png differ diff --git a/docs/screenshots/fleet-dashboard-mixed-health.png b/docs/screenshots/fleet-dashboard-mixed-health.png new file mode 100644 index 0000000..ba313ec Binary files /dev/null and b/docs/screenshots/fleet-dashboard-mixed-health.png differ diff --git a/packages/web/e2e/fleet.spec.ts b/packages/web/e2e/fleet.spec.ts new file mode 100644 index 0000000..a8c07b3 --- /dev/null +++ b/packages/web/e2e/fleet.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +const STORE_KEY = "openconcho:instances"; + +// Two unreachable instances — the rows still render with their configured +// names; only the health column flips to "unreachable" once the workspaces +// query errors. We only assert on the rendered names + row count, so the +// test doesn't depend on a live backend. +const FLEET_STORE = JSON.stringify({ + instances: [ + { id: "a", name: "Neo", baseUrl: "http://localhost:9001", token: "" }, + { id: "b", name: "Iris", baseUrl: "http://localhost:9002", token: "" }, + { id: "c", name: "Lexi", baseUrl: "http://localhost:9003", token: "" }, + ], + activeId: "a", +}); + +test.describe("Fleet route", () => { + test.beforeEach(async ({ context }) => { + await context.addInitScript( + ([key, value]) => { + window.localStorage.setItem(key, value); + }, + [STORE_KEY, FLEET_STORE], + ); + }); + + test("renders one row per configured instance and the Fleet heading", async ({ page }) => { + await page.goto("/fleet"); + + // Page header + await expect(page.getByRole("heading", { name: /^Fleet$/ })).toBeVisible(); + + // One row per instance, asserted via the table not the sidebar (the + // active instance's name also appears in the sidebar switcher). + const table = page.getByRole("table"); + await expect(table.getByText("Neo", { exact: true })).toBeVisible(); + await expect(table.getByText("Iris", { exact: true })).toBeVisible(); + await expect(table.getByText("Lexi", { exact: true })).toBeVisible(); + + // 1 header row + 3 instance rows + await expect(table.getByRole("row")).toHaveCount(4); + }); + + test("Fleet link in the sidebar navigates to /fleet", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: /fleet/i }).click(); + await expect(page).toHaveURL(/\/fleet$/); + await expect(page.getByRole("heading", { name: /^Fleet$/ })).toBeVisible(); + }); +}); diff --git a/packages/web/e2e/sidebar.spec.ts b/packages/web/e2e/sidebar.spec.ts index b0eb6e0..1037a22 100644 --- a/packages/web/e2e/sidebar.spec.ts +++ b/packages/web/e2e/sidebar.spec.ts @@ -17,6 +17,7 @@ test.describe("Sidebar", () => { await page.goto("/"); await expect(page.getByRole("complementary")).toBeVisible(); await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /fleet/i })).toBeVisible(); await expect(page.getByRole("link", { name: /workspaces/i })).toBeVisible(); await expect(page.getByRole("link", { name: /settings/i })).toBeVisible(); }); diff --git a/packages/web/src/api/compareQueries.ts b/packages/web/src/api/compareQueries.ts index d18407b..ed7ad99 100644 --- a/packages/web/src/api/compareQueries.ts +++ b/packages/web/src/api/compareQueries.ts @@ -16,6 +16,9 @@ const CK = { ["compare", instId, "peer-representation", wsId, pId] as const, peerCard: (instId: string, wsId: string, pId: string) => ["compare", instId, "peer-card", wsId, pId] as const, + queueStatus: (instId: string, wsId: string) => ["compare", instId, "queue-status", wsId] as const, + conclusionsCount: (instId: string, wsId: string) => + ["compare", instId, "conclusions-count", wsId] as const, }; export function useScopedWorkspaces(instance: Instance, page = 1, pageSize = 20) { @@ -83,3 +86,43 @@ export function useScopedPeerCard(instance: Instance, workspaceId: string, peerI enabled: Boolean(workspaceId) && Boolean(peerId), }); } + +// Option builders — used by both single-fetch hooks and useQueries fan-out (e.g. Fleet view). + +export function scopedQueueStatusOptions(instance: Instance, workspaceId: string) { + return { + queryKey: CK.queueStatus(instance.id, workspaceId), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.GET("/v3/workspaces/{workspace_id}/queue/status", { + params: { path: { workspace_id: workspaceId } }, + }); + return data ?? err(error); + }, + enabled: Boolean(workspaceId), + refetchInterval: 10_000, + } as const; +} + +export function scopedConclusionsCountOptions(instance: Instance, workspaceId: string) { + return { + queryKey: CK.conclusionsCount(instance.id, workspaceId), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.POST("/v3/workspaces/{workspace_id}/conclusions/list", { + params: { path: { workspace_id: workspaceId }, query: { page: 1, size: 1 } }, + body: {}, + }); + return data ?? err(error); + }, + enabled: Boolean(workspaceId), + } as const; +} + +export function useScopedQueueStatus(instance: Instance, workspaceId: string) { + return useQuery(scopedQueueStatusOptions(instance, workspaceId)); +} + +export function useScopedConclusionsCount(instance: Instance, workspaceId: string) { + return useQuery(scopedConclusionsCountOptions(instance, workspaceId)); +} diff --git a/packages/web/src/components/fleet/FleetDashboard.tsx b/packages/web/src/components/fleet/FleetDashboard.tsx new file mode 100644 index 0000000..97799dd --- /dev/null +++ b/packages/web/src/components/fleet/FleetDashboard.tsx @@ -0,0 +1,177 @@ +import { Link } from "@tanstack/react-router"; +import { motion } from "framer-motion"; +import { Network, Server, Settings as SettingsIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { EmptyState } from "@/components/shared/EmptyState"; +import { Body, PageTitle, SectionHeading } from "@/components/ui/typography"; +import { useInstances } from "@/hooks/useInstances"; +import { COLOR } from "@/lib/constants"; +import { formatCount } from "@/lib/utils"; +import { FleetRow } from "./FleetRow"; +import { + computeFleetAggregates, + DEFAULT_ROW_METRICS, + type FleetRowMetrics, +} from "./fleetAggregates"; + +export function FleetDashboard() { + const { instances } = useInstances(); + const [metricsById, setMetricsById] = useState>({}); + + const setMetrics = useCallback((id: string, m: FleetRowMetrics) => { + setMetricsById((prev) => ({ ...prev, [id]: m })); + }, []); + + const rows = useMemo( + () => instances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS), + [instances, metricsById], + ); + const agg = useMemo(() => computeFleetAggregates(rows), [rows]); + + if (instances.length === 0) { + return ( +
+ + + Go to Settings + + } + /> +
+ ); + } + + return ( +
+ +
+ + Fleet + + {agg.totalInstances} agent{agg.totalInstances !== 1 ? "s" : ""} + +
+ Cross-instance overview of all configured agents +
+ + + + + + 0 ? COLOR.destructive : "var(--text-3)"} + /> + + + +
+ + Agents + + all configured instances · queue updates every 10s + +
+ +
+ + + + + + + + + + + + {instances.map((inst) => ( + + ))} + +
+ Agent + + Workspaces + + Conclusions + + Queue (a/p) + + Last seen +
+
+
+
+ ); +} + +interface MetricCardProps { + label: string; + value: number; + total?: number; + color?: string; + accent?: boolean; +} + +function MetricCard({ label, value, total, color, accent }: MetricCardProps) { + const valueColor = color ?? (accent ? COLOR.accentText : "var(--text-1)"); + return ( +
+
+ {formatCount(value)} + {total !== undefined && ( + + / {formatCount(total)} + + )} +
+
+ {label} +
+
+ ); +} diff --git a/packages/web/src/components/fleet/FleetRow.tsx b/packages/web/src/components/fleet/FleetRow.tsx new file mode 100644 index 0000000..dbe1163 --- /dev/null +++ b/packages/web/src/components/fleet/FleetRow.tsx @@ -0,0 +1,192 @@ +import { useQueries } from "@tanstack/react-query"; +import { motion } from "framer-motion"; +import { CircleDot } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; +import { + scopedConclusionsCountOptions, + scopedQueueStatusOptions, + useScopedWorkspaces, +} from "@/api/compareQueries"; +import type { components } from "@/api/schema.d.ts"; +import { HealthDot } from "@/components/shared/HealthDot"; +import { useDemo } from "@/hooks/useDemo"; +import type { Instance } from "@/lib/config"; +import { COLOR } from "@/lib/constants"; +import { formatCount } from "@/lib/utils"; +import type { FleetRowMetrics } from "./fleetAggregates"; + +type Workspace = components["schemas"]["Workspace"]; +type QueueStatus = components["schemas"]["QueueStatus"]; +type ConclusionPage = components["schemas"]["Page_Conclusion_"]; + +interface Props { + instance: Instance; + onMetrics: (id: string, metrics: FleetRowMetrics) => void; +} + +function shallowEqualMetrics(a: FleetRowMetrics, b: FleetRowMetrics): boolean { + return ( + a.workspaceCount === b.workspaceCount && + a.conclusionCount === b.conclusionCount && + a.queueActive === b.queueActive && + a.queuePending === b.queuePending && + a.lastSeen === b.lastSeen && + a.health === b.health + ); +} + +function formatRelative(ts: number | null): string { + if (!ts) return "—"; + const seconds = Math.round((Date.now() - ts) / 1000); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +} + +export function FleetRow({ instance, onMetrics }: Props) { + const { mask } = useDemo(); + const workspacesQ = useScopedWorkspaces(instance, 1, 100); + + const workspaces: Workspace[] = useMemo( + () => (workspacesQ.data as { items?: Workspace[] } | undefined)?.items ?? [], + [workspacesQ.data], + ); + const totalWorkspaces = + (workspacesQ.data as { total?: number } | undefined)?.total ?? workspaces.length; + + const queueResults = useQueries({ + queries: workspaces.map((ws) => scopedQueueStatusOptions(instance, ws.id)), + }); + const conclusionsResults = useQueries({ + queries: workspaces.map((ws) => scopedConclusionsCountOptions(instance, ws.id)), + }); + + const queueActive = queueResults.reduce( + (s, q) => s + ((q.data as QueueStatus | undefined)?.in_progress_work_units ?? 0), + 0, + ); + const queuePending = queueResults.reduce( + (s, q) => s + ((q.data as QueueStatus | undefined)?.pending_work_units ?? 0), + 0, + ); + const conclusionCount = conclusionsResults.reduce( + (s, c) => s + ((c.data as ConclusionPage | undefined)?.total ?? 0), + 0, + ); + + const health: FleetRowMetrics["health"] = workspacesQ.isError + ? "unreachable" + : workspacesQ.isSuccess + ? "ok" + : "loading"; + const lastSeen = workspacesQ.dataUpdatedAt > 0 ? workspacesQ.dataUpdatedAt : null; + + const isActive = queueActive > 0 || queuePending > 0; + const isLoading = + workspacesQ.isLoading || + queueResults.some((q) => q.isLoading) || + conclusionsResults.some((c) => c.isLoading); + + const metrics: FleetRowMetrics = useMemo( + () => ({ + workspaceCount: totalWorkspaces, + conclusionCount, + queueActive, + queuePending, + lastSeen, + health, + }), + [totalWorkspaces, conclusionCount, queueActive, queuePending, lastSeen, health], + ); + + const lastReported = useRef(null); + useEffect(() => { + if (lastReported.current && shallowEqualMetrics(lastReported.current, metrics)) return; + lastReported.current = metrics; + onMetrics(instance.id, metrics); + }, [instance.id, metrics, onMetrics]); + + const hostname = instance.baseUrl.replace(/^https?:\/\//, ""); + + return ( + + +
+ +
+
+ {instance.name} +
+
+ {mask(hostname)} +
+
+
+ + + + {workspacesQ.isLoading ? "—" : formatCount(totalWorkspaces)} + + + + {isLoading ? "—" : formatCount(conclusionCount)} + + + + {isLoading ? ( + + … + + ) : ( +
+ {isActive ? ( + + + + ) : ( + + )} + + {isActive ? `${formatCount(queueActive)}/${formatCount(queuePending)}` : "idle"} + +
+ )} + + + + {health === "unreachable" ? "unreachable" : formatRelative(lastSeen)} + + + ); +} diff --git a/packages/web/src/components/fleet/fleetAggregates.ts b/packages/web/src/components/fleet/fleetAggregates.ts new file mode 100644 index 0000000..447e199 --- /dev/null +++ b/packages/web/src/components/fleet/fleetAggregates.ts @@ -0,0 +1,43 @@ +export type FleetHealth = "ok" | "unreachable" | "loading"; + +export interface FleetRowMetrics { + workspaceCount: number; + conclusionCount: number; + queueActive: number; + queuePending: number; + lastSeen: number | null; + health: FleetHealth; +} + +export interface FleetAggregates { + totalInstances: number; + totalWorkspaces: number; + totalConclusions: number; + totalQueueActive: number; + totalQueuePending: number; + healthyCount: number; + unreachableCount: number; + loadingCount: number; +} + +export const DEFAULT_ROW_METRICS: FleetRowMetrics = { + workspaceCount: 0, + conclusionCount: 0, + queueActive: 0, + queuePending: 0, + lastSeen: null, + health: "loading", +}; + +export function computeFleetAggregates(rows: FleetRowMetrics[]): FleetAggregates { + return { + totalInstances: rows.length, + totalWorkspaces: rows.reduce((s, r) => s + r.workspaceCount, 0), + totalConclusions: rows.reduce((s, r) => s + r.conclusionCount, 0), + totalQueueActive: rows.reduce((s, r) => s + r.queueActive, 0), + totalQueuePending: rows.reduce((s, r) => s + r.queuePending, 0), + healthyCount: rows.filter((r) => r.health === "ok").length, + unreachableCount: rows.filter((r) => r.health === "unreachable").length, + loadingCount: rows.filter((r) => r.health === "loading").length, + }; +} diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx index 7a9ba55..c96d23e 100644 --- a/packages/web/src/components/layout/Sidebar.tsx +++ b/packages/web/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { MessageSquare, Moon, MoonStar, + Network, Settings, Sun, Users, @@ -31,6 +32,7 @@ import { COLOR } from "@/lib/constants"; const TOP_NAV = [ { to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true }, + { to: "/fleet" as const, label: "Fleet", icon: Network, exact: false }, { to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false }, { to: "/seed-kits" as const, label: "Seed Kits", icon: Layers, exact: false }, { to: "/settings" as const, label: "Settings", icon: Settings, exact: false }, diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 1a8b2cc..14e2ddc 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WorkspacesRouteImport } from './routes/workspaces' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SeedKitsRouteImport } from './routes/seed-kits' +import { Route as FleetRouteImport } from './routes/fleet' import { Route as ExploreRouteImport } from './routes/explore' import { Route as IndexRouteImport } from './routes/index' import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId' @@ -42,6 +43,11 @@ const SeedKitsRoute = SeedKitsRouteImport.update({ path: '/seed-kits', getParentRoute: () => rootRouteImport, } as any) +const FleetRoute = FleetRouteImport.update({ + id: '/fleet', + path: '/fleet', + getParentRoute: () => rootRouteImport, +} as any) const ExploreRoute = ExploreRouteImport.update({ id: '/explore', path: '/explore', @@ -126,6 +132,7 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/explore': typeof ExploreRoute + '/fleet': typeof FleetRoute '/seed-kits': typeof SeedKitsRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute @@ -145,6 +152,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/explore': typeof ExploreRoute + '/fleet': typeof FleetRoute '/seed-kits': typeof SeedKitsRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute @@ -165,6 +173,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/explore': typeof ExploreRoute + '/fleet': typeof FleetRoute '/seed-kits': typeof SeedKitsRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute @@ -186,6 +195,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/explore' + | '/fleet' | '/seed-kits' | '/settings' | '/workspaces' @@ -205,6 +215,7 @@ export interface FileRouteTypes { to: | '/' | '/explore' + | '/fleet' | '/seed-kits' | '/settings' | '/workspaces' @@ -224,6 +235,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/explore' + | '/fleet' | '/seed-kits' | '/settings' | '/workspaces' @@ -244,6 +256,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ExploreRoute: typeof ExploreRoute + FleetRoute: typeof FleetRoute SeedKitsRoute: typeof SeedKitsRoute SettingsRoute: typeof SettingsRoute WorkspacesRoute: typeof WorkspacesRoute @@ -284,6 +297,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SeedKitsRouteImport parentRoute: typeof rootRouteImport } + '/fleet': { + id: '/fleet' + path: '/fleet' + fullPath: '/fleet' + preLoaderRoute: typeof FleetRouteImport + parentRoute: typeof rootRouteImport + } '/explore': { id: '/explore' path: '/explore' @@ -388,6 +408,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ExploreRoute: ExploreRoute, + FleetRoute: FleetRoute, SeedKitsRoute: SeedKitsRoute, SettingsRoute: SettingsRoute, WorkspacesRoute: WorkspacesRoute, diff --git a/packages/web/src/routes/fleet.tsx b/packages/web/src/routes/fleet.tsx new file mode 100644 index 0000000..c4b91b0 --- /dev/null +++ b/packages/web/src/routes/fleet.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { FleetDashboard } from "@/components/fleet/FleetDashboard"; + +export const Route = createFileRoute("/fleet")({ + component: FleetDashboard, +}); diff --git a/packages/web/src/test/fleet.test.tsx b/packages/web/src/test/fleet.test.tsx new file mode 100644 index 0000000..56c50e9 --- /dev/null +++ b/packages/web/src/test/fleet.test.tsx @@ -0,0 +1,175 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { scopedConclusionsCountOptions, scopedQueueStatusOptions } from "@/api/compareQueries"; +import { + computeFleetAggregates, + DEFAULT_ROW_METRICS, + type FleetRowMetrics, +} from "@/components/fleet/fleetAggregates"; +import { DemoProvider } from "@/context/DemoContext"; +import { MetadataProvider } from "@/context/MetadataContext"; +import type { Instance } from "@/lib/config"; +import { saveStore } from "@/lib/config"; +import { routeTree } from "@/routeTree.gen"; + +vi.mock("@/lib/http", () => ({ + httpFetch: vi.fn( + async () => + new Response(JSON.stringify({ items: [], total: 0, page: 1, size: 1, pages: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), +})); + +const neo: Instance = { + id: "neo", + name: "Neo", + baseUrl: "http://localhost:8001", + token: "neo-token", +}; +const iris: Instance = { + id: "iris", + name: "Iris", + baseUrl: "http://localhost:8002", + token: "iris-token", +}; + +describe("computeFleetAggregates", () => { + it("matches snapshot for an empty fleet", () => { + expect(computeFleetAggregates([])).toMatchInlineSnapshot(` + { + "healthyCount": 0, + "loadingCount": 0, + "totalConclusions": 0, + "totalInstances": 0, + "totalQueueActive": 0, + "totalQueuePending": 0, + "totalWorkspaces": 0, + "unreachableCount": 0, + } + `); + }); + + it("matches snapshot for a mixed fleet (healthy, loading, unreachable)", () => { + const rows: FleetRowMetrics[] = [ + { + workspaceCount: 3, + conclusionCount: 142, + queueActive: 2, + queuePending: 5, + lastSeen: 1_700_000_000_000, + health: "ok", + }, + { + workspaceCount: 1, + conclusionCount: 87, + queueActive: 0, + queuePending: 0, + lastSeen: 1_700_000_001_000, + health: "ok", + }, + { ...DEFAULT_ROW_METRICS }, + { + workspaceCount: 0, + conclusionCount: 0, + queueActive: 0, + queuePending: 0, + lastSeen: null, + health: "unreachable", + }, + ]; + + expect(computeFleetAggregates(rows)).toMatchInlineSnapshot(` + { + "healthyCount": 2, + "loadingCount": 1, + "totalConclusions": 229, + "totalInstances": 4, + "totalQueueActive": 2, + "totalQueuePending": 5, + "totalWorkspaces": 4, + "unreachableCount": 1, + } + `); + }); +}); + +describe("scoped option builders", () => { + beforeEach(async () => { + const { httpFetch } = await import("@/lib/http"); + (httpFetch as ReturnType).mockClear(); + }); + + it("scopes queue status requests by instance baseUrl and token", async () => { + const { httpFetch } = await import("@/lib/http"); + const opts = scopedQueueStatusOptions(neo, "ws-1"); + await opts.queryFn(); + const req = (httpFetch as ReturnType).mock.calls[0][0] as Request; + expect(req.url).toBe("http://localhost:8001/v3/workspaces/ws-1/queue/status"); + expect(req.headers.get("Authorization")).toBe("Bearer neo-token"); + }); + + it("scopes conclusions-count requests by instance baseUrl and token", async () => { + const { httpFetch } = await import("@/lib/http"); + const opts = scopedConclusionsCountOptions(iris, "ws-9"); + await opts.queryFn(); + const req = (httpFetch as ReturnType).mock.calls[0][0] as Request; + expect(req.url.startsWith("http://localhost:8002/v3/workspaces/ws-9/conclusions/list")).toBe( + true, + ); + expect(req.headers.get("Authorization")).toBe("Bearer iris-token"); + }); + + it("produces distinct query keys per instance to prevent cache collisions", () => { + const neoKey = scopedQueueStatusOptions(neo, "ws-shared").queryKey; + const irisKey = scopedQueueStatusOptions(iris, "ws-shared").queryKey; + expect(neoKey).not.toEqual(irisKey); + expect(neoKey).toContain("neo"); + expect(irisKey).toContain("iris"); + }); +}); + +function renderRouteAt(initialPath: string) { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [initialPath] }), + }); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + {/* biome-ignore lint/suspicious/noExplicitAny: test router type */} + + + + , + ); +} + +describe("Fleet route", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("mounts FleetDashboard at /fleet when an instance is configured", async () => { + saveStore({ instances: [neo], activeId: "neo" }); + renderRouteAt("/fleet"); + expect(await screen.findByRole("heading", { name: /Fleet/i })).toBeInTheDocument(); + }); + + it("renders one table row per configured instance", async () => { + saveStore({ instances: [neo, iris], activeId: "neo" }); + renderRouteAt("/fleet"); + const table = await screen.findByRole("table"); + await waitFor(() => { + expect(within(table).getByText("Neo")).toBeInTheDocument(); + expect(within(table).getByText("Iris")).toBeInTheDocument(); + }); + // 1 header + 2 instance rows + expect(within(table).getAllByRole("row")).toHaveLength(3); + }); +});