diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index e5138c6..35307e9 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -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 diff --git a/packages/web/src/components/dashboard/Dashboard.tsx b/packages/web/src/components/dashboard/Dashboard.tsx index dfafe7d..74f3a0d 100644 --- a/packages/web/src/components/dashboard/Dashboard.tsx +++ b/packages/web/src/components/dashboard/Dashboard.tsx @@ -1,159 +1,101 @@ -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 ───────────────────────────────────────────────── - -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 ( - - - - - {mask(workspaceId)} - - - - - - - {isLoading ? ( - - … - - ) : ( -
- {isActive ? ( - - - - ) : ( - - )} - - {isActive ? `${formatCount(pending + active)} pending` : "Idle"} - -
- )} - - - {( - [ - { 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 }) => ( - - {isLoading ? "—" : formatCount(val)} - - ))} - +/** + * Unified, server-aware dashboard: every workspace across every configured server, + * labelled ` ()` 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(ALL_SERVERS); + const [metricsById, setMetricsById] = useState>({}); + const lastMetrics = useRef>({}); + + useEffect(() => { + if (serverFilter !== ALL_SERVERS && !instances.find((i) => i.id === serverFilter)) { + setServerFilter(ALL_SERVERS); + } + }, [instances, serverFilter]); + + 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], ); -} -// ─── 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 ( -
- {( - [ - { 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 }) => ( -
-
- {allLoaded || always ? formatCount(value) : "—"} -
-
- {label} -
-
- ))} -
+ const shownInstances = useMemo( + () => + serverFilter === ALL_SERVERS ? instances : instances.filter((i) => i.id === serverFilter), + [instances, serverFilter], ); -} -// ─── Main dashboard ─────────────────────────────────────────────────────────── - -export function Dashboard() { - const [page] = useState(1); - const { data, isLoading, error } = useWorkspaces(page, 50); + const agg = useMemo( + () => + computeFleetAggregates(shownInstances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS)), + [shownInstances, metricsById], + ); - const workspaces = - (data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? []; - const total = (data as { total?: number } | undefined)?.total ?? 0; + if (instances.length === 0) { + return ( +
+ + + Go to Settings + + } + /> +
+ ); + } return (
@@ -165,163 +107,136 @@ export function Dashboard() { strokeWidth={1.5} /> Dashboard - {total > 0 && ( - - {total} workspace{total !== 1 ? "s" : ""} - - )} -
- Overview of your Honcho instance - - - - {isLoading && } - - {!isLoading && workspaces.length > 0 && ( -
- {/* Aggregate stat row */} - - - - - {/* Per-workspace queue table */} - -
- - Queue Status - - all workspaces · live polling - -
- -
- - - - {["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => ( - - ))} - - - - {workspaces.map((ws) => ( - - ))} - -
- {h} -
-
-
- - {total > workspaces.length && ( -

- Showing {workspaces.length} of {total} workspaces.{" "} - - View all → - -

- )} -
- )} - - {!isLoading && workspaces.length === 0 && ( -
- - No workspaces found. + {agg.totalInstances} server{agg.totalInstances !== 1 ? "s" : ""} +
- )} - - ); -} + Workspaces across every configured server + -function DashboardSkeleton() { - return ( -