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:
@@ -16,6 +16,9 @@ const CK = {
|
||||
["compare", instId, "peer-representation", wsId, pId] as const,
|
||||
peerCard: (instId: string, wsId: string, pId: string) =>
|
||||
["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) {
|
||||
@@ -83,3 +86,43 @@ export function useScopedPeerCard(instance: Instance, workspaceId: string, peerI
|
||||
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,
|
||||
Moon,
|
||||
MoonStar,
|
||||
Network,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
@@ -31,6 +32,7 @@ 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 },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as WorkspacesRouteImport } from './routes/workspaces'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
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 IndexRouteImport } from './routes/index'
|
||||
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
||||
@@ -42,6 +43,11 @@ const SeedKitsRoute = SeedKitsRouteImport.update({
|
||||
path: '/seed-kits',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const FleetRoute = FleetRouteImport.update({
|
||||
id: '/fleet',
|
||||
path: '/fleet',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ExploreRoute = ExploreRouteImport.update({
|
||||
id: '/explore',
|
||||
path: '/explore',
|
||||
@@ -126,6 +132,7 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/explore': typeof ExploreRoute
|
||||
'/fleet': typeof FleetRoute
|
||||
'/seed-kits': typeof SeedKitsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
@@ -145,6 +152,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/explore': typeof ExploreRoute
|
||||
'/fleet': typeof FleetRoute
|
||||
'/seed-kits': typeof SeedKitsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
@@ -165,6 +173,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/explore': typeof ExploreRoute
|
||||
'/fleet': typeof FleetRoute
|
||||
'/seed-kits': typeof SeedKitsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
@@ -186,6 +195,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/explore'
|
||||
| '/fleet'
|
||||
| '/seed-kits'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
@@ -205,6 +215,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/'
|
||||
| '/explore'
|
||||
| '/fleet'
|
||||
| '/seed-kits'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
@@ -224,6 +235,7 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/explore'
|
||||
| '/fleet'
|
||||
| '/seed-kits'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
@@ -244,6 +256,7 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ExploreRoute: typeof ExploreRoute
|
||||
FleetRoute: typeof FleetRoute
|
||||
SeedKitsRoute: typeof SeedKitsRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
WorkspacesRoute: typeof WorkspacesRoute
|
||||
@@ -284,6 +297,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SeedKitsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/fleet': {
|
||||
id: '/fleet'
|
||||
path: '/fleet'
|
||||
fullPath: '/fleet'
|
||||
preLoaderRoute: typeof FleetRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/explore': {
|
||||
id: '/explore'
|
||||
path: '/explore'
|
||||
@@ -388,6 +408,7 @@ declare module '@tanstack/react-router' {
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ExploreRoute: ExploreRoute,
|
||||
FleetRoute: FleetRoute,
|
||||
SeedKitsRoute: SeedKitsRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
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