Skip to content

Commit ab69b00

Browse files
Merge pull request #42 from offendingcommit/feat/fleet-dashboard
feat(web): add Fleet dashboard for cross-instance observability
2 parents 472b140 + 37eb9bd commit ab69b00

13 files changed

Lines changed: 711 additions & 0 deletions
218 KB
Loading
207 KB
Loading
215 KB
Loading

‎packages/web/e2e/fleet.spec.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
const STORE_KEY = "openconcho:instances";
4+
5+
// Two unreachable instances — the rows still render with their configured
6+
// names; only the health column flips to "unreachable" once the workspaces
7+
// query errors. We only assert on the rendered names + row count, so the
8+
// test doesn't depend on a live backend.
9+
const FLEET_STORE = JSON.stringify({
10+
instances: [
11+
{ id: "a", name: "Neo", baseUrl: "http://localhost:9001", token: "" },
12+
{ id: "b", name: "Iris", baseUrl: "http://localhost:9002", token: "" },
13+
{ id: "c", name: "Lexi", baseUrl: "http://localhost:9003", token: "" },
14+
],
15+
activeId: "a",
16+
});
17+
18+
test.describe("Fleet route", () => {
19+
test.beforeEach(async ({ context }) => {
20+
await context.addInitScript(
21+
([key, value]) => {
22+
window.localStorage.setItem(key, value);
23+
},
24+
[STORE_KEY, FLEET_STORE],
25+
);
26+
});
27+
28+
test("renders one row per configured instance and the Fleet heading", async ({ page }) => {
29+
await page.goto("/fleet");
30+
31+
// Page header
32+
await expect(page.getByRole("heading", { name: /^Fleet$/ })).toBeVisible();
33+
34+
// One row per instance, asserted via the table not the sidebar (the
35+
// active instance's name also appears in the sidebar switcher).
36+
const table = page.getByRole("table");
37+
await expect(table.getByText("Neo", { exact: true })).toBeVisible();
38+
await expect(table.getByText("Iris", { exact: true })).toBeVisible();
39+
await expect(table.getByText("Lexi", { exact: true })).toBeVisible();
40+
41+
// 1 header row + 3 instance rows
42+
await expect(table.getByRole("row")).toHaveCount(4);
43+
});
44+
45+
test("Fleet link in the sidebar navigates to /fleet", async ({ page }) => {
46+
await page.goto("/");
47+
await page.getByRole("link", { name: /fleet/i }).click();
48+
await expect(page).toHaveURL(/\/fleet$/);
49+
await expect(page.getByRole("heading", { name: /^Fleet$/ })).toBeVisible();
50+
});
51+
});

‎packages/web/e2e/sidebar.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ test.describe("Sidebar", () => {
1717
await page.goto("/");
1818
await expect(page.getByRole("complementary")).toBeVisible();
1919
await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible();
20+
await expect(page.getByRole("link", { name: /fleet/i })).toBeVisible();
2021
await expect(page.getByRole("link", { name: /workspaces/i })).toBeVisible();
2122
await expect(page.getByRole("link", { name: /settings/i })).toBeVisible();
2223
});

‎packages/web/src/api/compareQueries.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const CK = {
1616
["compare", instId, "peer-representation", wsId, pId] as const,
1717
peerCard: (instId: string, wsId: string, pId: string) =>
1818
["compare", instId, "peer-card", wsId, pId] as const,
19+
queueStatus: (instId: string, wsId: string) => ["compare", instId, "queue-status", wsId] as const,
20+
conclusionsCount: (instId: string, wsId: string) =>
21+
["compare", instId, "conclusions-count", wsId] as const,
1922
};
2023

