diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index e5138c6..35307e9 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -6,7 +6,7 @@ runs:
steps:
- uses: pnpm/action-setup@v6
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
diff --git a/packages/web/src/components/dashboard/Dashboard.tsx b/packages/web/src/components/dashboard/Dashboard.tsx
index dfafe7d..74f3a0d 100644
--- a/packages/web/src/components/dashboard/Dashboard.tsx
+++ b/packages/web/src/components/dashboard/Dashboard.tsx
@@ -1,159 +1,101 @@
-import { Link } from "@tanstack/react-router";
+import { Link, useNavigate } from "@tanstack/react-router";
import { motion } from "framer-motion";
-import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
-import { useState } from "react";
-import { useQueueStatus, useWorkspaces } from "@/api/queries";
-import type { components } from "@/api/schema.d.ts";
-import { ErrorAlert } from "@/components/shared/ErrorAlert";
-import { Skeleton } from "@/components/shared/Skeleton";
-import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
-import { useDemo } from "@/hooks/useDemo";
+import { Boxes, LayoutDashboard, Network, Settings as SettingsIcon } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ computeFleetAggregates,
+ DEFAULT_ROW_METRICS,
+ type FleetRowMetrics,
+} from "@/components/fleet/fleetAggregates";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { Body, PageTitle, SectionHeading } from "@/components/ui/typography";
+import { useInstances } from "@/hooks/useInstances";
+import type { Instance } from "@/lib/config";
import { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
+import { ServerWorkspaceRows } from "./ServerWorkspaceRows";
-type QueueStatus = components["schemas"]["QueueStatus"];
+const ALL_SERVERS = "all";
-// ─── Per-workspace queue row ─────────────────────────────────────────────────
-
-function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
- const { mask } = useDemo();
- const { data, isLoading } = useQueueStatus(workspaceId);
-
- const pending = data?.pending_work_units ?? 0;
- const active = data?.in_progress_work_units ?? 0;
- const done = data?.completed_work_units ?? 0;
- const total = data?.total_work_units ?? 0;
- const isActive = active > 0 || pending > 0;
-
- return (
-
- |
-
-
- {mask(workspaceId)}
-
-
-
- |
-
-
- {isLoading ? (
-
- …
-
- ) : (
-
- {isActive ? (
-
-
-
- ) : (
-
- )}
-
- {isActive ? `${formatCount(pending + active)} pending` : "Idle"}
-
-
- )}
- |
-
- {(
- [
- { key: "total", val: total, color: "var(--text-2)" },
- { key: "done", val: done, color: COLOR.success },
- { key: "active", val: active, color: COLOR.warning },
- { key: "pending", val: pending, color: "var(--text-3)" },
- ] satisfies Array<{ key: string; val: number; color: string }>
- ).map(({ key, val, color }) => (
-
- {isLoading ? "—" : formatCount(val)}
- |
- ))}
-
+/**
+ * Unified, server-aware dashboard: every workspace across every configured server,
+ * labelled ` ()` and filterable by server. Aggregates fold in the
+ * cross-server totals (formerly the standalone Fleet view). Opening a workspace
+ * activates its server, then drills into the existing workspace detail route.
+ */
+export function Dashboard() {
+ const { instances, activeId, activate } = useInstances();
+ const navigate = useNavigate();
+ const [serverFilter, setServerFilter] = useState(ALL_SERVERS);
+ const [metricsById, setMetricsById] = useState>({});
+ const lastMetrics = useRef>({});
+
+ useEffect(() => {
+ if (serverFilter !== ALL_SERVERS && !instances.find((i) => i.id === serverFilter)) {
+ setServerFilter(ALL_SERVERS);
+ }
+ }, [instances, serverFilter]);
+
+ const onMetrics = useCallback((id: string, m: FleetRowMetrics) => {
+ const prev = lastMetrics.current[id];
+ if (
+ prev &&
+ prev.workspaceCount === m.workspaceCount &&
+ prev.conclusionCount === m.conclusionCount &&
+ prev.queueActive === m.queueActive &&
+ prev.queuePending === m.queuePending &&
+ prev.health === m.health
+ )
+ return;
+ lastMetrics.current = { ...lastMetrics.current, [id]: m };
+ setMetricsById((prev) => ({ ...prev, [id]: m }));
+ }, []);
+
+ const onOpenWorkspace = useCallback(
+ (instance: Instance, workspaceId: string) => {
+ if (instance.id !== activeId) activate(instance.id);
+ navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } as never });
+ },
+ [activeId, activate, navigate],
);
-}
-// ─── Aggregate banner ─────────────────────────────────────────────────────────
-// Each workspace row already called useQueueStatus — TanStack Query deduplicates
-// the fetches so calling the same hooks here just reads from cache.
-
-function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
- const statuses = workspaces.map((ws) => {
- const { data } = useQueueStatus(ws.id);
- return data as QueueStatus | undefined;
- });
-
- const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
- const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
- const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
- const allLoaded = statuses.every((d) => d !== undefined);
-
- return (
-
- {(
- [
- { label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
- { label: "Total done", value: totalDone, color: COLOR.success, always: false },
- { label: "Active", value: totalActive, color: COLOR.warning, always: false },
- {
- label: "Pending",
- value: totalPending,
- color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
- always: false,
- },
- ] as Array<{ label: string; value: number; color: string; always: boolean }>
- ).map(({ label, value, color, always }) => (
-
-
- {allLoaded || always ? formatCount(value) : "—"}
-
-
- {label}
-
-
- ))}
-
+ const shownInstances = useMemo(
+ () =>
+ serverFilter === ALL_SERVERS ? instances : instances.filter((i) => i.id === serverFilter),
+ [instances, serverFilter],
);
-}
-// ─── Main dashboard ───────────────────────────────────────────────────────────
-
-export function Dashboard() {
- const [page] = useState(1);
- const { data, isLoading, error } = useWorkspaces(page, 50);
+ const agg = useMemo(
+ () =>
+ computeFleetAggregates(shownInstances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS)),
+ [shownInstances, metricsById],
+ );
- const workspaces =
- (data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
- const total = (data as { total?: number } | undefined)?.total ?? 0;
+ if (instances.length === 0) {
+ return (
+
+
+
+ Go to Settings
+
+ }
+ />
+
+ );
+ }
return (
@@ -165,163 +107,136 @@ export function Dashboard() {
strokeWidth={1.5}
/>
Dashboard
- {total > 0 && (
-
- {total} workspace{total !== 1 ? "s" : ""}
-
- )}
-
- Overview of your Honcho instance
-
-
-
- {isLoading && }
-
- {!isLoading && workspaces.length > 0 && (
-
- {/* Aggregate stat row */}
-
-
-
-
- {/* Per-workspace queue table */}
-
-
-
-
Queue Status
-
- all workspaces · live polling
-
-
-
-
-
-
-
- {["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
- |
- {h}
- |
- ))}
-
-
-
- {workspaces.map((ws) => (
-
- ))}
-
-
-
-
-
- {total > workspaces.length && (
-
- Showing {workspaces.length} of {total} workspaces.{" "}
-
- View all →
-
-
- )}
-
- )}
-
- {!isLoading && workspaces.length === 0 && (
-
-
- No workspaces found.
+ {agg.totalInstances} server{agg.totalInstances !== 1 ? "s" : ""}
+
- )}
-
- );
-}
+ Workspaces across every configured server
+
-function DashboardSkeleton() {
- return (
-
-
- {Array.from({ length: 4 }).map((_, index) => (
-
-
-
-
- ))}
-
+
+
+
+
+ 0 ? COLOR.destructive : "var(--text-3)"}
+ />
+
-
+
-
-
-
+
+ Workspaces
+ {instances.length > 1 && (
+
+ )}
- {Array.from({ length: 6 }).map((_, index) => (
- |
-
- |
- ))}
+
+ Workspace (server)
+ |
+
+ Conclusions
+ |
+
+ Queue (a/p)
+ |
- {Array.from({ length: 5 }).map((_, rowIndex) => (
-
- |
-
- |
-
-
-
-
- |
- {Array.from({ length: 4 }).map((__, cellIndex) => (
-
-
-
-
- |
- ))}
-
+ {shownInstances.map((inst) => (
+
))}
+
+
+ );
+}
+
+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/dashboard/ServerWorkspaceRows.tsx b/packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
new file mode 100644
index 0000000..7204d5b
--- /dev/null
+++ b/packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
@@ -0,0 +1,191 @@
+import { useQueries } from "@tanstack/react-query";
+import { motion } from "framer-motion";
+import { ChevronRight, CircleDot } from "lucide-react";
+import { useEffect, useMemo } from "react";
+import {
+ scopedConclusionsCountOptions,
+ scopedQueueStatusOptions,
+ useScopedWorkspaces,
+} from "@/api/compareQueries";
+import type { components } from "@/api/schema.d.ts";
+import type { FleetRowMetrics } from "@/components/fleet/fleetAggregates";
+import { useDemo } from "@/hooks/useDemo";
+import type { Instance } from "@/lib/config";
+import { COLOR } from "@/lib/constants";
+import { formatCount } from "@/lib/utils";
+
+type Workspace = components["schemas"]["Workspace"];
+type QueueStatus = components["schemas"]["QueueStatus"];
+type ConclusionPage = components["schemas"]["Page_Conclusion_"];
+
+interface Props {
+ instance: Instance;
+ /** Open a workspace's drill-down (activates the instance first if needed). */
+ onOpenWorkspace: (instance: Instance, workspaceId: string) => void;
+ /** Report this server's summed metrics up for the aggregate header. */
+ onMetrics: (id: string, metrics: FleetRowMetrics) => void;
+}
+
+const WORKSPACE_PAGE_SIZE = 100;
+
+/**
+ * Renders one `
` per workspace on a single server (instance), labelled
+ * ` ()`, and reports the server's summed metrics up so the
+ * Dashboard header can aggregate across servers. Per-instance data fetching lives
+ * here (not in a parent loop) to satisfy the rules of hooks — one child per server.
+ */
+export function ServerWorkspaceRows({ instance, onOpenWorkspace, onMetrics }: Props) {
+ const { mask } = useDemo();
+ const workspacesQ = useScopedWorkspaces(instance, 1, WORKSPACE_PAGE_SIZE);
+
+ 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";
+ // Dep array uses primitives only — an object dep (e.g. the metrics shape) would
+ // create a new reference on each render even when values are unchanged, causing
+ // onMetrics → setMetricsById → re-render → new object → onMetrics … loop.
+ useEffect(() => {
+ onMetrics(instance.id, {
+ workspaceCount: totalWorkspaces,
+ conclusionCount,
+ queueActive,
+ queuePending,
+ lastSeen: null,
+ health,
+ });
+ }, [instance.id, totalWorkspaces, conclusionCount, queueActive, queuePending, health, onMetrics]);
+
+ if (workspacesQ.isError) {
+ return (
+
+ |
+
+ {instance.name} — unreachable
+
+ |
+
+ );
+ }
+
+ if (workspaces.length === 0) {
+ return (
+
+ |
+
+ {instance.name} — {workspacesQ.isLoading ? "loading…" : "no workspaces"}
+
+ |
+
+ );
+ }
+
+ return (
+ <>
+ {workspaces.map((ws, i) => {
+ const queue = queueResults[i]?.data as QueueStatus | undefined;
+ const active = queue?.in_progress_work_units ?? 0;
+ const pending = queue?.pending_work_units ?? 0;
+ const isActive = active > 0 || pending > 0;
+ const conclusions = (conclusionsResults[i]?.data as ConclusionPage | undefined)?.total;
+
+ return (
+
+ |
+
+ |
+
+
+ {conclusions === undefined ? "—" : formatCount(conclusions)}
+ |
+
+
+
+ {isActive ? (
+
+
+
+ ) : (
+
+ )}
+
+ {isActive ? `${formatCount(active)}/${formatCount(pending)}` : "idle"}
+
+
+ |
+
+ );
+ })}
+ >
+ );
+}
diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx
index c96d23e..97c9d62 100644
--- a/packages/web/src/components/layout/Sidebar.tsx
+++ b/packages/web/src/components/layout/Sidebar.tsx
@@ -15,7 +15,6 @@ import {
MessageSquare,
Moon,
MoonStar,
- Network,
Settings,
Sun,
Users,
@@ -32,7 +31,6 @@ 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 },
@@ -62,7 +60,9 @@ function useLastDataUpdate(): string {
useEffect(() => {
function refresh() {
- setNow(Date.now());
+ // No setNow here — calling setNow on every cache event causes a render loop on
+ // CI (each Date.now() call crosses a ms boundary → new value → React re-renders
+ // Sidebar → cache events fire again → loop). setNow belongs only in the interval.
const latest = queryClient
.getQueryCache()
.getAll()
@@ -72,7 +72,10 @@ function useLastDataUpdate(): string {
refresh();
const unsubscribe = queryClient.getQueryCache().subscribe(refresh);
- const interval = window.setInterval(refresh, 30_000);
+ const interval = window.setInterval(() => {
+ setNow(Date.now()); // refresh relative-time display ("X ago") every 30s
+ refresh();
+ }, 30_000);
return () => {
unsubscribe();
window.clearInterval(interval);
diff --git a/packages/web/src/routes/fleet.tsx b/packages/web/src/routes/fleet.tsx
index c4b91b0..a39548c 100644
--- a/packages/web/src/routes/fleet.tsx
+++ b/packages/web/src/routes/fleet.tsx
@@ -1,6 +1,7 @@
-import { createFileRoute } from "@tanstack/react-router";
-import { FleetDashboard } from "@/components/fleet/FleetDashboard";
+import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/fleet")({
- component: FleetDashboard,
+ beforeLoad: () => {
+ throw redirect({ to: "/" });
+ },
});
diff --git a/packages/web/src/test/dashboard.test.tsx b/packages/web/src/test/dashboard.test.tsx
new file mode 100644
index 0000000..b97ce83
--- /dev/null
+++ b/packages/web/src/test/dashboard.test.tsx
@@ -0,0 +1,106 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+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";
+
+// One mocked transport for every scoped call; branch by URL so the per-workspace
+// fan-out (workspaces list → queue status + conclusions count) all resolve.
+vi.mock("@/lib/http", () => ({
+ httpFetch: vi.fn(async (input: Request | string) => {
+ const url = typeof input === "string" ? input : input.url;
+ const json = (body: unknown) =>
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ if (url.includes("/queue/status")) {
+ return json({
+ in_progress_work_units: 0,
+ pending_work_units: 0,
+ completed_work_units: 0,
+ total_work_units: 0,
+ });
+ }
+ if (url.includes("/conclusions/list")) {
+ return json({ items: [], total: 5, page: 1, size: 1, pages: 1 });
+ }
+ return json({ items: [{ id: "ws-1" }], total: 1, page: 1, size: 100, pages: 1 });
+ }),
+}));
+
+const neo: Instance = { id: "neo", name: "Neo", baseUrl: "https://neo.example.net", token: "" };
+const iris: Instance = { id: "iris", name: "Iris", baseUrl: "https://iris.example.net", token: "" };
+
+function renderDashboard() {
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ return render(
+
+
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
+
+
+
+ ,
+ );
+}
+
+describe("Dashboard — unified server-aware view", () => {
+ afterEach(() => localStorage.clear());
+
+ it("does not loop when Date.now advances on each call (CI render-loop repro)", async () => {
+ // On CI, consecutive Date.now() calls cross millisecond boundaries, so setNow(Date.now())
+ // in the cache-event subscriber always produces a new value → React keeps re-rendering
+ // Sidebar → hits the 25-cycle "Maximum update depth exceeded" limit.
+ // This test forces that CI condition locally to catch regressions.
+ let t = 1_000_000;
+ const spy = vi.spyOn(Date, "now").mockImplementation(() => t++);
+ saveStore({ instances: [neo, iris], activeId: "neo" });
+ renderDashboard();
+ await waitFor(() => {
+ expect(screen.getByText("(Neo)")).toBeInTheDocument();
+ expect(screen.getByText("(Iris)")).toBeInTheDocument();
+ });
+ spy.mockRestore();
+ });
+
+ it("lists each server's workspaces labelled with the server name", async () => {
+ saveStore({ instances: [neo, iris], activeId: "neo" });
+ renderDashboard();
+ await waitFor(() => {
+ expect(screen.getByText("(Neo)")).toBeInTheDocument();
+ expect(screen.getByText("(Iris)")).toBeInTheDocument();
+ });
+ });
+
+ it("offers a server filter listing every server", async () => {
+ saveStore({ instances: [neo, iris], activeId: "neo" });
+ renderDashboard();
+ const select = await screen.findByLabelText("Filter by server");
+ expect(within(select).getByRole("option", { name: "Neo" })).toBeInTheDocument();
+ expect(within(select).getByRole("option", { name: "Iris" })).toBeInTheDocument();
+ });
+
+ it("narrows to a single server when filtered", async () => {
+ saveStore({ instances: [neo, iris], activeId: "neo" });
+ renderDashboard();
+ await screen.findByText("(Iris)");
+ const select = await screen.findByLabelText("Filter by server");
+ fireEvent.change(select, { target: { value: "iris" } });
+ await waitFor(() => {
+ expect(screen.queryByText("(Neo)")).not.toBeInTheDocument();
+ expect(screen.getByText("(Iris)")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/web/src/test/fleet.test.tsx b/packages/web/src/test/fleet.test.tsx
index 7ed13ac..d1bf000 100644
--- a/packages/web/src/test/fleet.test.tsx
+++ b/packages/web/src/test/fleet.test.tsx
@@ -1,6 +1,6 @@
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 { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { scopedConclusionsCountOptions, scopedQueueStatusOptions } from "@/api/compareQueries";
import {
@@ -157,21 +157,18 @@ describe("Fleet route", () => {
localStorage.clear();
});
- it("mounts FleetDashboard at /fleet when an instance is configured", async () => {
+ it("redirects /fleet to the Dashboard", async () => {
saveStore({ instances: [neo], activeId: "neo" });
renderRouteAt("/fleet");
- expect(await screen.findByRole("heading", { name: /Fleet/i })).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
});
- it("renders one table row per configured instance", async () => {
+ it("shows each instance after /fleet redirect", 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();
+ expect(screen.getByText("Neo — no workspaces")).toBeInTheDocument();
+ expect(screen.getByText("Iris — no workspaces")).toBeInTheDocument();
});
- // 1 header + 2 instance rows
- expect(within(table).getAllByRole("row")).toHaveLength(3);
});
});
diff --git a/packages/web/src/test/server-workspace-rows.test.tsx b/packages/web/src/test/server-workspace-rows.test.tsx
new file mode 100644
index 0000000..11b0f83
--- /dev/null
+++ b/packages/web/src/test/server-workspace-rows.test.tsx
@@ -0,0 +1,95 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, render, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ServerWorkspaceRows } from "@/components/dashboard/ServerWorkspaceRows";
+import type { FleetRowMetrics } from "@/components/fleet/fleetAggregates";
+import { DemoProvider } from "@/context/DemoContext";
+import type { Instance } from "@/lib/config";
+
+vi.mock("@/lib/http", () => ({
+ httpFetch: vi.fn(async (input: Request | string) => {
+ const url = typeof input === "string" ? input : input.url;
+ const json = (body: unknown) =>
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ if (url.includes("/queue/status")) {
+ return json({ in_progress_work_units: 0, pending_work_units: 0 });
+ }
+ if (url.includes("/conclusions/list")) {
+ return json({ items: [], total: 3, page: 1, size: 1, pages: 1 });
+ }
+ return json({ items: [{ id: "ws-1" }], total: 1, page: 1, size: 100, pages: 1 });
+ }),
+}));
+
+const neo: Instance = { id: "neo", name: "Neo", baseUrl: "https://neo.example.net", token: "" };
+
+function makeQc() {
+ return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+}
+
+function renderRows(instance: Instance, onMetrics: (id: string, metrics: FleetRowMetrics) => void) {
+ const qc = makeQc();
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("ServerWorkspaceRows — onMetrics stability", () => {
+ afterEach(() => localStorage.clear());
+
+ it("calls onMetrics with health:ok after data loads", async () => {
+ const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
+ renderRows(neo, onMetrics);
+ await waitFor(() =>
+ expect(onMetrics).toHaveBeenCalledWith(
+ "neo",
+ expect.objectContaining({ health: "ok", workspaceCount: 1, conclusionCount: 3 }),
+ ),
+ );
+ });
+
+ it("does not call onMetrics again when values have not changed", async () => {
+ const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
+ renderRows(neo, onMetrics);
+
+ // Wait until we have at least one call with stable state
+ await waitFor(() =>
+ expect(onMetrics).toHaveBeenCalledWith("neo", expect.objectContaining({ health: "ok" })),
+ );
+
+ const callsBefore = onMetrics.mock.calls.length;
+
+ // Flush any pending micro-tasks / React batched updates
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 50));
+ });
+
+ // onMetrics must not have been called again — no render loop
+ expect(onMetrics).toHaveBeenCalledTimes(callsBefore);
+ });
+
+ it("calls onMetrics when health transitions from loading to ok", async () => {
+ const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
+ renderRows(neo, onMetrics);
+
+ // Must eventually report ok (not just loading)
+ await waitFor(() =>
+ expect(onMetrics).toHaveBeenCalledWith("neo", expect.objectContaining({ health: "ok" })),
+ );
+ });
+});