import { useMemo, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@heroui/react"; import { useI18n } from "../i18n"; import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon"; import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules"; import { openOrFocusModule } from "../utils/moduleWindows"; import { useAuth } from "../auth/AuthContext"; import { fetchFabricSummary, fetchManagedNeStats, fetchOpsTasks, fetchRuntimeMetrics, fetchTopologyTree, type OpsTaskItem } from "../services/api"; import { formatSystemTime } from "../utils/time"; import { isUmeWorldContainer, isWorldDrillFolder } from "./topology/treeUtils"; import type { TopologyTreeFolderItem } from "../types"; const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"]; type DisplaySection = { key: "operations" | "system"; mods: ModuleDefinition[]; }; function formatCount(n: number | string): string { if (typeof n === "string") return n; if (!Number.isFinite(n)) return "—"; if (Math.abs(n) >= 10000) { return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); } return String(n); } function formatAgeSec(sec: number | null | undefined): string { if (sec == null || !Number.isFinite(sec)) return "—"; const n = Math.max(0, sec); if (n < 60) return `${Math.round(n)}s`; if (n < 3600) return `${Math.max(1, Math.round(n / 60))}m`; return `${(n / 3600).toFixed(n >= 10 ? 0 : 1)}h`; } function formatCompactTime(value: string | null | undefined): string { if (!value) return "—"; const full = formatSystemTime(value); if (!full) return "—"; // Keep date+time but drop seconds when present: "2026/9/12 15:42:01" → "9/12 15:42" const m = full.match(/(\d{1,4}[/-]\d{1,2}[/-]\d{1,2})\s+(\d{1,2}:\d{2})(?::\d{2})?/); if (m) { const date = m[1].replace(/^\d{4}[/-]/, ""); return `${date} ${m[2]}`; } return full; } const FLEET_TONE_COLOR: Record<"ok" | "mid" | "warn" | "bad" | "muted", string> = { ok: "#22c55e", mid: "#38bdf8", warn: "#f59e0b", bad: "#ef4444", muted: "#64748b", }; const REGION_PALETTE = [ "#38bdf8", "#22d3ee", "#818cf8", "#34d399", "#fbbf24", "#fb7185", "#a78bfa", "#2dd4bf", ]; type FleetSlice = { key: string; n: number; label?: string; tone?: "ok" | "mid" | "warn" | "bad" | "muted"; color?: string; }; function sliceColor(p: FleetSlice): string { if (p.color) return p.color; if (p.tone) return FLEET_TONE_COLOR[p.tone]; return FLEET_TONE_COLOR.muted; } function buildDonutGradient(parts: FleetSlice[]): string { const raw = parts.map((p) => Math.max(0, p.n)); const total = raw.reduce((s, n) => s + n, 0); if (total <= 0) { return `conic-gradient(${FLEET_TONE_COLOR.muted} 0deg 360deg)`; } // Tiny non-zero slices get a minimum arc so they stay glanceable on skewed fleets. const MIN_SHARE = 0.022; const shares = raw.map((n) => { if (n <= 0) return 0; return Math.max(n / total, MIN_SHARE); }); const shareSum = shares.reduce((s, n) => s + n, 0); let cursor = 0; const stops: string[] = []; parts.forEach((p, i) => { const share = shares[i] / shareSum; if (share <= 0) return; const start = cursor * 360; cursor += share; const end = cursor * 360; stops.push(`${sliceColor(p)} ${start.toFixed(2)}deg ${end.toFixed(2)}deg`); }); return `conic-gradient(${stops.join(", ")})`; } function FleetDonut({ parts, center, unit, }: { parts: FleetSlice[]; center: ReactNode; unit?: ReactNode; }) { return ( ); } const SCHED_DISC_COLORS = ["#38bdf8", "#22d3ee", "#818cf8", "#34d399"]; function polarPoint(cx: number, cy: number, r: number, deg: number): [number, number] { const rad = ((deg - 90) * Math.PI) / 180; return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; } function donutSlicePath( cx: number, cy: number, rOuter: number, rInner: number, startDeg: number, endDeg: number, ): string { const sweep = endDeg - startDeg; const large = sweep > 180 ? 1 : 0; const [x1, y1] = polarPoint(cx, cy, rOuter, startDeg); const [x2, y2] = polarPoint(cx, cy, rOuter, endDeg); const [x3, y3] = polarPoint(cx, cy, rInner, endDeg); const [x4, y4] = polarPoint(cx, cy, rInner, startDeg); return [ `M ${x1.toFixed(2)} ${y1.toFixed(2)}`, `A ${rOuter} ${rOuter} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`, `L ${x3.toFixed(2)} ${y3.toFixed(2)}`, `A ${rInner} ${rInner} 0 ${large} 0 ${x4.toFixed(2)} ${y4.toFixed(2)}`, "Z", ].join(" "); } type SchedDiscItem = { key: string; label: string; on: boolean; age: string; moduleId: string; path: string; }; function SchedDartDisc({ items, center, unit, onSelect, }: { items: SchedDiscItem[]; center: ReactNode; unit?: ReactNode; onSelect: (item: SchedDiscItem) => void; }) { // Match FleetDonut footprint (84px) so the three FLEET cards share one visual rhythm. const size = 84; const cx = size / 2; const cy = size / 2; const rOuter = 39; const rInner = 25; const gap = items.length > 1 ? 2.5 : 0; const slice = items.length > 0 ? 360 / items.length : 360; return (
{items.map((item, i) => { const start = i * slice + gap / 2; const end = (i + 1) * slice - gap / 2; const color = item.on ? SCHED_DISC_COLORS[i % SCHED_DISC_COLORS.length] : "#475569"; return ( { e.stopPropagation(); onSelect(item); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onSelect(item); } }} > {`${item.label} · ${item.on ? "ON" : "OFF"} · ${item.age}`} ); })}
{center}
{unit ?
{unit}
: null}
); } function topLevelRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] { if (!root) return []; return (root.children || []).filter((c) => { if (String(c.kind) !== "region") return false; if (isUmeWorldContainer(c) || isWorldDrillFolder(c)) return false; return true; }); } function clampPct(n: number): number { if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(100, Math.round(n))); } function ratioPct(used: number, limit: number): number { if (limit <= 0) return 0; return clampPct((used / limit) * 100); } function toneClass(pct: number, muted?: boolean): string { if (muted) return "is-muted"; if (pct >= 75) return "is-high"; if (pct >= 40) return "is-mid"; return "is-ok"; } function formatBytes(n: number): string { const v = Math.max(0, Number(n) || 0); const gib = 1024 ** 3; const mib = 1024 ** 2; if (v >= gib) { const g = v / gib; return `${g.toFixed(g >= 10 ? 0 : 1)} GiB`; } return `${(v / mib).toFixed(0)} MiB`; } function formatBytesCompact(n: number): string { const v = Math.max(0, Number(n) || 0); const gib = 1024 ** 3; const mib = 1024 ** 2; if (v >= gib) return `${(v / gib).toFixed(v >= 10 * gib ? 0 : 1)}G`; if (v >= mib) return `${(v / mib).toFixed(0)}M`; return `${Math.max(1, Math.round(v / 1024))}K`; } function isActiveStatus(status: string): boolean { const s = status.toLowerCase(); return ( s === "collecting" || s === "running" || s === "connecting" || s === "testing" || s === "paused" || s === "pending" || s === "detached" ); } function kindLabel(kind: string, t: (k: string) => string): string { const key = `audit.tasks.kind.${kind}`; const tr = t(key); return tr === key ? kind : tr; } function statusLabel(status: string, t: (k: string) => string): string { const key = `audit.tasks.status.${status}`; const tr = t(key); return tr === key ? status : tr; } function statusTone(status: string): "running" | "ok" | "paused" | "stopped" | "other" { const s = status.toLowerCase(); if (s === "collecting" || s === "running" || s === "connecting" || s === "testing") return "running"; if (s === "ready" || s === "idle" || s === "succeeded" || s === "success") return "ok"; if (s === "paused" || s === "pending" || s === "detached") return "paused"; if (s === "failed" || s === "error" || s === "stopped") return "stopped"; return "other"; } function taskTitle(row: OpsTaskItem, t: (k: string) => string): string { const kind = kindLabel(row.kind, t); const subject = String(row.title || row.id || "").trim(); if (!subject) return kind; if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) { return subject; } return `${kind} · ${subject}`; } function openTaskHref(href: string) { const path = String(href || "").trim() || "/audit/tasks"; if (path.startsWith("/ume")) { void openOrFocusModule({ moduleId: "ume", path }); return; } if ( path.startsWith("/network") || path.startsWith("/lldp") || path.startsWith("/config-sync") || path.startsWith("/port-traffic") ) { void openOrFocusModule({ moduleId: "network", path }); return; } if (path.startsWith("/ne")) { void openOrFocusModule({ moduleId: "ne", path }); return; } if (path.startsWith("/webcrt")) { void openOrFocusModule({ moduleId: "webcrt", path }); return; } void openOrFocusModule({ moduleId: "audit", path: path.startsWith("/audit") ? path : "/audit/tasks", }); } function StatLine({ label, value, pct, muted, }: { label: string; value: string; pct?: number; muted?: boolean; }) { return (
{label} {value} {pct == null ? null : (
); } export function WorkbenchPage() { const { t } = useI18n(); const { isAdmin, hasScope } = useAuth(); const metricsQuery = useQuery({ queryKey: ["runtimeMetrics"], queryFn: fetchRuntimeMetrics, staleTime: 10_000, refetchInterval: 15_000, refetchIntervalInBackground: false, }); const opsQuery = useQuery({ queryKey: ["opsTasks"], queryFn: fetchOpsTasks, staleTime: 10_000, refetchInterval: (q) => ((q.state.data?.active ?? 0) > 0 ? 4000 : 20_000), refetchIntervalInBackground: false, }); const neStatsQuery = useQuery({ queryKey: ["managedNeStats"], queryFn: fetchManagedNeStats, staleTime: 30_000, refetchInterval: 45_000, refetchIntervalInBackground: false, enabled: isAdmin || hasScope("ne:read"), }); const fabricQuery = useQuery({ queryKey: ["fabricSummary"], queryFn: fetchFabricSummary, staleTime: 30_000, refetchInterval: 60_000, refetchIntervalInBackground: false, enabled: isAdmin || hasScope("ne:read"), }); const topoTreeQuery = useQuery({ queryKey: ["topologyTree"], queryFn: fetchTopologyTree, staleTime: 45_000, refetchInterval: 60_000, refetchIntervalInBackground: false, enabled: isAdmin || hasScope("ne:read"), }); const visibleBySection = useMemo(() => { const canSee = (mod: ModuleDefinition) => !mod.workbenchHidden && (!mod.adminOnly || isAdmin) && (!mod.requiredScope || hasScope(mod.requiredScope) || isAdmin); return Object.fromEntries( SECTIONS.map((section) => [section, modulesInSection(section).filter(canSee)]), ) as Record; }, [isAdmin, hasScope]); const displaySections = useMemo((): DisplaySection[] => { const ops = [...visibleBySection.monitoring, ...visibleBySection.operations]; const system = visibleBySection.system; const sections: DisplaySection[] = [ { key: "operations", mods: ops }, { key: "system", mods: system }, ]; return sections.filter((s) => s.mods.length > 0); }, [visibleBySection]); const railTasks = useMemo(() => { const items = opsQuery.data?.items ?? []; const active = items.filter((row) => isActiveStatus(row.status)); return (active.length ? active : items).slice(0, 8); }, [opsQuery.data]); const metrics = metricsQuery.data; const host = metrics?.host; const activeTasks = opsQuery.data?.active ?? 0; const cpuPct = clampPct(Number(host?.cpu_percent ?? 0)); const memPct = clampPct(Number(host?.mem_percent ?? 0)); const memUsed = Number(host?.mem_used_bytes || 0); const memTotal = Number(host?.mem_total_bytes || 0); const storage = metrics?.db_storage; const storageUsed = Number(storage?.used_bytes || 0); const storageOk = storageUsed > 0 || (storage?.source === "pg_database_size" && !storage?.error); const cliUsed = Number(metrics?.cli_budget?.in_use || 0); const cliLimit = Math.max(1, Number(metrics?.cli_budget?.limit || 0)); const cliPct = ratioPct(cliUsed, cliLimit); const dbOut = Number(metrics?.db_pool?.checked_out || 0); const dbSize = Math.max(1, Number(metrics?.db_pool?.size || 0)); const dbPct = ratioPct(dbOut, dbSize); const webUsed = Number(metrics?.webcrt?.active_sessions || 0); const webMax = Math.max(1, Number(metrics?.webcrt?.max_sessions || 20)); const webPct = ratioPct(webUsed, webMax); const hostBusy = cpuPct >= 75 || memPct >= 75; const platformBusy = cliPct >= 75 || dbPct >= 75 || webPct >= 75; const schedulers = metrics?.device_schedulers; const schedConfigOn = Boolean(schedulers?.config_sync?.running ?? metrics?.config_sync?.running); const schedLldpOn = Boolean(schedulers?.lldp_collect?.running ?? metrics?.lldp_collect?.running); const schedPortOn = Boolean(schedulers?.port_traffic?.running ?? metrics?.port_traffic?.running); const schedNeOn = Boolean(schedulers?.ne_collect?.running); const schedStale = Boolean(schedulers?.stale); const schedMode = String(schedulers?.mode || ""); const schedAge = typeof schedulers?.age_sec === "number" ? schedulers.age_sec : null; const healthBusy = hostBusy || platformBusy || schedStale; const canReadFleet = isAdmin || hasScope("ne:read"); const neTotal = neStatsQuery.data?.total ?? 0; const neByStatus = neStatsQuery.data?.by_status ?? {}; const nePass = Number(neByStatus.pass || 0); const neFail = Number(neByStatus.fail || 0); const neTesting = Number(neByStatus.testing || 0); const neUnknown = Number(neByStatus.unknown || 0); const neNoTag = Number(neStatsQuery.data?.no_tag_count || 0); const neKnownPct = neTotal > 0 ? clampPct(((neTotal - neUnknown) / neTotal) * 100) : 0; const neTopTags = Object.entries(neStatsQuery.data?.tag_counts ?? {}) .filter(([tag, n]) => tag && tag !== "__no_tag__" && Number(n) > 0) .sort((a, b) => Number(b[1]) - Number(a[1])) .slice(0, 2) .map(([tag, n]) => ({ tag, n: Number(n) })); const neValue = !canReadFleet ? "—" : neStatsQuery.isLoading ? "…" : neStatsQuery.isError ? "—" : neTotal; const fabricNodes = Number(fabricQuery.data?.node_count || 0); const fabricEdges = Number(fabricQuery.data?.edge_count || 0); const fabricDiscover = formatCompactTime(fabricQuery.data?.last_discover_at ?? null); const fabricUpdated = formatCompactTime(fabricQuery.data?.updated_at ?? null); const regionSlices = useMemo((): FleetSlice[] => { const regions = topLevelRegions(topoTreeQuery.data?.root) .map((r) => ({ id: r.id, name: String(r.name || "").trim() || r.id, n: Number(r.ne_count || 0), })) .filter((r) => r.n > 0) .sort((a, b) => b.n - a.n); // Cap at 4 legend rows to match 网元 / 调度 denseness. const TOP_N = 3; const head = regions.slice(0, TOP_N); const rest = regions.slice(TOP_N); const restSum = rest.reduce((s, r) => s + r.n, 0); const assigned = regions.reduce((s, r) => s + r.n, 0); const unassigned = Math.max(0, fabricNodes - assigned); const other = restSum + unassigned; const slices: FleetSlice[] = head.map((r, i) => ({ key: r.id, n: r.n, label: r.name, color: REGION_PALETTE[i % REGION_PALETTE.length], })); if (other > 0) { slices.push({ key: unassigned > 0 && restSum === 0 ? "__none__" : "__other__", n: other, label: "", color: unassigned > 0 && restSum === 0 ? FLEET_TONE_COLOR.muted : "#94a3b8", }); } return slices; }, [topoTreeQuery.data, fabricNodes]); const memValue = memTotal > 0 ? `${memPct}% · ${formatBytes(memUsed)}` : `${memPct}%`; const storageValue = storageOk ? formatBytesCompact(storageUsed) : "—"; const dash = (v: number | string) => (canReadFleet ? v : "—"); const tickAge = (sec?: number | null) => formatAgeSec(sec ?? null); const schedRows: SchedDiscItem[] = [ { key: "config", on: schedConfigOn, label: t("workbench.fleetSchedConfig"), age: tickAge(schedulers?.config_sync?.last_tick_age_sec), moduleId: "network", path: "/network/tasks/config-sync", }, { key: "lldp", on: schedLldpOn, label: t("workbench.fleetSchedLldp"), age: tickAge(schedulers?.lldp_collect?.last_tick_age_sec), moduleId: "network", path: "/network/topology/lldp", }, { key: "port", on: schedPortOn, label: t("workbench.fleetSchedPort"), age: tickAge(schedulers?.port_traffic?.last_tick_age_sec), moduleId: "network", path: "/network/tasks/port-traffic", }, { key: "ne", on: schedNeOn, label: t("workbench.fleetSchedNeCollect"), age: tickAge(schedulers?.ne_collect?.last_tick_age_sec), moduleId: "network", path: "/network/tasks/collect", }, ]; const schedOnCount = schedRows.filter((r) => r.on).length; return (

{t("workbench.title")}

{t("workbench.hostHealth")}
{displaySections.map((section) => (

{t(`workbench.${section.key}`)}

{section.mods.map((mod) => ( ))}
))}

{t("workbench.fleet")}