2124
export function useScopedWorkspaces(instance: Instance, page = 1, pageSize = 20) {
@@ -83,3 +86,43 @@ export function useScopedPeerCard(instance: Instance, workspaceId: string, peerI
8386
enabled: Boolean(workspaceId) && Boolean(peerId),
8487
});
8588
}
89+
90+
// Option builders — used by both single-fetch hooks and useQueries fan-out (e.g. Fleet view).
91+
92+
export function scopedQueueStatusOptions(instance: Instance, workspaceId: string) {
93+
return {
94+
queryKey: CK.queueStatus(instance.id, workspaceId),
95+
queryFn: async () => {
96+
const client = createScopedClient(instance);
97+
const { data, error } = await client.GET("/v3/workspaces/{workspace_id}/queue/status", {
98+
params: { path: { workspace_id: workspaceId } },
99+
});
100+
return data ?? err(error);
101+
},
102+
enabled: Boolean(workspaceId),
103+
refetchInterval: 10_000,
104+
} as const;
105+
}
106+
107+
export function scopedConclusionsCountOptions(instance: Instance, workspaceId: string) {
108+
return {
109+
queryKey: CK.conclusionsCount(instance.id, workspaceId),
110+
queryFn: async () => {
111+
const client = createScopedClient(instance);
112+
const { data, error } = await client.POST("/v3/workspaces/{workspace_id}/conclusions/list", {
113+
params: { path: { workspace_id: workspaceId }, query: { page: 1, size: 1 } },
114+
body: {},
115+
});
116+
return data ?? err(error);
117+
},
118+
enabled: Boolean(workspaceId),
119+
} as const;
120+
}
121+
122+
export function useScopedQueueStatus(instance: Instance, workspaceId: string) {
123+
return useQuery(scopedQueueStatusOptions(instance, workspaceId));
124+
}
125+
126+
export function useScopedConclusionsCount(instance: Instance, workspaceId: string) {
127+
return useQuery(scopedConclusionsCountOptions(instance, workspaceId));
128+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { Link } from "@tanstack/react-router";
2+
import { motion } from "framer-motion";
3+
import { Network, Server, Settings as SettingsIcon } from "lucide-react";
4+
import { useCallback, useMemo, useState } from "react";
5+
import { EmptyState } from "@/components/shared/EmptyState";
6+
import { Body, PageTitle, SectionHeading } from "@/components/ui/typography";
7+
import { useInstances } from "@/hooks/useInstances";
8+
import { COLOR } from "@/lib/constants";
9+
import { formatCount } from "@/lib/utils";
10+
import { FleetRow } from "./FleetRow";
11+
import {
12+
computeFleetAggregates,
13+
DEFAULT_ROW_METRICS,
14+
type FleetRowMetrics,
15+
} from "./fleetAggregates";
16+
17+
export function FleetDashboard() {
18+
const { instances } = useInstances();
19+
const [metricsById, setMetricsById] = useState<Record<string, FleetRowMetrics>>({});
20+
21+
const setMetrics = useCallback((id: string, m: FleetRowMetrics) => {
22+
setMetricsById((prev) => ({ ...prev, [id]: m }));
23+
}, []);
24+
25+
const rows = useMemo(
26+
() => instances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS),
27+
[instances, metricsById],
28+
);
29+
const agg = useMemo(() => computeFleetAggregates(rows), [rows]);
30+
31+
if (instances.length === 0) {
32+
return (
33+
<div className="page-container page-container--xl">
34+
<EmptyState
35+
icon={Network}
36+
title="No instances configured"
37+
description="Add at least one Honcho instance in Settings to use the Fleet view."
38+
action={
39+
<Link
40+
to="/settings"
41+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md"
42+
style={{
43+
background: "var(--accent-dim)",
44+
border: "1px solid var(--accent-border)",
45+
color: "var(--accent-text)",
46+
}}
47+
>
48+
<SettingsIcon className="w-4 h-4" strokeWidth={1.5} />
49+
Go to Settings
50+
</Link>
51+
}
52+
/>
53+
</div>
54+
);
55+
}
56+
57+
return (
58+
<div className="page-container page-container--xl">
59+
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
60+
<div className="flex items-center gap-2 mb-1">
61+
<Network className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
62+
<PageTitle>Fleet</PageTitle>
63+
<span
64+
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
65+
style={{
66+
background: COLOR.accentSubtle,
67+
color: COLOR.accentText,
68+
border: `1px solid ${COLOR.accentBorder}`,
69+
}}
70+
>
71+
{agg.totalInstances} agent{agg.totalInstances !== 1 ? "s" : ""}
72+
</span>
73+
</div>
74+
<Body className="leading-none">Cross-instance overview of all configured agents</Body>
75+
</motion.div>
76+
77+
<motion.div
78+
initial={{ opacity: 0, y: 8 }}
79+
animate={{ opacity: 1, y: 0 }}
80+
transition={{ delay: 0.05 }}
81+
className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"
82+
>
83+
<MetricCard label="Workspaces" value={agg.totalWorkspaces} />
84+
<MetricCard label="Conclusions" value={agg.totalConclusions} accent />
85+
<MetricCard
86+
label="Healthy"
87+
value={agg.healthyCount}
88+
total={agg.totalInstances}
89+
color={agg.healthyCount === agg.totalInstances ? COLOR.success : COLOR.warning}
90+
/>
91+
<MetricCard
92+
label="Unreachable"
93+
value={agg.unreachableCount}
94+
color={agg.unreachableCount > 0 ? COLOR.destructive : "var(--text-3)"}
95+
/>
96+
</motion.div>
97+
98+
<motion.div
99+
initial={{ opacity: 0, y: 8 }}
100+
animate={{ opacity: 1, y: 0 }}
101+
transition={{ delay: 0.12 }}
102+
className="rounded-xl theme-card overflow-hidden"
103+
>
104+
<div
105+
className="flex items-center gap-2 px-4 py-3"
106+
style={{ borderBottom: "1px solid var(--border)" }}
107+
>
108+
<Server className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
109+
<SectionHeading className="mb-0">Agents</SectionHeading>
110+
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
111+
all configured instances · queue updates every 10s
112+
</span>
113+
</div>
114+
115+
<div className="overflow-x-auto">
116+
<table className="w-full text-xs">
117+
<thead>
118+
<tr style={{ background: "var(--bg-3)" }}>
119+
<th className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
120+
Agent
121+
</th>
122+
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
123+
Workspaces
124+
</th>
125+
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
126+
Conclusions
127+
</th>
128+
<th
129+
className="py-2 px-4 font-medium text-right"
130+
style={{ color: "var(--text-3)" }}
131+
title="Active / Pending queue work units"
132+
>
133+
Queue (a/p)
134+
</th>
135+
<th className="py-2 px-4 font-medium text-right" style={{ color: "var(--text-3)" }}>
136+
Last seen
137+
</th>
138+
</tr>
139+
</thead>
140+
<tbody>
141+
{instances.map((inst) => (
142+
<FleetRow key={inst.id} instance={inst} onMetrics={setMetrics} />
143+
))}
144+
</tbody>
145+
</table>
146+
</div>
147+
</motion.div>
148+
</div>
149+
);
150+
}
151+
152+
interface MetricCardProps {
153+
label: string;
154+
value: number;
155+
total?: number;
156+
color?: string;
157+
accent?: boolean;
158+
}
159+
160+
function MetricCard({ label, value, total, color, accent }: MetricCardProps) {
161+
const valueColor = color ?? (accent ? COLOR.accentText : "var(--text-1)");
162+
return (
163+
<div className="rounded-xl p-4 theme-card">
164+
<div className="text-2xl font-semibold font-mono" style={{ color: valueColor }}>
165+
{formatCount(value)}
166+
{total !== undefined && (
167+
<span className="text-base ml-1" style={{ color: "var(--text-4)" }}>
168+
/ {formatCount(total)}
169+
</span>
170+
)}
171+
</div>
172+
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
173+
{label}
174+
</div>
175+
</div>
176+
);
177+
}

0 commit comments

Comments
 (0)