diff --git a/packages/web/src/components/dashboard/Dashboard.tsx b/packages/web/src/components/dashboard/Dashboard.tsx
index dfafe7d..ac72827 100644
--- a/packages/web/src/components/dashboard/Dashboard.tsx
+++ b/packages/web/src/components/dashboard/Dashboard.tsx
@@ -1,159 +1,83 @@
-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, useMemo, 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"];
-
-// ─── 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)}
- |
- ))}
-
- );
-}
-
-// ─── 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}
-
-
- ))}
-
- );
-}
-
-// ─── Main dashboard ───────────────────────────────────────────────────────────
+const ALL_SERVERS = "all";
+/**
+ * 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 [page] = useState(1);
- const { data, isLoading, error } = useWorkspaces(page, 50);
+ const { instances, activeId, activate } = useInstances();
+ const navigate = useNavigate();
+ const [serverFilter, setServerFilter] = useState(ALL_SERVERS);
+ const [metricsById, setMetricsById] = useState>({});
- const workspaces =
- (data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
- const total = (data as { total?: number } | undefined)?.total ?? 0;
+ const onMetrics = useCallback((id: string, m: FleetRowMetrics) => {
+ 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],
+ );
+
+ const shownInstances = useMemo(
+ () =>
+ serverFilter === ALL_SERVERS ? instances : instances.filter((i) => i.id === serverFilter),
+ [instances, serverFilter],
+ );
+
+ const agg = useMemo(
+ () =>
+ computeFleetAggregates(shownInstances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS)),
+ [shownInstances, metricsById],
+ );
+
+ if (instances.length === 0) {
+ return (
+
+
+
+ Go to Settings
+
+ }
+ />
+
+ );
+ }
return (
@@ -165,163 +89,136 @@ export function Dashboard() {
strokeWidth={1.5}
/>
Dashboard
- {total > 0 && (
-
- {total} workspace{total !== 1 ? "s" : ""}
-
- )}
+
+ {agg.totalInstances} server{agg.totalInstances !== 1 ? "s" : ""}
+
- Overview of your Honcho instance
+ Workspaces across every configured server
-
- {isLoading && }
+
+
+
+
+ 0 ? COLOR.destructive : "var(--text-3)"}
+ />
+
- {!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.
-
- )}
-
- );
-}
-
-function DashboardSkeleton() {
- return (
-
-
- {Array.from({ length: 4 }).map((_, index) => (
-
-
-
-
- ))}
-
-
-
+
-
-
-
+
+ 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..8e2e909
--- /dev/null
+++ b/packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
@@ -0,0 +1,209 @@
+import { useQueries } from "@tanstack/react-query";
+import { motion } from "framer-motion";
+import { ChevronRight, 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 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;
+
+function metricsEqual(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
+ );
+}
+
+/**
+ * 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";
+ const lastSeen = workspacesQ.dataUpdatedAt > 0 ? workspacesQ.dataUpdatedAt : null;
+
+ 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 && metricsEqual(lastReported.current, metrics)) return;
+ lastReported.current = metrics;
+ onMetrics(instance.id, metrics);
+ }, [instance.id, metrics, 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..7a9ba55 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 },
diff --git a/packages/web/src/test/dashboard.test.tsx b/packages/web/src/test/dashboard.test.tsx
new file mode 100644
index 0000000..ae0bf29
--- /dev/null
+++ b/packages/web/src/test/dashboard.test.tsx
@@ -0,0 +1,89 @@
+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 userEvent from "@testing-library/user-event";
+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 } } });
+ return render(
+
+
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
+
+
+
+ ,
+ );
+}
+
+describe("Dashboard — unified server-aware view", () => {
+ afterEach(() => localStorage.clear());
+
+ 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" });
+ const user = userEvent.setup();
+ renderDashboard();
+ await screen.findByText("(Iris)");
+ await user.selectOptions(await screen.findByLabelText("Filter by server"), "iris");
+ await waitFor(() => {
+ expect(screen.queryByText("(Neo)")).not.toBeInTheDocument();
+ expect(screen.getByText("(Iris)")).toBeInTheDocument();
+ });
+ });
+});