2026-06-02 16:41:51 -05:00
|
|
|
import { Link, useNavigate } from "@tanstack/react-router";
|
2026-04-27 11:20:47 -05:00
|
|
|
import { motion } from "framer-motion";
|
2026-06-02 16:41:51 -05:00
|
|
|
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";
|
2026-04-24 13:47:22 -05:00
|
|
|
import { COLOR } from "@/lib/constants";
|
|
|
|
|
import { formatCount } from "@/lib/utils";
|
2026-06-02 16:41:51 -05:00
|
|
|
import { ServerWorkspaceRows } from "./ServerWorkspaceRows";
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-06-02 16:41:51 -05:00
|
|
|
const ALL_SERVERS = "all";
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-06-02 16:41:51 -05:00
|
|
|
/**
|
|
|
|
|
* 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 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],
|
2026-04-24 13:47:22 -05:00
|
|
|
);
|
|
|
|
|
|
2026-06-02 16:41:51 -05:00
|
|
|
const shownInstances = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
serverFilter === ALL_SERVERS ? instances : instances.filter((i) => i.id === serverFilter),
|
|
|
|
|
[instances, serverFilter],
|
2026-04-24 21:30:48 -05:00
|
|
|
);
|
|
|
|
|
|
2026-06-02 16:41:51 -05:00
|
|
|
const agg = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
computeFleetAggregates(shownInstances.map((i) => metricsById[i.id] ?? DEFAULT_ROW_METRICS)),
|
|
|
|
|
[shownInstances, metricsById],
|
|
|
|
|
);
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-06-02 16:41:51 -05:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-24 21:30:48 -05:00
|
|
|
|
|
|
|
|
return (
|
2026-04-24 16:52:40 -05:00
|
|
|
<div className="page-container page-container--xl">
|
|
|
|
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
2026-04-24 21:30:48 -05:00
|
|
|
<div className="flex items-center gap-2 mb-1">
|
2026-04-24 13:47:22 -05:00
|
|
|
<LayoutDashboard
|
|
|
|
|
className="w-5 h-5"
|
|
|
|
|
style={{ color: "var(--accent)" }}
|
|
|
|
|
strokeWidth={1.5}
|
|
|
|
|
/>
|
2026-04-24 16:52:40 -05:00
|
|
|
<PageTitle>Dashboard</PageTitle>
|
2026-06-02 16:41:51 -05:00
|
|
|
<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}`,
|
|
|
|
|
}}
|
2026-04-24 13:47:22 -05:00
|
|
|
>
|
2026-06-02 16:41:51 -05:00
|
|
|
{agg.totalInstances} server{agg.totalInstances !== 1 ? "s" : ""}
|
|
|
|
|
</span>
|
2026-04-24 21:30:48 -05:00
|
|
|
</div>
|
2026-06-02 16:41:51 -05:00
|
|
|
<Body className="leading-none">Workspaces across every configured server</Body>
|
|
|
|
|
</motion.div>
|
2026-05-12 10:05:00 -05:00
|
|
|
|
2026-06-02 16:41:51 -05:00
|
|
|
<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>
|
2026-05-12 10:05:00 -05:00
|
|
|
|
2026-06-02 16:41:51 -05:00
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 8 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.12 }}
|
|
|
|
|
className="rounded-xl theme-card overflow-hidden"
|
|
|
|
|
>
|
2026-05-12 10:05:00 -05:00
|
|
|
<div
|
|
|
|
|
className="flex items-center gap-2 px-4 py-3"
|
|
|
|
|
style={{ borderBottom: "1px solid var(--border)" }}
|
|
|
|
|
>
|
2026-06-02 16:41:51 -05:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-05-12 10:05:00 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr style={{ background: "var(--bg-3)" }}>
|
2026-06-02 16:41:51 -05:00
|
|
|
<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>
|
2026-05-12 10:05:00 -05:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
2026-06-02 16:41:51 -05:00
|
|
|
{shownInstances.map((inst) => (
|
|
|
|
|
<ServerWorkspaceRows
|
|
|
|
|
key={inst.id}
|
|
|
|
|
instance={inst}
|
|
|
|
|
onOpenWorkspace={onOpenWorkspace}
|
|
|
|
|
onMetrics={onMetrics}
|
|
|
|
|
/>
|
2026-05-12 10:05:00 -05:00
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
2026-06-02 16:41:51 -05:00
|
|
|
</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}
|
2026-05-12 10:05:00 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|