diff --git a/frontend/web/src/admin/modules/Dashboard.tsx b/frontend/web/src/admin/modules/Dashboard.tsx index 1fa997e..9382722 100644 --- a/frontend/web/src/admin/modules/Dashboard.tsx +++ b/frontend/web/src/admin/modules/Dashboard.tsx @@ -10,9 +10,10 @@ import { import { useAppStore } from "../../store/useAppStore"; import { navigateAdmin, type AdminSectionId } from "../router"; import { CardGrid, StatCard } from "../components"; -import { Surface, Tag } from "../../components/primitives"; +import { Tag } from "../../components/primitives"; import { Icon, type IconName } from "../../components/Icon"; import { useAutoReload } from "../hooks"; +import { OverviewSection } from "./dashboard/OverviewSection"; interface DashCard { id: AdminSectionId; @@ -88,28 +89,7 @@ export function DashboardModule(): React.ReactNode { ))} - -
-

资源 / LLM / 业务概览

- 占位(后端 M10+ 提供) -
-
- - - -
-
卡片即入口,点击上溯卡片下钻到对应模块。
-
- - ); -} - -function MiniMetric({ label, value, hint }: { label: string; value: string; hint: string }): React.ReactNode { - return ( -
-
{label}
-
{value}
-
{hint}
+
); } diff --git a/frontend/web/src/admin/modules/dashboard/MiniMetric.tsx b/frontend/web/src/admin/modules/dashboard/MiniMetric.tsx new file mode 100644 index 0000000..4392a3b --- /dev/null +++ b/frontend/web/src/admin/modules/dashboard/MiniMetric.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; +import { Icon, type IconName } from "../../../components/Icon"; +import { Tag, type TagTone } from "../../../components/primitives"; +import { navigateAdmin, type AdminSectionId } from "../../router"; + +/** Honest per-card state: real data landed / still loading / endpoint failed / + * no endpoint exists in lib/api.ts (a placeholder, never a fabricated value). */ +export type MetricState = "ok" | "loading" | "error" | "placeholder"; + +/** One derived overview card (design/10 §4: 卡片多而全, 详情在各独立页面). */ +export interface MiniMetricSpec { + icon: IconName; + label: string; + value: string; + hint: string; + state: MetricState; + /** Admin module this card drills down to; absent = not a link. */ + section?: AdminSectionId; +} + +const STATE_BADGE: Record = { + ok: { label: "已接入", tone: "ok" }, + loading: { label: "加载中", tone: "neutral" }, + error: { label: "端点报错", tone: "err" }, + placeholder: { label: "占位", tone: "warn" }, +}; + +export function MiniMetric({ icon, label, value, hint, state, section }: MiniMetricSpec): ReactNode { + const badge = STATE_BADGE[state]; + const body = ( + <> +
+ + {label} + {badge.label} +
+
{value}
+
{hint}
+ + ); + const base = + "rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-3"; + if (!section) return
{body}
; + return ( + + ); +} diff --git a/frontend/web/src/admin/modules/dashboard/OverviewSection.tsx b/frontend/web/src/admin/modules/dashboard/OverviewSection.tsx new file mode 100644 index 0000000..0430181 --- /dev/null +++ b/frontend/web/src/admin/modules/dashboard/OverviewSection.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { Surface } from "../../../components/primitives"; +import { MiniMetric } from "./MiniMetric"; +import { buildOverviewMetrics } from "./overviewMetrics"; +import { useDashboardExtras } from "./useDashboardExtras"; + +/** The secondary dashboard block: 资源 / LLM / 业务 aggregates pulled from the + * real admin endpoints (design/10 §3 #1, §4). Each card drills down; the most + * detailed view always lives on its own module page. */ +export function OverviewSection(): ReactNode { + const extras = useDashboardExtras(); + const metrics = buildOverviewMetrics(extras); + return ( + +
+

资源 / LLM / 业务概览

+ 真实聚合 · 每卡可下钻 +
+
+ {metrics.map((m) => ( + + ))} +
+
+ 卡片即入口,点击下钻到对应模块;缺端点的卡片只标注缺口,不伪造数值。 +
+
+ ); +} diff --git a/frontend/web/src/admin/modules/dashboard/overviewMetrics.ts b/frontend/web/src/admin/modules/dashboard/overviewMetrics.ts new file mode 100644 index 0000000..b338e2c --- /dev/null +++ b/frontend/web/src/admin/modules/dashboard/overviewMetrics.ts @@ -0,0 +1,203 @@ +/** + * Pure derivation of the dashboard's secondary overview cards from the loaded + * API sources (design/10 §4: 卡片多而全, 最详细信息在各独立页面). Nothing here + * invents a value: a source that errored reports the error, an empty list says + * so, and a capability with no read endpoint in `lib/api.ts` stays a placeholder. + */ +import type { IconName } from "../../../components/Icon"; +import type { + AdminAutomationRecord, + AdminLlmConfig, + AdminObservabilityMetrics, + AdminRagResponse, + AdminUserRecord, + InstalledPlugin, +} from "../../../lib/types"; +import type { AdminSectionId } from "../../router"; +import type { MetricState, MiniMetricSpec } from "./MiniMetric"; +import type { DashboardExtras, Loaded } from "./useDashboardExtras"; + +/** Runtime constant from the agent loop (design/03): the tool loop is capped at + * 8 rounds before the agent must answer. Shown as a fact, not as a metric. */ +const TOOL_LOOP_CAP = 8; + +export function buildOverviewMetrics(extras: DashboardExtras): MiniMetricSpec[] { + return [ + llmMetric(extras.llm), + ragMetric(extras.rag), + automationMetric(extras.automations), + pluginMetric(extras.installed), + userMetric(extras.users), + gatewayMetric(extras.metrics), + agentCallMetric(extras.metrics), + storageMetric(), + ]; +} + +function stateOf(source: Loaded): MetricState { + if (source.data != null) return "ok"; + if (source.error != null) return "error"; + return "loading"; +} + +/** A source that has not produced data yet: an em dash instead of a number, and + * the endpoint's own error text when it failed. */ +function pending( + icon: IconName, + label: string, + planned: string, + source: Loaded, + section?: AdminSectionId +): MiniMetricSpec { + return { + icon, + label, + value: "—", + hint: source.error ? `端点报错:${source.error}` : planned, + state: stateOf(source), + section, + }; +} + +function llmMetric(llm: Loaded): MiniMetricSpec { + if (llm.data == null) { + return pending("robot", "LLM 供应商 / 模型", "多模态检测 / fallback / 角色路由", llm, "llm"); + } + const providers = llm.data.providers; + const enabled = providers.filter((p) => p.enabled).length; + const multimodal = providers.filter((p) => p.multimodal).length; + const fallback = providers.filter((p) => p.fallback).length; + return { + icon: "robot", + label: "LLM 供应商 / 模型", + value: `${providers.length} 组模型`, + hint: + providers.length === 0 + ? "未配置供应商" + : `启用 ${enabled} · 多模态 ${multimodal} · 带 fallback ${fallback}`, + state: "ok", + section: "llm", + }; +} + +function ragMetric(rag: Loaded): MiniMetricSpec { + if (rag.data == null) { + return pending("docs", "知识库 RAG", "文档 / 分块 / 嵌入状态", rag, "rag"); + } + const docs = rag.data.documents; + const chunks = docs.reduce((n, d) => n + d.chunks, 0); + const embedded = docs.filter((d) => d.embedded).length; + return { + icon: "docs", + label: "知识库 RAG", + value: `${docs.length} 文档`, + hint: docs.length === 0 ? "暂无知识文档" : `${chunks} 分块 · ${embedded}/${docs.length} 已嵌入`, + state: "ok", + section: "rag", + }; +} + +function automationMetric(autos: Loaded): MiniMetricSpec { + if (autos.data == null) { + return pending("clock", "自动化与提醒", "cron / 事件 / 后台任务", autos, "automations"); + } + const rows = autos.data; + const enabled = rows.filter((a) => a.enabled).length; + const next = rows.map((a) => a.nextRun).filter((t): t is string => Boolean(t)).sort()[0]; + return { + icon: "clock", + label: "自动化与提醒", + value: `${rows.length} 条规则`, + hint: rows.length === 0 ? "暂无自动化规则" : `启用 ${enabled} · 下次运行 ${formatTime(next)}`, + state: "ok", + section: "automations", + }; +} + +function pluginMetric(installed: Loaded): MiniMetricSpec { + if (installed.data == null) { + return pending("plugin", "已安装插件 / MCP", "liveness / 工具数", installed, "plugins"); + } + const rows = installed.data; + const live = rows.filter((p) => p.live).length; + const tools = rows.reduce((n, p) => n + p.toolCount, 0); + return { + icon: "plugin", + label: "已安装插件 / MCP", + value: `${rows.length} 个`, + hint: rows.length === 0 ? "无运行时安装插件" : `${live} 在线 · 提供 ${tools} 个工具`, + state: "ok", + section: "plugins", + }; +} + +function userMetric(users: Loaded): MiniMetricSpec { + if (users.data == null) { + return pending("users", "用户与权限", "RBAC / API Key", users, "users"); + } + const rows = users.data; + const owners = rows.filter((u) => u.role === "owner").length; + const admins = rows.filter((u) => u.role === "admin").length; + const viewers = rows.length - owners - admins; + return { + icon: "users", + label: "用户与权限", + value: `${rows.length} 用户`, + hint: rows.length === 0 ? "暂无后端用户" : `owner ${owners} · admin ${admins} · viewer ${viewers}`, + state: "ok", + section: "users", + }; +} + +function gatewayMetric(m: Loaded): MiniMetricSpec { + if (m.data == null) { + return pending("trend", "网关请求", "QPS / 延迟 / 状态码", m, "observability"); + } + const d = m.data; + return { + icon: "trend", + label: "网关请求", + value: `${d.totalRequests} 次`, + hint: `QPS ${d.qps} · 平均 ${d.avgLatencyMs}ms · 峰值 ${d.maxLatencyMs}ms`, + state: "ok", + section: "observability", + }; +} + +function agentCallMetric(m: Loaded): MiniMetricSpec { + if (m.data == null) { + return pending("agent", "LLM 调用 / 子代理", `工具循环上限 ${TOOL_LOOP_CAP} 轮`, m, "observability"); + } + const d = m.data; + return { + icon: "agent", + label: "LLM 调用 / 子代理", + value: `${d.llmCalls} 次`, + hint: `子代理事件 ${d.subagentEvents} · 工具循环上限 ${TOOL_LOOP_CAP} 轮`, + state: "ok", + section: "observability", + }; +} + +/** Storage has no read-only endpoint in `lib/api.ts` (backup/restore are writes + * that would touch the filesystem), so this card states the configured default + * and names the missing endpoint instead of showing a made-up figure. */ +function storageMetric(): MiniMetricSpec { + return { + icon: "storage", + label: "存储", + value: "SQLite", + hint: "默认引擎 · PG/pgvector 可选增强;缺只读查询端点(未在 lib/api.ts 暴露)", + state: "placeholder", + section: "system", + }; +} + +/** ISO → local "MM-DD HH:mm"; absent or unparseable values render as an em dash. */ +function formatTime(iso?: string): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/frontend/web/src/admin/modules/dashboard/useDashboardExtras.ts b/frontend/web/src/admin/modules/dashboard/useDashboardExtras.ts new file mode 100644 index 0000000..69ec45c --- /dev/null +++ b/frontend/web/src/admin/modules/dashboard/useDashboardExtras.ts @@ -0,0 +1,54 @@ +import { + adminGetLlm, + adminListAutomations, + adminListInstalledPlugins, + adminListRag, + adminListUsers, + adminObservabilityMetrics, +} from "../../../lib/api"; +import { useAsyncData } from "../../../lib/useAsyncData"; +import type { + AdminAutomationRecord, + AdminLlmConfig, + AdminObservabilityMetrics, + AdminRagResponse, + AdminUserRecord, + InstalledPlugin, +} from "../../../lib/types"; +import { useAutoReload } from "../../hooks"; + +/** The load state of one async source. Structurally identical to what + * `useAsyncData` returns, so its result passes straight through. */ +export interface Loaded { + data: T | null; + loading: boolean; + error: string | null; +} + +export interface DashboardExtras { + llm: Loaded; + metrics: Loaded; + installed: Loaded; + automations: Loaded; + rag: Loaded; + users: Loaded; +} + +/** The secondary dashboard sources, all from endpoints that already exist in + * `lib/api.ts`. The dashboard is an overview, not a live console, so each one + * refreshes on its own slow interval. */ +export function useDashboardExtras(): DashboardExtras { + const llm = useAsyncData(adminGetLlm, []); + const metrics = useAsyncData(adminObservabilityMetrics, []); + const installed = useAsyncData(adminListInstalledPlugins, []); + const automations = useAsyncData(adminListAutomations, []); + const rag = useAsyncData(adminListRag, []); + const users = useAsyncData(adminListUsers, []); + useAutoReload(llm.reload, 30000); + useAutoReload(metrics.reload, 15000); + useAutoReload(installed.reload, 30000); + useAutoReload(automations.reload, 30000); + useAutoReload(rag.reload, 30000); + useAutoReload(users.reload, 30000); + return { llm, metrics, installed, automations, rag, users }; +}