Files
openconcho/packages/web/src/components/dashboard/ServerWorkspaceRows.tsx

192 lines
6.2 KiB
TypeScript
Raw Normal View History

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>
);
})}
</>
);
}