2026-06-02 16:41:51 -05:00
|
|
|
import { useQueries } from "@tanstack/react-query";
|
|
|
|
|
import { motion } from "framer-motion";
|
|
|
|
|
import { ChevronRight, CircleDot } from "lucide-react";
|
2026-06-03 17:41:57 -05:00
|
|
|
import { useEffect, useMemo } from "react";
|
2026-06-02 16:41:51 -05:00
|
|
|
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";
|
2026-06-03 17:41:57 -05:00
|
|
|
// 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, {
|
2026-06-02 16:41:51 -05:00
|
|
|
workspaceCount: totalWorkspaces,
|
|
|
|
|
conclusionCount,
|
|
|
|
|
queueActive,
|
|
|
|
|
queuePending,
|
2026-06-03 17:27:42 -05:00
|
|
|
lastSeen: null,
|
2026-06-02 16:41:51 -05:00
|
|
|
health,
|
2026-06-03 17:41:57 -05:00
|
|
|
});
|
|
|
|
|
}, [instance.id, totalWorkspaces, conclusionCount, queueActive, queuePending, health, onMetrics]);
|
2026-06-02 16:41:51 -05:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|