feat(web): merge Fleet into a server-filterable Dashboard
Implements Phase 1 of the UI navigation rework (docs/superpowers/specs/ 2026-06-02-ui-navigation-rework.md): the Dashboard now lists every workspace across every configured server as <workspace> (<server>), filterable by server, with cross-server aggregate cards (reusing computeFleetAggregates). Opening a workspace activates its server then drills into the existing detail route. Per-server fan-out lives in a ServerWorkspaceRows child (rules-of-hooks safe, mirrors FleetRow). Fleet removed from the sidebar nav; the /fleet route is left intact for now (full removal deferred to avoid churning fleet.test.tsx while #54 is open).
This commit is contained in:
@@ -1,159 +1,83 @@
|
||||
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, useMemo, 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"];
|
||||
|
||||
// ─── 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 ───────────────────────────────────────────────────────────
|
||||
const ALL_SERVERS = "all";
|
||||
|
||||
/**
|
||||
* 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 [page] = useState(1);
|
||||
const { data, isLoading, error } = useWorkspaces(page, 50);
|
||||
const { instances, activeId, activate } = useInstances();
|
||||
const navigate = useNavigate();
|
||||
const [serverFilter, setServerFilter] = useState<string>(ALL_SERVERS);
|
||||
const [metricsById, setMetricsById] = useState<Record<string, FleetRowMetrics>>({});
|
||||
|
||||
const workspaces =
|
||||
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
const onMetrics = useCallback((id: string, m: FleetRowMetrics) => {
|
||||
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 (
|
||||
<div className="page-container page-container--xl">
|
||||
@@ -165,163 +89,136 @@ 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={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{total} workspace{total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{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 />}
|
||||
<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"
|
||||
>
|
||||
<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 && (
|
||||
<div className="space-y-4">
|
||||
{/* Aggregate stat row */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
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">
|
||||
<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)" }}
|
||||
>
|
||||
<Skeleton accent className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
<Skeleton className="ml-1 h-3 w-32 rounded" />
|
||||
<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)" }}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<th key={index} className="px-4 py-2 text-left">
|
||||
<Skeleton className="h-3 w-14 rounded" />
|
||||
</th>
|
||||
))}
|
||||
<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>
|
||||
{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>
|
||||
{shownInstances.map((inst) => (
|
||||
<ServerWorkspaceRows
|
||||
key={inst.id}
|
||||
instance={inst}
|
||||
onOpenWorkspace={onOpenWorkspace}
|
||||
onMetrics={onMetrics}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
|
||||
209
packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
Normal file
209
packages/web/src/components/dashboard/ServerWorkspaceRows.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronRight, CircleDot } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } 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;
|
||||
|
||||
function metricsEqual(a: FleetRowMetrics, b: FleetRowMetrics): boolean {
|
||||
return (
|
||||
a.workspaceCount === b.workspaceCount &&
|
||||
a.conclusionCount === b.conclusionCount &&
|
||||
a.queueActive === b.queueActive &&
|
||||
a.queuePending === b.queuePending &&
|
||||
a.lastSeen === b.lastSeen &&
|
||||
a.health === b.health
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
const lastSeen = workspacesQ.dataUpdatedAt > 0 ? workspacesQ.dataUpdatedAt : null;
|
||||
|
||||
const metrics: FleetRowMetrics = useMemo(
|
||||
() => ({
|
||||
workspaceCount: totalWorkspaces,
|
||||
conclusionCount,
|
||||
queueActive,
|
||||
queuePending,
|
||||
lastSeen,
|
||||
health,
|
||||
}),
|
||||
[totalWorkspaces, conclusionCount, queueActive, queuePending, lastSeen, health],
|
||||
);
|
||||
|
||||
const lastReported = useRef<FleetRowMetrics | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastReported.current && metricsEqual(lastReported.current, metrics)) return;
|
||||
lastReported.current = metrics;
|
||||
onMetrics(instance.id, metrics);
|
||||
}, [instance.id, metrics, 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 },
|
||||
|
||||
89
packages/web/src/test/dashboard.test.tsx
Normal file
89
packages/web/src/test/dashboard.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
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 } } });
|
||||
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("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" });
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
await screen.findByText("(Iris)");
|
||||
await user.selectOptions(await screen.findByLabelText("Filter by server"), "iris");
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("(Neo)")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("(Iris)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user