feat(admin): 仪表盘次级概览卡接真数据(A5) — 8卡聚合,存储卡诚实占位
- 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:
1 parent
f28995c1e5
commit
ca2faa0f95
5 files changed
+343
-23
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user