Merge pull request #42 from offendingcommit/feat/fleet-dashboard
feat(web): add Fleet dashboard for cross-instance observability
This commit is contained in:
BIN
docs/screenshots/fleet-dashboard-dark.png
Normal file
BIN
docs/screenshots/fleet-dashboard-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/screenshots/fleet-dashboard-light.png
Normal file
BIN
docs/screenshots/fleet-dashboard-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
BIN
docs/screenshots/fleet-dashboard-mixed-health.png
Normal file
BIN
docs/screenshots/fleet-dashboard-mixed-health.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
51
packages/web/e2e/fleet.spec.ts
Normal file
51
packages/web/e2e/fleet.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ test.describe("Sidebar", () => {
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("complementary")).toBeVisible();
|
await expect(page.getByRole("complementary")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: /dashboard/i })).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: /workspaces/i })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: /settings/i })).toBeVisible();
|
await expect(page.getByRole("link", { name: /settings/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const CK = {
|
|||||||
["compare", instId, "peer-representation", wsId, pId] as const,
|
["compare", instId, "peer-representation", wsId, pId] as const,
|
||||||
peerCard: (instId: string, wsId: string, pId: string) =>
|
peerCard: (instId: string, wsId: string, pId: string) =>
|
||||||
["compare", instId, "peer-card", wsId, pId] as const,
|
["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) {
|
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),
|
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));
|
||||||
|
}
|
||||||
|
|||||||
177
packages/web/src/components/fleet/FleetDashboard.tsx
Normal file
177
packages/web/src/components/fleet/FleetDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
packages/web/src/components/fleet/FleetRow.tsx
Normal file
192
packages/web/src/components/fleet/FleetRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
packages/web/src/components/fleet/fleetAggregates.ts
Normal file
43
packages/web/src/components/fleet/fleetAggregates.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Moon,
|
Moon,
|
||||||
MoonStar,
|
MoonStar,
|
||||||
|
Network,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
@@ -31,6 +32,7 @@ import { COLOR } from "@/lib/constants";
|
|||||||
|
|
||||||
const TOP_NAV = [
|
const TOP_NAV = [
|
||||||
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
{ 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: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
|
||||||
{ to: "/seed-kits" as const, label: "Seed Kits", icon: Layers, exact: false },
|
{ to: "/seed-kits" as const, label: "Seed Kits", icon: Layers, exact: false },
|
||||||
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as WorkspacesRouteImport } from './routes/workspaces'
|
import { Route as WorkspacesRouteImport } from './routes/workspaces'
|
||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as SeedKitsRouteImport } from './routes/seed-kits'
|
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 ExploreRouteImport } from './routes/explore'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
||||||
@@ -42,6 +43,11 @@ const SeedKitsRoute = SeedKitsRouteImport.update({
|
|||||||
path: '/seed-kits',
|
path: '/seed-kits',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const FleetRoute = FleetRouteImport.update({
|
||||||
|
id: '/fleet',
|
||||||
|
path: '/fleet',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ExploreRoute = ExploreRouteImport.update({
|
const ExploreRoute = ExploreRouteImport.update({
|
||||||
id: '/explore',
|
id: '/explore',
|
||||||
path: '/explore',
|
path: '/explore',
|
||||||
@@ -126,6 +132,7 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/explore': typeof ExploreRoute
|
'/explore': typeof ExploreRoute
|
||||||
|
'/fleet': typeof FleetRoute
|
||||||
'/seed-kits': typeof SeedKitsRoute
|
'/seed-kits': typeof SeedKitsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/workspaces': typeof WorkspacesRoute
|
'/workspaces': typeof WorkspacesRoute
|
||||||
@@ -145,6 +152,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/explore': typeof ExploreRoute
|
'/explore': typeof ExploreRoute
|
||||||
|
'/fleet': typeof FleetRoute
|
||||||
'/seed-kits': typeof SeedKitsRoute
|
'/seed-kits': typeof SeedKitsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/workspaces': typeof WorkspacesRoute
|
'/workspaces': typeof WorkspacesRoute
|
||||||
@@ -165,6 +173,7 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/explore': typeof ExploreRoute
|
'/explore': typeof ExploreRoute
|
||||||
|
'/fleet': typeof FleetRoute
|
||||||
'/seed-kits': typeof SeedKitsRoute
|
'/seed-kits': typeof SeedKitsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/workspaces': typeof WorkspacesRoute
|
'/workspaces': typeof WorkspacesRoute
|
||||||
@@ -186,6 +195,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/explore'
|
| '/explore'
|
||||||
|
| '/fleet'
|
||||||
| '/seed-kits'
|
| '/seed-kits'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/workspaces'
|
| '/workspaces'
|
||||||
@@ -205,6 +215,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/explore'
|
| '/explore'
|
||||||
|
| '/fleet'
|
||||||
| '/seed-kits'
|
| '/seed-kits'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/workspaces'
|
| '/workspaces'
|
||||||
@@ -224,6 +235,7 @@ export interface FileRouteTypes {
|
|||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/explore'
|
| '/explore'
|
||||||
|
| '/fleet'
|
||||||
| '/seed-kits'
|
| '/seed-kits'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/workspaces'
|
| '/workspaces'
|
||||||
@@ -244,6 +256,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ExploreRoute: typeof ExploreRoute
|
ExploreRoute: typeof ExploreRoute
|
||||||
|
FleetRoute: typeof FleetRoute
|
||||||
SeedKitsRoute: typeof SeedKitsRoute
|
SeedKitsRoute: typeof SeedKitsRoute
|
||||||
SettingsRoute: typeof SettingsRoute
|
SettingsRoute: typeof SettingsRoute
|
||||||
WorkspacesRoute: typeof WorkspacesRoute
|
WorkspacesRoute: typeof WorkspacesRoute
|
||||||
@@ -284,6 +297,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SeedKitsRouteImport
|
preLoaderRoute: typeof SeedKitsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/fleet': {
|
||||||
|
id: '/fleet'
|
||||||
|
path: '/fleet'
|
||||||
|
fullPath: '/fleet'
|
||||||
|
preLoaderRoute: typeof FleetRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/explore': {
|
'/explore': {
|
||||||
id: '/explore'
|
id: '/explore'
|
||||||
path: '/explore'
|
path: '/explore'
|
||||||
@@ -388,6 +408,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ExploreRoute: ExploreRoute,
|
ExploreRoute: ExploreRoute,
|
||||||
|
FleetRoute: FleetRoute,
|
||||||
SeedKitsRoute: SeedKitsRoute,
|
SeedKitsRoute: SeedKitsRoute,
|
||||||
SettingsRoute: SettingsRoute,
|
SettingsRoute: SettingsRoute,
|
||||||
WorkspacesRoute: WorkspacesRoute,
|
WorkspacesRoute: WorkspacesRoute,
|
||||||
|
|||||||
6
packages/web/src/routes/fleet.tsx
Normal file
6
packages/web/src/routes/fleet.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { FleetDashboard } from "@/components/fleet/FleetDashboard";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/fleet")({
|
||||||
|
component: FleetDashboard,
|
||||||
|
});
|
||||||
175
packages/web/src/test/fleet.test.tsx
Normal file
175
packages/web/src/test/fleet.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user