feat(admin): 仪表盘次级概览卡接真数据(A5) — 8卡聚合,存储卡诚实占位
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- Dashboard.tsx 去掉占位块 → OverviewSection; 新增 admin/modules/dashboard/(MiniMetric/useDashboardExtras/overviewMetrics/OverviewSection)
- 8卡用现有 lib/api 导出: LLM组/RAG文档/自动化/已装插件(含toolCount)/用户/网关请求/LLM调用+子代理/存储(缺只读端点→诚实占位)
- 端点报错→值'—'+徽标'端点报错'+hint后端原文(不崩不伪造)
- 仅碰 Dashboard.tsx+dashboard/*, 未动 api.ts/types.ts(避免并发冲突)
- 前端 npm run build 绿(156 modules)
This commit is contained in:
AskaEth committed 2026-09-30 08:44:00 +08:00
1 parent f28995c1e5
commit ca2faa0f95
5 files changed
+343 -23

No files matched your search

+3 -23
View File
@@ -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 {
))}
</CardGrid>
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">资源 / LLM / 业务概览</h3>
<span className="text-xs text-[var(--mu-text-lo)]">占位(后端 M10+ 提供)</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<MiniMetric label="LLM 供应商/模型" value="—" hint="多模态检测 / fallback / 角色路由" />
<MiniMetric label="存储" value="SQLite" hint="默认;PG/pgvector 可选增强" />
<MiniMetric label="工具循环上限" value="8 轮" hint="后台声明 / 前台 2s 转后台" />
</div>
<div className="pt-2 text-xs text-[var(--mu-text-lo)]">卡片即入口,点击上溯卡片下钻到对应模块。</div>
</Surface>
</div>
);
}
function MiniMetric({ label, value, hint }: { label: string; value: string; hint: string }): React.ReactNode {
return (
<div className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-3">
<div className="text-xs text-[var(--mu-text-lo)]">{label}</div>
<div className="mt-1 text-lg font-semibold">{value}</div>
<div className="text-xs text-[var(--mu-text-lo)]">{hint}</div>
<OverviewSection />
</div>
);
}
@@ -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<MetricState, { label: string; tone: TagTone }> = {
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 = (
<>
<div className="flex items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
<Icon name={icon} size={14} />
<span className="truncate">{label}</span>
<Tag tone={badge.tone} className="ml-auto shrink-0">{badge.label}</Tag>
</div>
<div className="mt-1.5 text-lg font-semibold leading-tight">{value}</div>
<div className="mt-0.5 break-words text-xs text-[var(--mu-text-lo)]">{hint}</div>
</>
);
const base =
"rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-3";
if (!section) return <div className={base}>{body}</div>;
return (
<button
type="button"
title="点击下钻"
onClick={() => navigateAdmin(section)}
className={`${base} text-left transition-all duration-200 ease-[var(--mu-spring)] hover:border-[var(--mu-accent)]/50`}
>
{body}
</button>
);
}
@@ -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 (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">资源 / LLM / 业务概览</h3>
<span className="text-xs text-[var(--mu-text-lo)]">真实聚合 · 每卡可下钻</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{metrics.map((m) => (
<MiniMetric key={m.label} {...m} />
))}
</div>
<div className="pt-1 text-xs text-[var(--mu-text-lo)]">
卡片即入口,点击下钻到对应模块;缺端点的卡片只标注缺口,不伪造数值。
</div>
</Surface>
);
}
@@ -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<unknown>): 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<unknown>,
section?: AdminSectionId
): MiniMetricSpec {
return {
icon,
label,
value: "—",
hint: source.error ? `端点报错:${source.error}` : planned,
state: stateOf(source),
section,
};
}
function llmMetric(llm: Loaded<AdminLlmConfig>): 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<AdminRagResponse>): 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<AdminAutomationRecord[]>): 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<InstalledPlugin[]>): 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<AdminUserRecord[]>): 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<AdminObservabilityMetrics>): 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<AdminObservabilityMetrics>): 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())}`;
}
@@ -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<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export interface DashboardExtras {
llm: Loaded<AdminLlmConfig>;
metrics: Loaded<AdminObservabilityMetrics>;
installed: Loaded<InstalledPlugin[]>;
automations: Loaded<AdminAutomationRecord[]>;
rag: Loaded<AdminRagResponse>;
users: Loaded<AdminUserRecord[]>;
}
/** 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 };
}