Using a metrics object as a useEffect dep causes the loop: onMetrics → setMetricsById → Dashboard re-renders → ServerWorkspaceRows re-renders → useQueries runs → TanStack Query cache subscriber fires (Sidebar's setNow) → query result objects are new references → metrics useMemo returns new object → effect dep changed → onMetrics again → ∞ Fix: depend on the five primitive values (workspaceCount, conclusionCount, queueActive, queuePending, health) directly. React compares primitives by value, so the effect only fires when actual data changes, not on reference churn from re-renders. Also adds server-workspace-rows.test.tsx with three focused unit tests: correct health:ok report, stability after load (no re-fire), and health transition coverage.
192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|