Merge pull request #42 from offendingcommit/feat/fleet-dashboard

feat(web): add Fleet dashboard for cross-instance observability
This commit is contained in:
Offending Commit
2026-05-28 17:08:35 -05:00
committed by GitHub
13 changed files with 711 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@@ -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();
});
});

View File

@@ -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();
});

View File

@@ -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));
}

View File

@@ -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<Record<string, FleetRowMetrics>>({});
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 (
<div className="page-container page-container--xl">
<EmptyState
icon={Network}
title="No instances configured"
description="Add at least one Honcho instance in Settings to use the Fleet view."
action={
<Link
to="/settings"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md"
style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
>
<SettingsIcon className="w-4 h-4" strokeWidth={1.5} />
Go to Settings
</Link>
}
/>
</div>
);
}
return (
<div className="page-container page-container--xl">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
<div className="flex items-center gap-2 mb-1">
<Network className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<PageTitle>Fleet</PageTitle>
<span
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: COLOR.accentSubtle,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{agg.totalInstances} agent{agg.totalInstances !== 1 ? "s" : ""}
</span>
</div>
<Body className="leading-none">Cross-instance overview of all configured agents</Body>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"
>
<MetricCard label="Workspaces" value={agg.totalWorkspaces} />
<MetricCard label="Conclusions" value={agg.totalConclusions} accent />
<MetricCard
label="Healthy"
value={agg.healthyCount}
total={agg.totalInstances}
color={agg.healthyCount === agg.totalInstances ? COLOR.success : COLOR.warning}
/>
<MetricCard
label="Unreachable"
value={agg.unreachableCount}
color={agg.unreachableCount > 0 ? COLOR.destructive : "var(--text-3)"}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 }}
className="rounded-xl theme-card overflow-hidden"
>
<div
className="flex items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--border)" }}
>
<Server className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading className="mb-0">Agents</SectionHeading>
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
all configured instances · queue updates every 10s
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-3)" }}>
<th className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
Agent
</th>
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
Workspaces
</th>
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
Conclusions
</th>
<th
className="py-2 px-4 font-medium text-right"
style={{ color: "var(--text-3)" }}
title="Active / Pending queue work units"
>
Queue (a/p)
</th>
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
Last seen
</th>
</tr>
</thead>
<tbody>
{instances.map((inst) => (
<FleetRow key={inst.id} instance={inst} onMetrics={setMetrics} />
))}
</tbody>
</table>
</div>
</motion.div>
</div>
);
}
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 (
<div className="rounded-xl p-4 theme-card">
<div className="text-2xl font-semibold font-mono" style={{ color: valueColor }}>
{formatCount(value)}
{total !== undefined && (
<span className="text-base ml-1" style={{ color: "var(--text-4)" }}>
/ {formatCount(total)}
</span>
)}
</div>
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
{label}
</div>
</div>
);
}

View File

@@ -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<FleetRowMetrics | null>(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 (
<tr
data-testid={`fleet-row-${instance.id}`}
style={{
borderTop: "1px solid var(--border)",
background: isActive ? COLOR.warningDim : undefined,
}}
>
<td className="py-2.5 px-4">
<div className="flex items-center gap-2 min-w-0">
<HealthDot
status={health === "ok" ? "ok" : health === "unreachable" ? "unreachable" : "checking"}
/>
<div className="min-w-0">
<div className="text-sm font-medium truncate" style={{ color: "var(--text-1)" }}>
{instance.name}
</div>
<div
className="text-xs font-mono truncate max-w-[16rem]"
style={{ color: "var(--text-4)" }}
title={mask(hostname)}
>
{mask(hostname)}
</div>
</div>
</div>
</td>
<td className="py-2.5 px-4 text-right font-mono text-xs" style={{ color: "var(--text-2)" }}>
{workspacesQ.isLoading ? "—" : formatCount(totalWorkspaces)}
</td>
<td className="py-2.5 px-4 text-right font-mono text-xs" style={{ color: "var(--text-2)" }}>
{isLoading ? "—" : formatCount(conclusionCount)}
</td>
<td className="py-2.5 px-4 text-right">
{isLoading ? (
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
</span>
) : (
<div className="flex items-center justify-end gap-1.5">
{isActive ? (
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
>
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
</motion.div>
) : (
<CircleDot
className="w-3 h-3"
style={{ color: health === "ok" ? COLOR.success : "var(--text-4)" }}
strokeWidth={2}
/>
)}
<span
className="text-xs font-medium font-mono"
style={{ color: isActive ? COLOR.warning : "var(--text-3)" }}
>
{isActive ? `${formatCount(queueActive)}/${formatCount(queuePending)}` : "idle"}
</span>
</div>
)}
</td>
<td
className="py-2.5 px-4 text-right text-xs font-mono"
style={{ color: "var(--text-4)" }}
title={lastSeen ? new Date(lastSeen).toLocaleString() : undefined}
>
{health === "unreachable" ? "unreachable" : formatRelative(lastSeen)}
</td>
</tr>
);
}

View File

@@ -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,
};
}

View File

@@ -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 },

View File

@@ -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,

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { FleetDashboard } from "@/components/fleet/FleetDashboard";
export const Route = createFileRoute("/fleet")({
component: FleetDashboard,
});

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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(
<QueryClientProvider client={qc}>
<DemoProvider>
<MetadataProvider>
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
<RouterProvider router={router as any} />
</MetadataProvider>
</DemoProvider>
</QueryClientProvider>,
);
}
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);
});
});