feat(web): add Fleet dashboard view for cross-instance observability

Adds a new /fleet route that shows a fleet-wide overview of all
configured Honcho instances. Each row renders per-instance metrics
(workspace count, total conclusions, queue activity, last seen, health)
by fanning out scoped fetches via createScopedClient, and aggregates
into top-level metric cards.

Reuses the Phase 2 scoped-client pattern. Extends compareQueries.ts
with useScopedQueueStatus and useScopedConclusionsCount, plus option
builders so useQueries can fan out per-workspace requests inside
FleetRow without duplicating query logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Agents
2026-05-24 17:21:14 +01:00
committed by Offending Commit
parent dd2edbae99
commit 12712bb0b0
8 changed files with 659 additions and 0 deletions

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