diff --git a/frontend/web/src/admin/modules/Dashboard.tsx b/frontend/web/src/admin/modules/Dashboard.tsx index 9382722..9b1d628 100644 --- a/frontend/web/src/admin/modules/Dashboard.tsx +++ b/frontend/web/src/admin/modules/Dashboard.tsx @@ -14,6 +14,7 @@ import { Tag } from "../../components/primitives"; import { Icon, type IconName } from "../../components/Icon"; import { useAutoReload } from "../hooks"; import { OverviewSection } from "./dashboard/OverviewSection"; +import { channelListState } from "./channels/channelStatus"; interface DashCard { id: AdminSectionId; @@ -43,25 +44,58 @@ export function DashboardModule(): React.ReactNode { const backthink = useAppStore((s) => s.backthink); const wsStatus = useAppStore((s) => s.wsStatus); - const nPersonas = personas.data?.length ?? 0; - const nSessions = sessions.data?.length ?? 0; - const nMemories = memories.data?.length ?? 0; - const nPlugins = plugins.data?.length ?? 0; - const nChannels = channels.data?.length ?? 0; - const nDevices = iot.data?.length ?? 0; - const offlineChannels = (channels.data ?? []).filter((c) => !c.connected).length; - const onlineDevices = (iot.data ?? []).filter((d) => d.online).length; const runningAgents = agents.filter((a) => a.status === "running").length; + type Stat = { value: number | string; sub: string; tone: DashCard["tone"] }; + /** One HTTP-backed stat card. A list we could not read is "unknown" (—), never a + * fabricated 0 / 「全部在线」: the dashboard is the first thing the owner opens + * when something feels wrong, so it must not turn a dead backend into good news. + * Reuses the channel pages' list tri-state so the rule stays in one place. */ + const stat = ( + req: { data: readonly T[] | null; error: string | null }, + ready: (list: readonly T[]) => Stat, + ): Stat => { + switch (channelListState(req.data, req.error)) { + case "unknown": + return { value: "—", sub: "读取失败(与后端失联)", tone: "warn" }; + case "pending": + return { value: "—", sub: "读取中…", tone: "neutral" }; + default: + return ready(req.data as readonly T[]); + } + }; + const cards: DashCard[] = [ - { id: "personas", label: "人格", icon: "dna", value: nPersonas, sub: "启停/默认/路由", tone: "accent" }, - { id: "sessions", label: "活跃会话", icon: "chat", value: nSessions, sub: "渠道 × actor", tone: "ok" }, - { id: "memories", label: "记忆", icon: "memory", value: nMemories, sub: "三类作用域隔离", tone: "accent" }, - { id: "plugins", label: "插件与能力", icon: "plugin", value: nPlugins, sub: "含 MCP/渠道", tone: "neutral" }, - { id: "channels", label: "渠道", icon: "channel", value: `${nChannels - offlineChannels}/${nChannels}`, - sub: offlineChannels > 0 ? `${offlineChannels} 离线` : "全部在线", tone: offlineChannels > 0 ? "warn" : "ok" }, - { id: "iot", label: "IoT 设备", icon: "device", value: `${onlineDevices}/${nDevices}`, - sub: onlineDevices === nDevices && nDevices > 0 ? "全部在线" : "设备状态", tone: nDevices > 0 && onlineDevices === nDevices ? "ok" : "neutral" }, + { id: "personas", label: "人格", icon: "dna", ...stat(personas, (l) => ({ value: l.length, sub: "启停/默认/路由", tone: "accent" })) }, + { id: "sessions", label: "活跃会话", icon: "chat", ...stat(sessions, (l) => ({ value: l.length, sub: "渠道 × actor", tone: "ok" })) }, + { id: "memories", label: "记忆", icon: "memory", ...stat(memories, (l) => ({ value: l.length, sub: "三类作用域隔离", tone: "accent" })) }, + { id: "plugins", label: "插件与能力", icon: "plugin", ...stat(plugins, (l) => ({ value: l.length, sub: "含 MCP/渠道", tone: "neutral" })) }, + { + id: "channels", + label: "渠道", + icon: "channel", + ...stat(channels, (l) => { + const offline = l.filter((c) => !c.connected).length; + return { + value: `${l.length - offline}/${l.length}`, + sub: offline > 0 ? `${offline} 离线` : "全部在线", + tone: offline > 0 ? "warn" : "ok", + }; + }), + }, + { + id: "iot", + label: "IoT 设备", + icon: "device", + ...stat(iot, (l) => { + const online = l.filter((d) => d.online).length; + return { + value: `${online}/${l.length}`, + sub: online === l.length && l.length > 0 ? "全部在线" : "设备状态", + tone: l.length > 0 && online === l.length ? "ok" : "neutral", + }; + }), + }, { id: "agents", label: "Agent 运行", icon: "agent", value: runningAgents, sub: `${agents.length} 个已登记`, tone: runningAgents > 0 ? "accent" : "neutral" }, { id: "backthink", label: "后台思考活跃度", icon: "night", value: backthink.length, sub: "实时 (WS)", tone: backthink.length > 0 ? "warn" : "neutral" }, ];