Merge pull request #61 from offendingcommit/feat/dashboard-fleet-merge
This commit is contained in:
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -6,7 +6,7 @@ runs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|||||||
@@ -1,159 +1,101 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
|
import { Boxes, LayoutDashboard, Network, Settings as SettingsIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
import {
|
||||||
import type { components } from "@/api/schema.d.ts";
|
computeFleetAggregates,
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
DEFAULT_ROW_METRICS,
|
||||||
import { Skeleton } from "@/components/shared/Skeleton";
|
type FleetRowMetrics,
|
||||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
} from "@/components/fleet/fleetAggregates";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { Body, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||||
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
import { formatCount } from "@/lib/utils";
|
import { formatCount } from "@/lib/utils";
|
||||||
|
import { ServerWorkspaceRows } from "./ServerWorkspaceRows";
|
||||||
|
|
||||||
type QueueStatus = components["schemas"]["QueueStatus"];
|
const ALL_SERVERS = "all";
|
||||||
|
|
||||||
// ─── Per-workspace queue row ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
|
|
||||||
const { mask } = useDemo();
|
|
||||||
const { data, isLoading } = useQueueStatus(workspaceId);
|
|
||||||
|
|
||||||
const pending = data?.pending_work_units ?? 0;
|
|
||||||
const active = data?.in_progress_work_units ?? 0;
|
|
||||||
const done = data?.completed_work_units ?? 0;
|
|
||||||
const total = data?.total_work_units ?? 0;
|
|
||||||
const isActive = active > 0 || pending > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
style={{
|
|
||||||
borderTop: "1px solid var(--border)",
|
|
||||||
background: isActive ? COLOR.warningDim : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td className="py-2 px-4">
|
|
||||||
<Link
|
|
||||||
to="/workspaces/$workspaceId"
|
|
||||||
params={{ workspaceId } as never}
|
|
||||||
className="flex items-center gap-2 group"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
|
|
||||||
style={{ color: "var(--accent-text)" }}
|
|
||||||
>
|
|
||||||
{mask(workspaceId)}
|
|
||||||
</span>
|
|
||||||
<ChevronRight
|
|
||||||
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-2 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: COLOR.success }} strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="text-xs font-medium"
|
|
||||||
style={{ color: isActive ? COLOR.warning : COLOR.success }}
|
|
||||||
>
|
|
||||||
{isActive ? `${formatCount(pending + active)} pending` : "Idle"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ key: "total", val: total, color: "var(--text-2)" },
|
|
||||||
{ key: "done", val: done, color: COLOR.success },
|
|
||||||
{ key: "active", val: active, color: COLOR.warning },
|
|
||||||
{ key: "pending", val: pending, color: "var(--text-3)" },
|
|
||||||
] satisfies Array<{ key: string; val: number; color: string }>
|
|
||||||
).map(({ key, val, color }) => (
|
|
||||||
<td
|
|
||||||
key={key}
|
|
||||||
className="py-2 px-4 text-right font-mono text-xs"
|
|
||||||
style={{ color: isLoading ? "var(--text-4)" : color }}
|
|
||||||
>
|
|
||||||
{isLoading ? "—" : formatCount(val)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Aggregate banner ─────────────────────────────────────────────────────────
|
|
||||||
// Each workspace row already called useQueueStatus — TanStack Query deduplicates
|
|
||||||
// the fetches so calling the same hooks here just reads from cache.
|
|
||||||
|
|
||||||
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
|
|
||||||
const statuses = workspaces.map((ws) => {
|
|
||||||
const { data } = useQueueStatus(ws.id);
|
|
||||||
return data as QueueStatus | undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
|
|
||||||
const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
|
|
||||||
const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
|
|
||||||
const allLoaded = statuses.every((d) => d !== undefined);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
|
|
||||||
{ label: "Total done", value: totalDone, color: COLOR.success, always: false },
|
|
||||||
{ label: "Active", value: totalActive, color: COLOR.warning, always: false },
|
|
||||||
{
|
|
||||||
label: "Pending",
|
|
||||||
value: totalPending,
|
|
||||||
color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
|
|
||||||
always: false,
|
|
||||||
},
|
|
||||||
] as Array<{ label: string; value: number; color: string; always: boolean }>
|
|
||||||
).map(({ label, value, color, always }) => (
|
|
||||||
<div key={label} className="rounded-xl p-4 theme-card">
|
|
||||||
<div
|
|
||||||
className="text-2xl font-semibold font-mono"
|
|
||||||
style={{ color: allLoaded || always ? color : "var(--text-4)" }}
|
|
||||||
>
|
|
||||||
{allLoaded || always ? formatCount(value) : "—"}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main dashboard ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified, server-aware dashboard: every workspace across every configured server,
|
||||||
|
* labelled `<workspace> (<server>)` and filterable by server. Aggregates fold in the
|
||||||
|
* cross-server totals (formerly the standalone Fleet view). Opening a workspace
|
||||||
|
* activates its server, then drills into the existing workspace detail route.
|
||||||
|
*/
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [page] = useState(1);
|
const { instances, activeId, activate } = useInstances();
|
||||||
const { data, isLoading, error } = useWorkspaces(page, 50);
|
const navigate = useNavigate();
|
||||||
|
const [serverFilter, setServerFilter] = useState<string>(ALL_SERVERS);
|
||||||
|
const [metricsById, setMetricsById] = useState<Record<string, FleetRowMetrics>>({});
|
||||||
|
const lastMetrics = useRef<Record<string, FleetRowMetrics>>({});
|
||||||
|
|
||||||
const workspaces =
|
useEffect(() => {
|
||||||
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
if (serverFilter !== ALL_SERVERS && !instances.find((i) => i.id === serverFilter)) {
|
||||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
setServerFilter(ALL_SERVERS);
|
||||||
|
}
|
||||||
|
}, [instances, serverFilter]);
|
||||||
|
|
||||||
|
const onMetrics = useCallback((id: string, m: FleetRowMetrics) => {
|
||||||
|
const prev = lastMetrics.current[id];
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
prev.workspaceCount === m.workspaceCount &&
|
||||||
|
prev.conclusionCount === m.conclusionCount &&
|
||||||
|
prev.queueActive === m.queueActive &&
|
||||||
|
prev.queuePending === m.queuePending &&
|
||||||
|
prev.health === m.health
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
lastMetrics.current = { ...lastMetrics.current, [id]: m };
|
||||||
|
setMetricsById((prev) => ({ ...prev, [id]: m }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onOpenWorkspace = useCallback(
|
||||||
|
(instance: Instance, workspaceId: string) => {
|
||||||
|
if (instance.id !== activeId) activate(instance.id);
|
||||||
|
navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } as never });
|
||||||
|
},
|
||||||
|
[activeId, activate, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shownInstances = useMemo(
|
||||||
|
() =>
|
||||||
|
serverFilter === ALL_SERVERS ? instances : instances.filter((i) => i.id === serverFilter),
|
||||||
|
[instances, serverFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const agg = useMemo(
|
||||||
|
() =>
|
||||||
|
computeFleetAggregates(shownInstances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS)),
|
||||||
|
[shownInstances, metricsById],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (instances.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="page-container page-container--xl">
|
||||||
|
<EmptyState
|
||||||
|
icon={Boxes}
|
||||||
|
title="No servers configured"
|
||||||
|
description="Add at least one Honcho server in Settings to see your workspaces."
|
||||||
|
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 (
|
return (
|
||||||
<div className="page-container page-container--xl">
|
<div className="page-container page-container--xl">
|
||||||
@@ -165,163 +107,136 @@ export function Dashboard() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<PageTitle>Dashboard</PageTitle>
|
<PageTitle>Dashboard</PageTitle>
|
||||||
{total > 0 && (
|
<span
|
||||||
<span
|
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
style={{
|
||||||
style={{
|
background: COLOR.accentSubtle,
|
||||||
background: COLOR.accentSubtle,
|
color: COLOR.accentText,
|
||||||
color: COLOR.accentText,
|
border: `1px solid ${COLOR.accentBorder}`,
|
||||||
border: `1px solid ${COLOR.accentBorder}`,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{agg.totalInstances} server{agg.totalInstances !== 1 ? "s" : ""}
|
||||||
{total} workspace{total !== 1 ? "s" : ""}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Body className="leading-none">Overview of your Honcho instance</Body>
|
<Body className="leading-none">Workspaces across every configured server</Body>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<motion.div
|
||||||
{isLoading && <DashboardSkeleton />}
|
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>
|
||||||
|
|
||||||
{!isLoading && workspaces.length > 0 && (
|
<motion.div
|
||||||
<div className="space-y-4">
|
initial={{ opacity: 0, y: 8 }}
|
||||||
{/* Aggregate stat row */}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<motion.div
|
transition={{ delay: 0.12 }}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
className="rounded-xl theme-card overflow-hidden"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
>
|
||||||
transition={{ delay: 0.05 }}
|
|
||||||
>
|
|
||||||
<GlobalQueueBanner workspaces={workspaces} />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Per-workspace queue table */}
|
|
||||||
<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)" }}
|
|
||||||
>
|
|
||||||
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
|
||||||
<SectionHeading className="mb-0">Queue Status</SectionHeading>
|
|
||||||
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
|
||||||
all workspaces · live polling
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr style={{ background: "var(--bg-3)" }}>
|
|
||||||
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
|
|
||||||
<th
|
|
||||||
key={h}
|
|
||||||
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
|
|
||||||
style={{ color: "var(--text-3)" }}
|
|
||||||
>
|
|
||||||
{h}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{workspaces.map((ws) => (
|
|
||||||
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{total > workspaces.length && (
|
|
||||||
<p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
|
|
||||||
Showing {workspaces.length} of {total} workspaces.{" "}
|
|
||||||
<Link
|
|
||||||
to="/workspaces"
|
|
||||||
className="hover:underline"
|
|
||||||
style={{ color: "var(--accent-text)" }}
|
|
||||||
>
|
|
||||||
View all →
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && workspaces.length === 0 && (
|
|
||||||
<div className="rounded-xl p-10 text-center theme-card">
|
|
||||||
<Boxes
|
|
||||||
className="w-8 h-8 mx-auto mb-3"
|
|
||||||
style={{ color: "var(--text-4)" }}
|
|
||||||
strokeWidth={1}
|
|
||||||
/>
|
|
||||||
<Muted>No workspaces found.</Muted>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4" aria-hidden="true">
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
|
||||||
<div key={index} className="rounded-xl p-4 theme-card">
|
|
||||||
<Skeleton accent={index === 0} className="h-8 w-16 rounded-lg" />
|
|
||||||
<Skeleton className="mt-3 h-3 w-20 rounded" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl theme-card overflow-hidden">
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-4 py-3"
|
className="flex items-center gap-2 px-4 py-3"
|
||||||
style={{ borderBottom: "1px solid var(--border)" }}
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
<Skeleton accent className="h-4 w-4 rounded" />
|
<Network className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
<Skeleton className="h-4 w-28 rounded" />
|
<SectionHeading className="mb-0">Workspaces</SectionHeading>
|
||||||
<Skeleton className="ml-1 h-3 w-32 rounded" />
|
{instances.length > 1 && (
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-xs">
|
||||||
|
<span style={{ color: "var(--text-4)" }}>Server</span>
|
||||||
|
<select
|
||||||
|
aria-label="Filter by server"
|
||||||
|
value={serverFilter}
|
||||||
|
onChange={(e) => setServerFilter(e.target.value)}
|
||||||
|
className="rounded-md px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-3)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={ALL_SERVERS}>All servers</option>
|
||||||
|
{instances.map((i) => (
|
||||||
|
<option key={i.id} value={i.id}>
|
||||||
|
{i.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: "var(--bg-3)" }}>
|
<tr style={{ background: "var(--bg-3)" }}>
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
<th className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
|
||||||
<th key={index} className="px-4 py-2 text-left">
|
Workspace (server)
|
||||||
<Skeleton className="h-3 w-14 rounded" />
|
</th>
|
||||||
</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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Array.from({ length: 5 }).map((_, rowIndex) => (
|
{shownInstances.map((inst) => (
|
||||||
<tr key={rowIndex} style={{ borderTop: "1px solid var(--border)" }}>
|
<ServerWorkspaceRows
|
||||||
<td className="px-4 py-3">
|
key={inst.id}
|
||||||
<Skeleton accent className="h-3 w-28 rounded" />
|
instance={inst}
|
||||||
</td>
|
onOpenWorkspace={onOpenWorkspace}
|
||||||
<td className="px-4 py-3">
|
onMetrics={onMetrics}
|
||||||
<div className="flex justify-end">
|
/>
|
||||||
<Skeleton className="h-3 w-20 rounded" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{Array.from({ length: 4 }).map((__, cellIndex) => (
|
|
||||||
<td key={cellIndex} className="px-4 py-3">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Skeleton className="h-3 w-8 rounded" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
191
packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
Normal file
191
packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ChevronRight, CircleDot } from "lucide-react";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
scopedConclusionsCountOptions,
|
||||||
|
scopedQueueStatusOptions,
|
||||||
|
useScopedWorkspaces,
|
||||||
|
} from "@/api/compareQueries";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import type { FleetRowMetrics } from "@/components/fleet/fleetAggregates";
|
||||||
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { formatCount } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Workspace = components["schemas"]["Workspace"];
|
||||||
|
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||||
|
type ConclusionPage = components["schemas"]["Page_Conclusion_"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instance: Instance;
|
||||||
|
/** Open a workspace's drill-down (activates the instance first if needed). */
|
||||||
|
onOpenWorkspace: (instance: Instance, workspaceId: string) => void;
|
||||||
|
/** Report this server's summed metrics up for the aggregate header. */
|
||||||
|
onMetrics: (id: string, metrics: FleetRowMetrics) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one `<tr>` per workspace on a single server (instance), labelled
|
||||||
|
* `<workspace> (<server>)`, and reports the server's summed metrics up so the
|
||||||
|
* Dashboard header can aggregate across servers. Per-instance data fetching lives
|
||||||
|
* here (not in a parent loop) to satisfy the rules of hooks — one child per server.
|
||||||
|
*/
|
||||||
|
export function ServerWorkspaceRows({ instance, onOpenWorkspace, onMetrics }: Props) {
|
||||||
|
const { mask } = useDemo();
|
||||||
|
const workspacesQ = useScopedWorkspaces(instance, 1, WORKSPACE_PAGE_SIZE);
|
||||||
|
|
||||||
|
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";
|
||||||
|
// Dep array uses primitives only — an object dep (e.g. the metrics shape) would
|
||||||
|
// create a new reference on each render even when values are unchanged, causing
|
||||||
|
// onMetrics → setMetricsById → re-render → new object → onMetrics … loop.
|
||||||
|
useEffect(() => {
|
||||||
|
onMetrics(instance.id, {
|
||||||
|
workspaceCount: totalWorkspaces,
|
||||||
|
conclusionCount,
|
||||||
|
queueActive,
|
||||||
|
queuePending,
|
||||||
|
lastSeen: null,
|
||||||
|
health,
|
||||||
|
});
|
||||||
|
}, [instance.id, totalWorkspaces, conclusionCount, queueActive, queuePending, health, onMetrics]);
|
||||||
|
|
||||||
|
if (workspacesQ.isError) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
style={{ borderTop: "1px solid var(--border)" }}
|
||||||
|
data-testid={`server-error-${instance.id}`}
|
||||||
|
>
|
||||||
|
<td className="py-2.5 px-4" colSpan={3}>
|
||||||
|
<span className="text-xs" style={{ color: COLOR.destructive }}>
|
||||||
|
{instance.name} — unreachable
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaces.length === 0) {
|
||||||
|
return (
|
||||||
|
<tr style={{ borderTop: "1px solid var(--border)" }}>
|
||||||
|
<td className="py-2.5 px-4" colSpan={3}>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||||
|
{instance.name} — {workspacesQ.isLoading ? "loading…" : "no workspaces"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{workspaces.map((ws, i) => {
|
||||||
|
const queue = queueResults[i]?.data as QueueStatus | undefined;
|
||||||
|
const active = queue?.in_progress_work_units ?? 0;
|
||||||
|
const pending = queue?.pending_work_units ?? 0;
|
||||||
|
const isActive = active > 0 || pending > 0;
|
||||||
|
const conclusions = (conclusionsResults[i]?.data as ConclusionPage | undefined)?.total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${instance.id}:${ws.id}`}
|
||||||
|
data-testid={`ws-row-${instance.id}-${ws.id}`}
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
background: isActive ? COLOR.warningDim : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="py-2 px-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenWorkspace(instance, ws.id)}
|
||||||
|
className="flex items-center gap-2 group text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
|
>
|
||||||
|
{mask(ws.id)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||||
|
({instance.name})
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td
|
||||||
|
className="py-2 px-4 text-right font-mono text-xs"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
{conclusions === undefined ? "—" : formatCount(conclusions)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="py-2 px-4 text-right">
|
||||||
|
<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: COLOR.success }} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium font-mono"
|
||||||
|
style={{ color: isActive ? COLOR.warning : "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{isActive ? `${formatCount(active)}/${formatCount(pending)}` : "idle"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Moon,
|
Moon,
|
||||||
MoonStar,
|
MoonStar,
|
||||||
Network,
|
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
@@ -32,7 +31,6 @@ 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 },
|
||||||
@@ -62,7 +60,9 @@ function useLastDataUpdate(): string {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function refresh() {
|
function refresh() {
|
||||||
setNow(Date.now());
|
// No setNow here — calling setNow on every cache event causes a render loop on
|
||||||
|
// CI (each Date.now() call crosses a ms boundary → new value → React re-renders
|
||||||
|
// Sidebar → cache events fire again → loop). setNow belongs only in the interval.
|
||||||
const latest = queryClient
|
const latest = queryClient
|
||||||
.getQueryCache()
|
.getQueryCache()
|
||||||
.getAll()
|
.getAll()
|
||||||
@@ -72,7 +72,10 @@ function useLastDataUpdate(): string {
|
|||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
const unsubscribe = queryClient.getQueryCache().subscribe(refresh);
|
const unsubscribe = queryClient.getQueryCache().subscribe(refresh);
|
||||||
const interval = window.setInterval(refresh, 30_000);
|
const interval = window.setInterval(() => {
|
||||||
|
setNow(Date.now()); // refresh relative-time display ("X ago") every 30s
|
||||||
|
refresh();
|
||||||
|
}, 30_000);
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { FleetDashboard } from "@/components/fleet/FleetDashboard";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/fleet")({
|
export const Route = createFileRoute("/fleet")({
|
||||||
component: FleetDashboard,
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/" });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
106
packages/web/src/test/dashboard.test.tsx
Normal file
106
packages/web/src/test/dashboard.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
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";
|
||||||
|
|
||||||
|
// One mocked transport for every scoped call; branch by URL so the per-workspace
|
||||||
|
// fan-out (workspaces list → queue status + conclusions count) all resolve.
|
||||||
|
vi.mock("@/lib/http", () => ({
|
||||||
|
httpFetch: vi.fn(async (input: Request | string) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const json = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (url.includes("/queue/status")) {
|
||||||
|
return json({
|
||||||
|
in_progress_work_units: 0,
|
||||||
|
pending_work_units: 0,
|
||||||
|
completed_work_units: 0,
|
||||||
|
total_work_units: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/conclusions/list")) {
|
||||||
|
return json({ items: [], total: 5, page: 1, size: 1, pages: 1 });
|
||||||
|
}
|
||||||
|
return json({ items: [{ id: "ws-1" }], total: 1, page: 1, size: 100, pages: 1 });
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const neo: Instance = { id: "neo", name: "Neo", baseUrl: "https://neo.example.net", token: "" };
|
||||||
|
const iris: Instance = { id: "iris", name: "Iris", baseUrl: "https://iris.example.net", token: "" };
|
||||||
|
|
||||||
|
function renderDashboard() {
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||||
|
});
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DemoProvider>
|
||||||
|
<MetadataProvider>
|
||||||
|
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
|
||||||
|
<RouterProvider router={router as any} />
|
||||||
|
</MetadataProvider>
|
||||||
|
</DemoProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard — unified server-aware view", () => {
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("does not loop when Date.now advances on each call (CI render-loop repro)", async () => {
|
||||||
|
// On CI, consecutive Date.now() calls cross millisecond boundaries, so setNow(Date.now())
|
||||||
|
// in the cache-event subscriber always produces a new value → React keeps re-rendering
|
||||||
|
// Sidebar → hits the 25-cycle "Maximum update depth exceeded" limit.
|
||||||
|
// This test forces that CI condition locally to catch regressions.
|
||||||
|
let t = 1_000_000;
|
||||||
|
const spy = vi.spyOn(Date, "now").mockImplementation(() => t++);
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderDashboard();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("(Neo)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(Iris)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists each server's workspaces labelled with the server name", async () => {
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderDashboard();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("(Neo)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(Iris)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a server filter listing every server", async () => {
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderDashboard();
|
||||||
|
const select = await screen.findByLabelText("Filter by server");
|
||||||
|
expect(within(select).getByRole("option", { name: "Neo" })).toBeInTheDocument();
|
||||||
|
expect(within(select).getByRole("option", { name: "Iris" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrows to a single server when filtered", async () => {
|
||||||
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
|
renderDashboard();
|
||||||
|
await screen.findByText("(Iris)");
|
||||||
|
const select = await screen.findByLabelText("Filter by server");
|
||||||
|
fireEvent.change(select, { target: { value: "iris" } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("(Neo)")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(Iris)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
|
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { scopedConclusionsCountOptions, scopedQueueStatusOptions } from "@/api/compareQueries";
|
import { scopedConclusionsCountOptions, scopedQueueStatusOptions } from "@/api/compareQueries";
|
||||||
import {
|
import {
|
||||||
@@ -157,21 +157,18 @@ describe("Fleet route", () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mounts FleetDashboard at /fleet when an instance is configured", async () => {
|
it("redirects /fleet to the Dashboard", async () => {
|
||||||
saveStore({ instances: [neo], activeId: "neo" });
|
saveStore({ instances: [neo], activeId: "neo" });
|
||||||
renderRouteAt("/fleet");
|
renderRouteAt("/fleet");
|
||||||
expect(await screen.findByRole("heading", { name: /Fleet/i })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders one table row per configured instance", async () => {
|
it("shows each instance after /fleet redirect", async () => {
|
||||||
saveStore({ instances: [neo, iris], activeId: "neo" });
|
saveStore({ instances: [neo, iris], activeId: "neo" });
|
||||||
renderRouteAt("/fleet");
|
renderRouteAt("/fleet");
|
||||||
const table = await screen.findByRole("table");
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(within(table).getByText("Neo")).toBeInTheDocument();
|
expect(screen.getByText("Neo — no workspaces")).toBeInTheDocument();
|
||||||
expect(within(table).getByText("Iris")).toBeInTheDocument();
|
expect(screen.getByText("Iris — no workspaces")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
// 1 header + 2 instance rows
|
|
||||||
expect(within(table).getAllByRole("row")).toHaveLength(3);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
95
packages/web/src/test/server-workspace-rows.test.tsx
Normal file
95
packages/web/src/test/server-workspace-rows.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act, render, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ServerWorkspaceRows } from "@/components/dashboard/ServerWorkspaceRows";
|
||||||
|
import type { FleetRowMetrics } from "@/components/fleet/fleetAggregates";
|
||||||
|
import { DemoProvider } from "@/context/DemoContext";
|
||||||
|
import type { Instance } from "@/lib/config";
|
||||||
|
|
||||||
|
vi.mock("@/lib/http", () => ({
|
||||||
|
httpFetch: vi.fn(async (input: Request | string) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const json = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (url.includes("/queue/status")) {
|
||||||
|
return json({ in_progress_work_units: 0, pending_work_units: 0 });
|
||||||
|
}
|
||||||
|
if (url.includes("/conclusions/list")) {
|
||||||
|
return json({ items: [], total: 3, page: 1, size: 1, pages: 1 });
|
||||||
|
}
|
||||||
|
return json({ items: [{ id: "ws-1" }], total: 1, page: 1, size: 100, pages: 1 });
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const neo: Instance = { id: "neo", name: "Neo", baseUrl: "https://neo.example.net", token: "" };
|
||||||
|
|
||||||
|
function makeQc() {
|
||||||
|
return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(instance: Instance, onMetrics: (id: string, metrics: FleetRowMetrics) => void) {
|
||||||
|
const qc = makeQc();
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DemoProvider>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<ServerWorkspaceRows
|
||||||
|
instance={instance}
|
||||||
|
onOpenWorkspace={vi.fn()}
|
||||||
|
onMetrics={onMetrics}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</DemoProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ServerWorkspaceRows — onMetrics stability", () => {
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("calls onMetrics with health:ok after data loads", async () => {
|
||||||
|
const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
|
||||||
|
renderRows(neo, onMetrics);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onMetrics).toHaveBeenCalledWith(
|
||||||
|
"neo",
|
||||||
|
expect.objectContaining({ health: "ok", workspaceCount: 1, conclusionCount: 3 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call onMetrics again when values have not changed", async () => {
|
||||||
|
const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
|
||||||
|
renderRows(neo, onMetrics);
|
||||||
|
|
||||||
|
// Wait until we have at least one call with stable state
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onMetrics).toHaveBeenCalledWith("neo", expect.objectContaining({ health: "ok" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const callsBefore = onMetrics.mock.calls.length;
|
||||||
|
|
||||||
|
// Flush any pending micro-tasks / React batched updates
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
// onMetrics must not have been called again — no render loop
|
||||||
|
expect(onMetrics).toHaveBeenCalledTimes(callsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onMetrics when health transitions from loading to ok", async () => {
|
||||||
|
const onMetrics = vi.fn<(id: string, m: FleetRowMetrics) => void>();
|
||||||
|
renderRows(neo, onMetrics);
|
||||||
|
|
||||||
|
// Must eventually report ok (not just loading)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onMetrics).toHaveBeenCalledWith("neo", expect.objectContaining({ health: "ok" })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user