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:
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -1,160 +1,102 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { Skeleton } from "@/components/shared/Skeleton";
|
||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { Boxes, LayoutDashboard, Network, Settings as SettingsIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
computeFleetAggregates,
|
||||
DEFAULT_ROW_METRICS,
|
||||
type FleetRowMetrics,
|
||||
} from "@/components/fleet/fleetAggregates";
|
||||
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 { formatCount } from "@/lib/utils";
|
||||
import { ServerWorkspaceRows } from "./ServerWorkspaceRows";
|
||||
|
||||
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||
const ALL_SERVERS = "all";
|
||||
|
||||
// ─── Per-workspace queue row ─────────────────────────────────────────────────
|
||||
/**
|
||||
* 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() {
|
||||
const { instances, activeId, activate } = useInstances();
|
||||
const navigate = useNavigate();
|
||||
const [serverFilter, setServerFilter] = useState<string>(ALL_SERVERS);
|
||||
const [metricsById, setMetricsById] = useState<Record<string, FleetRowMetrics>>({});
|
||||
const lastMetrics = useRef<Record<string, FleetRowMetrics>>({});
|
||||
|
||||
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
|
||||
const { mask } = useDemo();
|
||||
const { data, isLoading } = useQueueStatus(workspaceId);
|
||||
useEffect(() => {
|
||||
if (serverFilter !== ALL_SERVERS && !instances.find((i) => i.id === serverFilter)) {
|
||||
setServerFilter(ALL_SERVERS);
|
||||
}
|
||||
}, [instances, serverFilter]);
|
||||
|
||||
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;
|
||||
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 (
|
||||
<tr
|
||||
<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={{
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: isActive ? COLOR.warningDim : undefined,
|
||||
background: "var(--accent-dim)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
color: "var(--accent-text)",
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
<SettingsIcon className="w-4 h-4" strokeWidth={1.5} />
|
||||
Go to Settings
|
||||
</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 ───────────────────────────────────────────────────────────
|
||||
|
||||
export function Dashboard() {
|
||||
const [page] = useState(1);
|
||||
const { data, isLoading, error } = useWorkspaces(page, 50);
|
||||
|
||||
const workspaces =
|
||||
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="page-container page-container--xl">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||
@@ -165,7 +107,6 @@ export function Dashboard() {
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
@@ -174,28 +115,33 @@ export function Dashboard() {
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{total} workspace{total !== 1 ? "s" : ""}
|
||||
{agg.totalInstances} server{agg.totalInstances !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Body className="leading-none">Overview of your Honcho instance</Body>
|
||||
<Body className="leading-none">Workspaces across every configured server</Body>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <DashboardSkeleton />}
|
||||
|
||||
{!isLoading && workspaces.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* Aggregate stat row */}
|
||||
<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"
|
||||
>
|
||||
<GlobalQueueBanner workspaces={workspaces} />
|
||||
<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>
|
||||
|
||||
{/* Per-workspace queue table */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -206,122 +152,91 @@ export function Dashboard() {
|
||||
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>
|
||||
<Network className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<SectionHeading className="mb-0">Workspaces</SectionHeading>
|
||||
{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 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 className="py-2 px-4 font-medium text-left" style={{ color: "var(--text-3)" }}>
|
||||
Workspace (server)
|
||||
</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workspaces.map((ws) => (
|
||||
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
|
||||
{shownInstances.map((inst) => (
|
||||
<ServerWorkspaceRows
|
||||
key={inst.id}
|
||||
instance={inst}
|
||||
onOpenWorkspace={onOpenWorkspace}
|
||||
onMetrics={onMetrics}
|
||||
/>
|
||||
))}
|
||||
</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() {
|
||||
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="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
|
||||
className="flex items-center gap-2 px-4 py-3"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<Skeleton accent className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
<Skeleton className="ml-1 h-3 w-32 rounded" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-3)" }}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<th key={index} className="px-4 py-2 text-left">
|
||||
<Skeleton className="h-3 w-14 rounded" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 5 }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="px-4 py-3">
|
||||
<Skeleton accent className="h-3 w-28 rounded" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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>
|
||||
</table>
|
||||
<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>
|
||||
);
|
||||
|
||||
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,
|
||||
Moon,
|
||||
MoonStar,
|
||||
Network,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
@@ -32,7 +31,6 @@ import { COLOR } from "@/lib/constants";
|
||||
|
||||
const TOP_NAV = [
|
||||
{ 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: "/seed-kits" as const, label: "Seed Kits", icon: Layers, exact: false },
|
||||
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
||||
@@ -62,7 +60,9 @@ function useLastDataUpdate(): string {
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
@@ -72,7 +72,10 @@ function useLastDataUpdate(): string {
|
||||
|
||||
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 () => {
|
||||
unsubscribe();
|
||||
window.clearInterval(interval);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { FleetDashboard } from "@/components/fleet/FleetDashboard";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
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 { 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 { scopedConclusionsCountOptions, scopedQueueStatusOptions } from "@/api/compareQueries";
|
||||
import {
|
||||
@@ -157,21 +157,18 @@ describe("Fleet route", () => {
|
||||
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" });
|
||||
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" });
|
||||
renderRouteAt("/fleet");
|
||||
const table = await screen.findByRole("table");
|
||||
await waitFor(() => {
|
||||
expect(within(table).getByText("Neo")).toBeInTheDocument();
|
||||
expect(within(table).getByText("Iris")).toBeInTheDocument();
|
||||
});
|
||||
// 1 header + 2 instance rows
|
||||
expect(within(table).getAllByRole("row")).toHaveLength(3);
|
||||
expect(screen.getByText("Neo — no workspaces")).toBeInTheDocument();
|
||||
expect(screen.getByText("Iris — no workspaces")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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