Merge pull request #42 from offendingcommit/feat/fleet-dashboard
feat(web): add Fleet dashboard for cross-instance observability
This commit is contained in:
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 },
|
||||
|
||||
Reference in New Issue
Block a user