Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/screenshots/fleet-dashboard-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/fleet-dashboard-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 51 additions & 0 deletions packages/web/e2e/fleet.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
1 change: 1 addition & 0 deletions packages/web/e2e/sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ test.describe("Sidebar", () => {
await page.goto("/");
await expect(page.getByRole("complementary")).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: /settings/i })).toBeVisible();
});
Expand Down
43 changes: 43 additions & 0 deletions packages/web/src/api/compareQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 changes: 177 additions & 0 deletions packages/web/src/components/fleet/FleetDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading
Loading