feat(admin): 提示词控制面页 + 概览/状态页重做 + 接入 vitest

- 新增「提示词」页(/prompts):接 /api/v1/prompts,建版本/激活热下发/
  对比激活版行级 diff/撤销,挂到配置组
- 概览页重做为系统控制塔:吃 admin/overview(全平台口径)——平台任务/
  评测/用户规模/服务在线 + 模型路由态 + 提示词覆盖态 + 健康拓扑 + 30s自刷
- 服务状态页重做:中性克制风 + 请求链路做成深色实时数据管道(流动光点/
  节点辉光) + MCP 工具改能力域配色小卡片
- 接入 vitest(此前零测试):41 单测(api 控制面/lib diff/路由派生)
- api.ts 增 statsOverview/adminOverview/prompt 控制面四接口 + groupPrompts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-02 08:59:40 +08:00
parent e2104efc64
commit 30e80c0eed
13 changed files with 3825 additions and 470 deletions
+2420 -1
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^19.0.0",
@@ -14,13 +16,18 @@
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
"vitest": "^4.1.9"
}
}
+269
View File
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
setToken,
getToken,
clearToken,
login,
me,
listModels,
saveModel,
gatewayOnline,
groupPrompts,
listPrompts,
createPromptVersion,
activatePrompt,
deactivatePrompt,
type PromptVersion,
} from "./api";
// 构造一个 fetch 响应桩。
function res(body: unknown, init?: { ok?: boolean; status?: number }): Response {
return {
ok: init?.ok ?? true,
status: init?.status ?? 200,
json: async () => body,
} as unknown as Response;
}
const fetchMock = vi.fn();
beforeEach(() => {
clearToken();
localStorage.clear();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("令牌持久化", () => {
it("setToken 写入内存并持久化到 localStorage", () => {
setToken("abc");
expect(getToken()).toBe("abc");
expect(localStorage.getItem("sdx_admin_token")).toBe("abc");
});
it("clearToken 清空内存与 localStorage", () => {
setToken("abc");
clearToken();
expect(getToken()).toBe("");
expect(localStorage.getItem("sdx_admin_token")).toBeNull();
});
});
describe("authHeaders(经由带鉴权的请求观测)", () => {
it("有令牌时带上 Bearer 头", async () => {
setToken("tok123");
fetchMock.mockResolvedValue(res({ models: [] }));
await listModels("chat");
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer tok123");
});
it("无令牌时不带 Authorization 头", async () => {
fetchMock.mockResolvedValue(res({ models: [] }));
await listModels("chat");
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
it("写请求带 JSON Content-Type", async () => {
setToken("tok");
fetchMock.mockResolvedValue(res({ id: "1" }));
await saveModel({ kind: "chat", provider: "openai", base_url: "u", api_key: "k", model: "m" });
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers["Content-Type"]).toBe("application/json");
});
});
describe("guard401 清令牌并广播登出", () => {
it("401 时清令牌并派发 sdx:logout 事件", async () => {
setToken("stale");
const onLogout = vi.fn();
window.addEventListener("sdx:logout", onLogout);
fetchMock.mockResolvedValue(res({}, { ok: false, status: 401 }));
await expect(listModels("chat")).rejects.toThrow();
expect(getToken()).toBe("");
expect(onLogout).toHaveBeenCalledOnce();
window.removeEventListener("sdx:logout", onLogout);
});
it("403(已登录非管理员)照常抛错,但不清令牌/不登出", async () => {
setToken("validuser");
const onLogout = vi.fn();
window.addEventListener("sdx:logout", onLogout);
fetchMock.mockResolvedValue(res({}, { ok: false, status: 403 }));
await expect(listModels("chat")).rejects.toThrow();
expect(getToken()).toBe("validuser"); // 令牌保留
expect(onLogout).not.toHaveBeenCalled();
window.removeEventListener("sdx:logout", onLogout);
});
});
describe("login", () => {
it("成功时存令牌并返回用户", async () => {
fetchMock.mockResolvedValue(
res({ token: "t1", user: { id: "u1", email: "a@b.c" } }),
);
const user = await login("a@b.c", "pw");
expect(user.id).toBe("u1");
expect(getToken()).toBe("t1");
});
it("失败时抛出后端 error 文案且不存令牌", async () => {
fetchMock.mockResolvedValue(res({ error: "密码错误" }, { ok: false, status: 401 }));
await expect(login("a@b.c", "bad")).rejects.toThrow("密码错误");
expect(getToken()).toBe("");
});
it("无 error 字段时回退到状态码文案", async () => {
fetchMock.mockResolvedValue(res({}, { ok: false, status: 500 }));
await expect(login("a@b.c", "pw")).rejects.toThrow("500");
});
});
describe("me", () => {
it("无令牌时直接返回 null(不发请求)", async () => {
const u = await me();
expect(u).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("响应非 ok 时清令牌并返回 null", async () => {
setToken("expired");
fetchMock.mockResolvedValue(res({}, { ok: false, status: 401 }));
expect(await me()).toBeNull();
expect(getToken()).toBe("");
});
it("成功时返回用户", async () => {
setToken("ok");
fetchMock.mockResolvedValue(res({ user: { id: "u1", email: "a@b.c" } }));
expect(await me()).toEqual({ id: "u1", email: "a@b.c" });
});
});
describe("gatewayOnline", () => {
it("healthz ok 时为 true", async () => {
fetchMock.mockResolvedValue(res(null, { ok: true }));
expect(await gatewayOnline()).toBe(true);
});
it("healthz 非 ok 时为 false", async () => {
fetchMock.mockResolvedValue(res(null, { ok: false, status: 503 }));
expect(await gatewayOnline()).toBe(false);
});
it("网络异常时吞掉错误并返回 false", async () => {
fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
expect(await gatewayOnline()).toBe(false);
});
});
// —— Prompt 控制面 ——
function pv(key: string, version: number, active = false): PromptVersion {
return { key, version, active, note: "", content: `c${version}` };
}
describe("groupPrompts(控制面分组:纯函数)", () => {
it("保持 keys 注册顺序,未建版本的 key 也成组(active=null", () => {
const gs = groupPrompts({ keys: ["a", "b", "c"], versions: [pv("b", 1)] });
expect(gs.map((g) => g.key)).toEqual(["a", "b", "c"]);
expect(gs[0]).toMatchObject({ key: "a", versions: [], active: null });
expect(gs[1].versions).toHaveLength(1);
});
it("每组版本号倒序,挑出激活版", () => {
const gs = groupPrompts({ keys: ["k"], versions: [pv("k", 1), pv("k", 3, true), pv("k", 2)] });
expect(gs[0].versions.map((v) => v.version)).toEqual([3, 2, 1]);
expect(gs[0].active?.version).toBe(3);
});
it("无激活版时 active 为 null(回退代码默认)", () => {
const gs = groupPrompts({ keys: ["k"], versions: [pv("k", 1), pv("k", 2)] });
expect(gs[0].active).toBeNull();
});
it("DB 出现 Known 未列的 key 也容错展示(追加在已知之后)", () => {
const gs = groupPrompts({ keys: ["known"], versions: [pv("orphan", 1, true)] });
expect(gs.map((g) => g.key)).toEqual(["known", "orphan"]);
expect(gs[1].active?.version).toBe(1);
});
});
describe("listPrompts", () => {
it("缺字段时回退为空数组(不崩)", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({}));
expect(await listPrompts()).toEqual({ keys: [], versions: [] });
});
it("非 ok 抛错", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({}, { ok: false, status: 502 }));
await expect(listPrompts()).rejects.toThrow("502");
});
it("命中 /api/v1/prompts(不在 /admin 前缀下)", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ keys: [], versions: [] }));
await listPrompts();
expect(fetchMock.mock.calls[0][0]).toMatch(/\/api\/v1\/prompts$/);
});
});
describe("createPromptVersion", () => {
it("成功返回新版本号", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ key: "k", version: 4 }));
expect(await createPromptVersion("k", "body", "note")).toBe(4);
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body).toEqual({ key: "k", content: "body", note: "note" });
});
it("失败抛后端 error 文案", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ error: "key/content required" }, { ok: false, status: 400 }));
await expect(createPromptVersion("k", "", "")).rejects.toThrow("key/content required");
});
});
describe("activatePrompt", () => {
it("成功无异常", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ activated: true }));
await expect(activatePrompt("k", 2)).resolves.toBeUndefined();
});
it("已激活但广播失败(warn) → 当作错误抛出提醒运维", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ warn: "已激活但广播失败: nats down" }));
await expect(activatePrompt("k", 2)).rejects.toThrow("广播失败");
});
it("HTTP 失败抛 error", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ error: "key/version required" }, { ok: false, status: 400 }));
await expect(activatePrompt("k", 0)).rejects.toThrow("key/version required");
});
});
describe("deactivatePrompt", () => {
it("成功无异常并带上 key", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ deactivated: true }));
await deactivatePrompt("k");
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body).toEqual({ key: "k" });
});
it("失败抛错", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ error: "boom" }, { ok: false, status: 502 }));
await expect(deactivatePrompt("k")).rejects.toThrow("boom");
});
});
+132
View File
@@ -181,3 +181,135 @@ export async function getStatus(): Promise<SystemStatus> {
if (!res.ok) throw new Error(`status failed: ${res.status}`);
return (await res.json()) as SystemStatus;
}
// —— 工作台概览(仪表盘聚合)——
// /api/v1/stats/overview 在 RequireAuth 组下;Task/Eval 表无 owner,故任务/评测口径为全局。
export interface DayCount {
key: string;
count: number;
}
export interface RecentRun {
task_id: string;
status: string;
detail: string;
at: string;
}
export interface Overview {
tasks_today: number;
tasks_total: number;
status_count: DayCount[]; // 近 7 天终态分布
task_trend: DayCount[]; // 近 7 天每日任务数(仅含有任务的天)
eval_avg: number;
faithful_avg: number; // 仅有来源的评测;无来源时为 0
eval_count: number;
kb_docs: number;
kb_count: number;
tokens_today: number;
daily_budget: number; // 0 = 不限额
token_trend: DayCount[];
recent_runs: RecentRun[];
services: Record<string, boolean>;
}
export async function statsOverview(): Promise<Overview> {
const res = guard(await fetch(`${GATEWAY}/api/v1/stats/overview`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`overview failed: ${res.status}`);
return (await res.json()) as Overview;
}
// —— 管理端系统级聚合(控制塔口径,RequireAdmin)——
// 区别于 statsOverview(桌面端个人工作台):这里一律全平台口径——全部用户/任务/评测/模型态/提示词态/健康。
export interface AdminOverview {
users: number;
kb_count: number; // 全平台知识库数
kb_docs: number; // 全平台文档数
tasks_today: number;
tasks_total: number;
status_count: DayCount[];
task_trend: DayCount[];
eval_avg: number;
faithful_avg: number;
eval_count: number;
models: { chat_count: number; embedding_count: number; active_chat: string; active_embedding: string; fallbacks: number };
prompts: { managed: number; overrides: number };
services: Record<string, boolean>;
checked_at: string;
}
export async function adminOverview(): Promise<AdminOverview> {
const res = guard(await fetch(`${ADMIN}/overview`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`admin overview failed: ${res.status}`);
return (await res.json()) as AdminOverview;
}
// —— Prompt 控制面(建版本 → 激活 → 控制面热下发各服务,不重启即生效)——
// 注意:prompt 路由在 RequireAuth 组下(/api/v1/prompts),不在 /admin 前缀内。
const PROMPTS = `${GATEWAY}/api/v1/prompts`;
export interface PromptVersion {
key: string;
version: number;
active: boolean;
note: string;
content: string;
}
export interface PromptListResp {
keys: string[]; // 平台受管的全部可配 key(即便还没建过版本)
versions: PromptVersion[];
}
// 某 key 的聚合视图:版本按号倒序、当前激活版(无则 null = 回退代码内置默认)。
export interface PromptGroup {
key: string;
versions: PromptVersion[];
active: PromptVersion | null;
}
// groupPrompts 把扁平的 {keys, versions} 归并成按 key 的分组(控制面页面的事实源)。
// 纯函数:保持 keys 的注册顺序,每组版本号倒序,挑出激活版。
export function groupPrompts(resp: PromptListResp): PromptGroup[] {
const byKey = new Map<string, PromptVersion[]>();
for (const k of resp.keys) byKey.set(k, []);
for (const v of resp.versions) {
if (!byKey.has(v.key)) byKey.set(v.key, []); // 容错:DB 有但 Known 未列的 key 也展示
byKey.get(v.key)!.push(v);
}
return Array.from(byKey.entries()).map(([key, vs]) => {
const versions = [...vs].sort((a, b) => b.version - a.version);
return { key, versions, active: versions.find((v) => v.active) ?? null };
});
}
export async function listPrompts(): Promise<PromptListResp> {
const res = guard(await fetch(PROMPTS, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list prompts failed: ${res.status}`);
const data = (await res.json()) as Partial<PromptListResp>;
return { keys: data.keys ?? [], versions: data.versions ?? [] };
}
export async function createPromptVersion(key: string, content: string, note: string): Promise<number> {
const res = guard(
await fetch(`${PROMPTS}/version`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key, content, note }) }),
);
const data = (await res.json()) as { version?: number; error?: string };
if (!res.ok) throw new Error(data.error ?? `create version failed: ${res.status}`);
return data.version ?? 0;
}
export async function activatePrompt(key: string, version: number): Promise<void> {
const res = guard(
await fetch(`${PROMPTS}/activate`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key, version }) }),
);
const data = (await res.json()) as { error?: string; warn?: string };
if (!res.ok) throw new Error(data.error ?? `activate failed: ${res.status}`);
if (data.warn) throw new Error(data.warn); // 已激活但广播失败 → 当作错误提示运维
}
export async function deactivatePrompt(key: string): Promise<void> {
const res = guard(await fetch(`${PROMPTS}/deactivate`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `deactivate failed: ${res.status}`);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { lineDiff, diffStat } from "./diff";
describe("lineDiff", () => {
it("完全相同 → 全 eq", () => {
const d = lineDiff("a\nb\nc", "a\nb\nc");
expect(d.every((l) => l.op === "eq")).toBe(true);
expect(d.map((l) => l.text)).toEqual(["a", "b", "c"]);
});
it("纯新增行标 add", () => {
const d = lineDiff("a\nc", "a\nb\nc");
expect(d).toEqual([
{ op: "eq", text: "a" },
{ op: "add", text: "b" },
{ op: "eq", text: "c" },
]);
});
it("纯删除行标 del", () => {
const d = lineDiff("a\nb\nc", "a\nc");
expect(d).toEqual([
{ op: "eq", text: "a" },
{ op: "del", text: "b" },
{ op: "eq", text: "c" },
]);
});
it("替换 = del 旧 + add 新", () => {
const d = lineDiff("hello\nworld", "hello\nthere");
expect(d).toContainEqual({ op: "del", text: "world" });
expect(d).toContainEqual({ op: "add", text: "there" });
expect(d[0]).toEqual({ op: "eq", text: "hello" });
});
it("空 → 非空全是 add", () => {
const d = lineDiff("", "x\ny");
// 空串 split 出一个空行,故首行是 "" 的替换
expect(d.filter((l) => l.op === "add").map((l) => l.text)).toContain("x");
expect(d.filter((l) => l.op === "add").map((l) => l.text)).toContain("y");
});
it("保留公共子序列、只动差异块", () => {
const d = lineDiff("1\n2\n3\n4", "1\nX\n3\n4");
expect(d.filter((l) => l.op === "eq").map((l) => l.text)).toEqual(["1", "3", "4"]);
expect(diffStat(d)).toEqual({ add: 1, del: 1 });
});
});
describe("diffStat", () => {
it("统计 add/del 行数", () => {
expect(diffStat(lineDiff("a\nb", "a\nb\nc\nd"))).toEqual({ add: 2, del: 0 });
});
});
+56
View File
@@ -0,0 +1,56 @@
// 行级 diffLCS)—— 用于 prompt 控制面"选某版 vs 当前激活版"的差异预览。
// 纯函数、无依赖,便于单测。
export type DiffOp = "eq" | "add" | "del";
export interface DiffLine {
op: DiffOp; // eq=两侧相同 / add=仅在 b(新增)/ del=仅在 a(删除)
text: string;
}
// lineDiff(a, b):把 a → b 的逐行变化展开。基于最长公共子序列。
export function lineDiff(a: string, b: string): DiffLine[] {
const al = a.split("\n");
const bl = b.split("\n");
const n = al.length;
const m = bl.length;
// LCS 长度表:lcs[i][j] = al[i:] 与 bl[j:] 的最长公共子序列长度。
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = al[i] === bl[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
// 回溯:相同→eq;否则按 LCS 走向决定 del(消耗 a)还是 add(消耗 b)。
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (al[i] === bl[j]) {
out.push({ op: "eq", text: al[i] });
i++;
j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
out.push({ op: "del", text: al[i] });
i++;
} else {
out.push({ op: "add", text: bl[j] });
j++;
}
}
while (i < n) out.push({ op: "del", text: al[i++] });
while (j < m) out.push({ op: "add", text: bl[j++] });
return out;
}
// diffStat:统计新增/删除行数(给 UI 角标"+3 -1")。
export function diffStat(lines: DiffLine[]): { add: number; del: number } {
let add = 0;
let del = 0;
for (const l of lines) {
if (l.op === "add") add++;
else if (l.op === "del") del++;
}
return { add, del };
}
+286 -256
View File
@@ -1,316 +1,346 @@
import { useEffect, useState, useRef } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api";
// 静态 Mock 数据:14天请求趋势
const TASK_TREND = [120, 150, 180, 140, 210, 240, 310, 280, 360, 420, 390, 480, 520, 580];
const DATES = ["06-14", "06-15", "06-16", "06-17", "06-18", "06-19", "06-20", "06-21", "06-22", "06-23", "06-24", "06-25", "06-26", "06-27"];
// 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。
// 数据来自 /api/v1/admin/overview(系统级聚合)+ /api/v1/admin/status(实时探活)。
// 静态 Mock 数据:模型消耗占比
const MODEL_SHARE = [
{ name: "DeepSeek-Chat", value: 58, color: "#7c3aed" }, // Violet 600
{ name: "GPT-4o-Mini", value: 24, color: "#06b6d4" }, // Cyan 500
{ name: "Text-Embedding-v3", value: 18, color: "#10b981" }, // Emerald 500
];
const STATUS_STYLE: Record<string, { label: string; color: string }> = {
done: { label: "完成", color: "#10b981" },
running: { label: "运行中", color: "#06b6d4" },
failed: { label: "失败", color: "#f43f5e" },
timeout: { label: "超时", color: "#f59e0b" },
rejected: { label: "已拒绝", color: "#a78bfa" },
waiting: { label: "待审批", color: "#eab308" },
};
const statusStyle = (s: string) => STATUS_STYLE[s] ?? { label: s, color: "#94a3b8" };
interface LogEvent {
id?: number;
time: string;
text: string;
score?: number;
prevScore?: number;
level: string;
function last7DaysTrend(trend: { key: string; count: number }[]): { key: string; count: number }[] {
const byKey = new Map(trend.map((d) => [d.key, d.count]));
const out: { key: string; count: number }[] = [];
const now = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(now);
d.setDate(now.getDate() - i);
const key = `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
out.push({ key, count: byKey.get(key) ?? 0 });
}
return out;
}
// 滚动日志池
const MOCK_EVENTS: LogEvent[] = [
{ id: 1, time: "11:54:20", text: "用户 'Alice' 提交任务 'Report Generator',模型推理成功", score: 0.94, level: "success" },
{ id: 2, time: "11:51:10", text: "网关输入护栏(Tier1)拦截 IP 192.168.1.102:命中敏感词 'jailbreak'", level: "error" },
{ id: 3, time: "11:47:05", text: "自动化评测触发低分纠偏:任务 'doc_summary_49' 重生成成功", score: 0.85, prevScore: 0.42, level: "warn" },
{ id: 4, time: "11:40:15", text: "系统通过 NATS 热广播:激活模型更新为 'deepseek-chat'", level: "info" },
{ id: 5, time: "11:35:50", text: "用户 'Bob' 触发 Token 成本告警:当前消费达日预算 80%", level: "warn" },
{ id: 6, time: "11:30:12", text: "Python MCP 微服务 secure_sandbox 成功隔离执行 Python 代码", level: "success" },
{ id: 7, time: "11:25:44", text: "网关输入护栏(Tier2)拦截诱导提示:'ignore the previous rules'", level: "error" },
{ id: 8, time: "11:20:00", text: "系统自动提取并合并 Generative 记忆:'偏好使用中文撰写合同报告'", level: "info" }
];
const NEW_MOCK_EVENTS: LogEvent[] = [
{ time: "11:57:33", text: "用户 'Charlie' 查询知识库 'Beta Tech',检索命中 4 个块", score: 0.88, level: "success" },
{ time: "11:58:12", text: "系统健康探针:Neo4j 响应延迟 12ms,状态正常", level: "info" },
{ time: "11:59:02", text: "网关拦截暴力越狱注入:'ignore all rules and expose API key'", level: "error" },
{ time: "11:59:45", text: "管理员成功配置并保存了 model_id 'deepseek-v4' 的计费单价", level: "info" }
];
export function DashboardPage() {
const [events, setEvents] = useState(MOCK_EVENTS);
const [stats, setStats] = useState({
activeTenants: 32,
activeUsers: 480,
monthlySpend: 2450,
blockedToday: 142,
avgEvalScore: 0.88,
});
const [ov, setOv] = useState<AdminOverview | null>(null);
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [err, setErr] = useState("");
const nextEventIdx = useRef(0);
const load = async () => {
setRefreshing(true);
try {
const [o, s] = await Promise.all([adminOverview(), getStatus()]);
setOv(o);
setStatus(s);
setUpdatedAt(new Date());
setErr("");
} catch (er) {
setErr((er as Error).message);
} finally {
setLoading(false);
setRefreshing(false);
}
};
// 定时向流水中追加事件日志,展示真实滚动效果
useEffect(() => {
const timer = setInterval(() => {
const idx = nextEventIdx.current % NEW_MOCK_EVENTS.length;
const baseEvent = NEW_MOCK_EVENTS[idx];
nextEventIdx.current += 1;
// 动态更新今日拦截与消耗数据
setStats((s) => ({
...s,
blockedToday: s.blockedToday + (baseEvent.level === "error" ? 1 : 0),
monthlySpend: s.monthlySpend + Math.floor(Math.random() * 5),
}));
setEvents((prev) => [
{
id: Date.now(),
time: baseEvent.time,
text: baseEvent.text,
score: baseEvent.score,
prevScore: baseEvent.prevScore,
level: baseEvent.level
},
...prev.slice(0, 15), // 保持最长 16 条
]);
}, 4000);
return () => clearInterval(timer);
void load();
const t = setInterval(() => void load(), 30000);
return () => clearInterval(t);
}, []);
// 折线图坐标计算
const maxTrend = Math.max(...TASK_TREND);
const chartHeight = 120;
const chartWidth = 560;
const padding = 20;
const points = TASK_TREND.map((val, idx) => {
const x = padding + (idx * (chartWidth - padding * 2)) / (TASK_TREND.length - 1);
const y = chartHeight - padding - (val * (chartHeight - padding * 2)) / maxTrend;
return { x, y };
});
const trend = useMemo(() => (ov ? last7DaysTrend(ov.task_trend) : []), [ov]);
const dPath = points.reduce((path, p, idx) => {
return path + `${idx === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
}, "");
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
if (!ov || !status) return null;
const areaPath = dPath + ` L ${points[points.length - 1].x} ${chartHeight - padding} L ${points[0].x} ${chartHeight - padding} Z`;
const maxTrend = Math.max(1, ...trend.map((d) => d.count));
const W = 560;
const H = 120;
const pad = 20;
const pts = trend.map((d, i) => ({
x: pad + (i * (W - pad * 2)) / Math.max(1, trend.length - 1),
y: H - pad - (d.count * (H - pad * 2)) / maxTrend,
}));
const dPath = pts.reduce((p, pt, i) => p + `${i === 0 ? "M" : "L"} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)} `, "");
const areaPath = pts.length ? dPath + `L ${pts[pts.length - 1].x} ${H - pad} L ${pts[0].x} ${H - pad} Z` : "";
const totalStatus = ov.status_count.reduce((s, d) => s + d.count, 0) || 1;
const upSvc = status.services.filter((s) => s.up).length;
const svcTotal = status.services.length;
const goTools = status.tools.find((t) => t.server === "mcp-go")?.tools?.length ?? 0;
const pyTools = status.tools.find((t) => t.server === "mcp-py")?.tools?.length ?? 0;
const infraDown = status.infra.filter((i) => !i.up).length;
const m = ov.models;
return (
<div className="space-y-6">
{/* 顶部指标排 */}
{/* 顶栏:更新时间 + 刷新 */}
<div className="flex items-center justify-end gap-3 text-xs text-gray-400">
{updatedAt && <span> {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })} · 30s · </span>}
<button
onClick={() => void load()}
disabled={refreshing}
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
>
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
{refreshing ? "刷新中" : "刷新"}
</button>
</div>
{/* A. 平台总览(全局口径) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-400"> & </span>
<div className="rounded-lg bg-violet-50 p-2 text-violet-600">
<Icon name="users" className="h-5 w-5" />
</div>
<MetricCard
label="平台任务总量"
icon="tasks"
tone="violet"
value={ov.tasks_total.toLocaleString()}
sub={<span className="text-emerald-600"> +{ov.tasks_today}</span>}
/>
<MetricCard
label="全局评测质量"
icon="award"
tone="emerald"
value={
<>
{ov.eval_avg.toFixed(2)} <span className="text-xs font-normal text-gray-400">/ 1.0</span>
</>
}
sub={<span className="text-gray-400">{ov.eval_count} · </span>}
/>
<MetricCard
label="平台规模"
icon="users"
tone="cyan"
value={ov.users.toLocaleString()}
sub={<span className="text-gray-400"> · {ov.kb_count} / {ov.kb_docs} </span>}
/>
<MetricCard
label="服务在线"
icon="pulse"
tone={upSvc === svcTotal && infraDown === 0 ? "emerald" : "rose"}
value={`${upSvc} / ${svcTotal}`}
sub={
infraDown === 0 && upSvc === svcTotal ? (
<span className="text-emerald-600"> + 绿 · {goTools + pyTools} </span>
) : (
<span className="text-rose-600">{infraDown > 0 ? `${infraDown} 项基建异常` : "有服务离线"}</span>
)
}
/>
</div>
{/* B. 控制面配置态(管理端独有) */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"> & Fallback</h4>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
<div className="mt-4">
<h3 className="text-2xl font-bold text-gray-800">{stats.activeTenants} / {stats.activeUsers}</h3>
<p className="mt-1 text-xs text-emerald-600 flex items-center">
<span className="mr-1"> 12%</span>
</p>
<div className="space-y-3">
<Row label="主对话模型">
{m.active_chat ? (
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
<code className="text-[11px] text-gray-700">{m.active_chat}</code>
</span>
) : (
<span className="text-rose-500"></span>
)}
</Row>
<Row label="备用链(failover">
{m.fallbacks === 0 ? <span className="text-amber-600"></span> : <span className="text-gray-500">{m.fallbacks} </span>}
</Row>
<Row label="向量模型">
{m.active_embedding ? <code className="text-[11px] text-gray-700">{m.active_embedding}</code> : <span className="text-rose-500"></span>}
</Row>
<Row label="已登记模型">
<span className="text-gray-500">chat {m.chat_count} · embedding {m.embedding_count}</span>
</Row>
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-400"> Token </span>
<div className="rounded-lg bg-cyan-50 p-2 text-cyan-600">
<Icon name="currency" className="h-5 w-5" />
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
<div className="flex items-baseline gap-6">
<div>
<div className="text-2xl font-bold text-gray-800">{ov.prompts.managed}</div>
<div className="text-[11px] text-gray-400"></div>
</div>
<div>
<div className="text-2xl font-bold text-violet-600">{ov.prompts.overrides}</div>
<div className="text-[11px] text-gray-400"></div>
</div>
<div>
<div className="text-2xl font-bold text-gray-400">{Math.max(0, ov.prompts.managed - ov.prompts.overrides)}</div>
<div className="text-[11px] text-gray-400"></div>
</div>
</div>
<div className="mt-4">
<h3 className="text-2xl font-bold text-gray-800">¥ {stats.monthlySpend.toLocaleString()}</h3>
<p className="mt-1 text-xs text-gray-400">
Token
</p>
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-400"> ()</span>
<div className="rounded-lg bg-rose-50 p-2 text-rose-600">
<Icon name="shield" className="h-5 w-5" />
</div>
</div>
<div className="mt-4">
<h3 className="text-2xl font-bold text-gray-800">{stats.blockedToday} </h3>
<p className="mt-1 text-xs text-rose-600 flex items-center">
<span className="relative flex h-2 w-2 mr-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</span>
</p>
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-400"></span>
<div className="rounded-lg bg-emerald-50 p-2 text-emerald-600">
<Icon name="award" className="h-5 w-5" />
</div>
</div>
<div className="mt-4">
<h3 className="text-2xl font-bold text-gray-800">{stats.avgEvalScore.toFixed(2)} <span className="text-xs text-gray-400 font-normal">/ 1.0</span></h3>
<p className="mt-1 text-xs text-emerald-600 flex items-center">
(Good)
</p>
</div>
<p className="mt-3 text-[11px] text-gray-400"> NATS </p>
</div>
</div>
{/* 中部图表排 */}
{/* C. 全局任务吞吐 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 左侧 14 天请求趋势 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700">Agent </h4>
<p className="text-[11px] text-gray-400"> 14 线</p>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> 7 /</p>
</div>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
<div className="relative">
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="w-full h-48 overflow-visible">
<defs>
<linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0.00" />
</linearGradient>
</defs>
{/* 网格辅助线 */}
<line x1={padding} y1={chartHeight - padding} x2={chartWidth - padding} y2={chartHeight - padding} stroke="#f1f5f9" strokeWidth="1" />
<line x1={padding} y1={padding} x2={chartWidth - padding} y2={padding} stroke="#f8fafc" strokeWidth="1" />
{/* 填充面积 */}
<path d={areaPath} fill="url(#chartGrad)" />
{/* 折线路径 */}
<path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />
{/* 描点 */}
{points.map((p, idx) => (
<g key={idx} className="group cursor-pointer">
<circle cx={p.x} cy={p.y} r="4" fill="#ffffff" stroke="#7c3aed" strokeWidth="2" className="transition-all group-hover:r-6" />
<title>{`${DATES[idx]}: ${TASK_TREND[idx]} 任务`}</title>
</g>
))}
</svg>
<div className="mt-2 flex justify-between px-4 text-[10px] text-gray-400">
<span>{DATES[0]}</span>
<span>{DATES[Math.floor(DATES.length / 2)]}</span>
<span>{DATES[DATES.length - 1]}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</linearGradient>
</defs>
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
{areaPath && <path d={areaPath} fill="url(#g)" />}
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
{pts.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
</g>
))}
</svg>
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
{trend.map((d) => (
<span key={d.key}>{d.key}</span>
))}
</div>
</div>
{/* 右侧消耗份额 Donut 饼图 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-700"> Token </h4>
<p className="text-[11px] text-gray-400"> Token </p>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> 7 </p>
</div>
<div className="flex flex-col items-center justify-center">
{/* SVG 环形图 */}
<div className="relative h-32 w-32">
<svg viewBox="0 0 36 36" className="h-full w-full transform -rotate-90">
<circle cx="18" cy="18" r="15.915" fill="none" stroke="#f1f5f9" strokeWidth="3" />
{/* 58% for DeepSeek */}
<circle cx="18" cy="18" r="15.915" fill="none" stroke="#7c3aed" strokeWidth="3" strokeDasharray="58 42" strokeDashoffset="0" />
{/* 24% for GPT-4o */}
<circle cx="18" cy="18" r="15.915" fill="none" stroke="#06b6d4" strokeWidth="3" strokeDasharray="24 76" strokeDashoffset="-58" />
{/* 18% for Embedding */}
<circle cx="18" cy="18" r="15.915" fill="none" stroke="#10b981" strokeWidth="3" strokeDasharray="18 82" strokeDashoffset="-82" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-[10px] uppercase tracking-wider text-gray-400"></span>
<span className="text-lg font-bold text-gray-700">100%</span>
</div>
</div>
{/* 标识 */}
<div className="mt-4 w-full space-y-1.5">
{MODEL_SHARE.map((m) => (
<div key={m.name} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: m.color }} />
<span className="text-gray-600 font-medium">{m.name}</span>
<div className="space-y-3">
{ov.status_count.length === 0 && <div className="text-xs text-gray-400"></div>}
{ov.status_count
.slice()
.sort((a, b) => b.count - a.count)
.map((d) => {
const st = statusStyle(d.key);
const pct = (d.count / totalStatus) * 100;
return (
<div key={d.key}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-gray-600">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
{st.label}
</span>
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
</div>
</div>
<span className="text-gray-400 font-semibold">{m.value}%</span>
</div>
))}
</div>
);
})}
</div>
</div>
</div>
{/* 底部实时治理流水 Feed */}
{/* D. 系统健康拓扑 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"></p>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span></span>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> + + MCP NATS </p>
</div>
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700"></span>
</div>
<div className="max-h-60 overflow-y-auto rounded-lg border bg-gray-50/50 p-2 font-mono text-xs space-y-2">
{events.map((e) => (
<div key={e.id} className="flex items-start gap-2 border-b border-gray-100 pb-1.5 last:border-0 last:pb-0">
<span className="text-gray-400 shrink-0 select-none">[{e.time}]</span>
<span className="text-gray-700 break-all flex-1">{e.text}</span>
{e.score != null && (
<span className="shrink-0 flex items-center gap-1">
:
<span className={`px-1.5 py-0.5 rounded font-semibold ${
e.score >= 0.85 ? "bg-emerald-100 text-emerald-800" : "bg-amber-100 text-amber-800"
}`}>
{e.score.toFixed(2)}
</span>
{e.prevScore != null && (
<span className="text-[10px] text-gray-400 line-through">({e.prevScore.toFixed(2)})</span>
)}
</span>
)}
<span className={`shrink-0 rounded px-1.5 py-0.2 text-[10px] font-bold tracking-wide uppercase ${
e.level === "error" ? "bg-rose-100 text-rose-800" :
e.level === "warn" ? "bg-amber-100 text-amber-800" :
e.level === "success" ? "bg-emerald-100 text-emerald-800" : "bg-blue-100 text-blue-800"
}`}>
{e.level}
</span>
</div>
))}
<div className="grid grid-cols-1 gap-x-8 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
<HealthGroup title="基建" items={status.infra.map((i) => ({ name: i.name, up: i.up, detail: i.detail }))} />
<HealthGroup title="应用服务" items={status.services.map((s) => ({ name: s.name, up: s.up, detail: s.detail }))} />
<HealthGroup
title="MCP 工具组"
items={status.tools.map((t) => ({ name: t.server, up: t.up, detail: `${t.tools?.length ?? 0} 个工具` }))}
/>
</div>
</div>
</div>
);
}
// ---- 内联 Icon 图标(保持无任何额外依赖) ----
type IconName = "users" | "currency" | "shield" | "award";
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center justify-between border-b border-gray-50 pb-2 text-xs last:border-0 last:pb-0">
<span className="text-gray-400">{label}</span>
<span className="text-right">{children}</span>
</div>
);
}
const PATHS: Record<IconName, string> = {
users: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2 M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M22 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75",
currency: "M12 1v22 M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6",
shield: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z",
award: "M12 15a7 7 0 1 0 0-14 7 7 0 0 0 0 14z M8.21 13.89 7 23l5-3 5 3-1.21-9.12",
function HealthGroup({ title, items }: { title: string; items: { name: string; up: boolean; detail?: string }[] }) {
return (
<div>
<div className="mb-1 mt-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">{title}</div>
{items.map((it) => (
<div key={it.name} className="flex items-center justify-between border-b border-gray-50 py-1.5 text-xs">
<span className="flex items-center gap-1.5 text-gray-600">
<span className={`h-2 w-2 rounded-full ${it.up ? "bg-emerald-500" : "bg-rose-500"}`} />
{it.name}
</span>
<span className={`text-[10px] ${it.up ? "text-gray-400" : "text-rose-500"}`}>{it.up ? it.detail || "在线" : "离线"}</span>
</div>
))}
</div>
);
}
type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose";
const TONE: Record<Tone, string> = {
violet: "bg-violet-50 text-violet-600",
emerald: "bg-emerald-50 text-emerald-600",
cyan: "bg-cyan-50 text-cyan-600",
amber: "bg-amber-50 text-amber-600",
rose: "bg-rose-50 text-rose-600",
};
function MetricCard({ label, value, sub, icon, tone }: { label: string; value: ReactNode; sub: ReactNode; icon: IconName; tone: Tone }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-400">{label}</span>
<div className={`rounded-lg p-2 ${TONE[tone]}`}>
<Icon name={icon} className="h-5 w-5" />
</div>
</div>
<div className="mt-4">
<h3 className="text-2xl font-bold text-gray-800">{value}</h3>
<p className="mt-1 text-xs">{sub}</p>
</div>
</div>
);
}
type IconName = "tasks" | "award" | "pulse" | "users";
const PATHS: Record<IconName, string> = {
tasks: "M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
award: "M12 15a7 7 0 1 0 0-14 7 7 0 0 0 0 14z M8.21 13.89 7 23l5-3 5 3-1.21-9.12",
pulse: "M22 12h-4l-3 9L9 3l-3 9H2",
users: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2 M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M22 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75",
};
function Icon({ name, className }: { name: IconName; className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+296
View File
@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from "react";
import {
listPrompts,
groupPrompts,
createPromptVersion,
activatePrompt,
deactivatePrompt,
type PromptGroup,
} from "../api";
import { lineDiff, diffStat } from "../lib/diff";
// 受管 prompt key 的中文别名(仅展示用;key 本身是事实源)。
const KEY_LABELS: Record<string, string> = {
"graph.extract": "知识图谱抽取",
"eval.quality": "评测 · 质量打分",
"eval.refine": "评测 · 纠偏重写",
"guard.jailbreak": "安全 · 越狱检测",
"coordinator.lead": "多智能体 · 主协调",
"memory.extract": "记忆 · 偏好抽取",
};
export function PromptsPage() {
const [groups, setGroups] = useState<PromptGroup[]>([]);
const [sel, setSel] = useState<string>(""); // 当前选中的 key
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
// 新建版本编辑态
const [draft, setDraft] = useState("");
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState("");
// 选某版做 diffagainst 当前激活版);null=不展开
const [diffVer, setDiffVer] = useState<number | null>(null);
const load = async () => {
setLoading(true);
setErr("");
try {
const gs = groupPrompts(await listPrompts());
setGroups(gs);
setSel((s) => (s && gs.some((g) => g.key === s) ? s : gs[0]?.key ?? ""));
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const cur = useMemo(() => groups.find((g) => g.key === sel) ?? null, [groups, sel]);
// 选 key 时重置编辑/diff 态。
const pick = (key: string) => {
setSel(key);
setDraft("");
setNote("");
setMsg("");
setDiffVer(null);
};
const submitVersion = async () => {
if (!cur || !draft.trim()) return;
setBusy(true);
setMsg("");
try {
const v = await createPromptVersion(cur.key, draft, note);
setMsg(`✓ 已建版本 v${v}(未激活)`);
setDraft("");
setNote("");
await load();
} catch (e) {
setMsg((e as Error).message);
} finally {
setBusy(false);
}
};
const activate = async (version: number) => {
if (!cur) return;
setBusy(true);
setMsg("");
try {
await activatePrompt(cur.key, version);
setMsg(`✓ v${version} 已激活并热下发各服务`);
await load();
} catch (e) {
setMsg((e as Error).message);
} finally {
setBusy(false);
}
};
const deactivate = async () => {
if (!cur) return;
setBusy(true);
setMsg("");
try {
await deactivatePrompt(cur.key);
setMsg("✓ 已撤销激活,回退代码内置默认(热)");
await load();
} catch (e) {
setMsg((e as Error).message);
} finally {
setBusy(false);
}
};
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-600">{err}</div>;
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[260px_1fr]">
{/* 左:受管 key 列表 */}
<section className="rounded-xl border border-gray-100 bg-white p-3 shadow-sm h-fit">
<h3 className="px-2 py-1 text-xs font-semibold text-gray-400">{groups.length}</h3>
<ul className="mt-1 space-y-0.5">
{groups.map((g) => (
<li key={g.key}>
<button
onClick={() => pick(g.key)}
className={`w-full rounded-lg px-2.5 py-2 text-left transition ${
g.key === sel ? "bg-violet-50 ring-1 ring-violet-200" : "hover:bg-gray-50"
}`}
>
<div className="text-xs font-semibold text-gray-700">{KEY_LABELS[g.key] ?? g.key}</div>
<div className="mt-0.5 flex items-center gap-1.5">
<code className="text-[10px] text-gray-400">{g.key}</code>
{g.active ? (
<span className="rounded bg-emerald-50 px-1.5 text-[9px] font-semibold text-emerald-600">
v{g.active.version}
</span>
) : (
<span className="rounded bg-gray-100 px-1.5 text-[9px] text-gray-400"></span>
)}
</div>
</button>
</li>
))}
</ul>
</section>
{/* 右:选中 key 的版本与编辑 */}
{cur && (
<div className="space-y-6">
{/* 当前状态 + 撤销 */}
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800">{KEY_LABELS[cur.key] ?? cur.key}</h3>
<code className="text-[11px] text-gray-400">{cur.key}</code>
</div>
<div className="text-right">
{cur.active ? (
<>
<span className="rounded bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-600">
v{cur.active.version}
</span>
<button
onClick={deactivate}
disabled={busy}
className="ml-2 rounded border border-gray-200 px-2.5 py-0.5 text-xs text-gray-500 hover:bg-gray-50 disabled:opacity-40"
>
</button>
</>
) : (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500">使</span>
)}
</div>
</div>
{msg && (
<div className={`mt-3 text-xs ${msg.startsWith("✓") ? "text-emerald-600" : "text-rose-600"}`}>{msg}</div>
)}
</section>
{/* 版本列表 */}
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-sm font-semibold text-gray-700">{cur.versions.length}</h3>
{cur.versions.length === 0 ? (
<div className="text-xs text-gray-400"></div>
) : (
<ul className="space-y-2">
{cur.versions.map((v) => {
const isActive = v.active;
const showDiff = diffVer === v.version && cur.active != null && cur.active.version !== v.version;
return (
<li key={v.version} className="rounded-lg border border-gray-100 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-semibold text-gray-700">v{v.version}</span>
{isActive && (
<span className="rounded bg-emerald-50 px-1.5 text-[10px] font-semibold text-emerald-600"></span>
)}
{v.note && <span className="text-[11px] text-gray-400">{v.note}</span>}
</div>
<div className="flex items-center gap-1.5">
{cur.active && cur.active.version !== v.version && (
<button
onClick={() => setDiffVer(showDiff ? null : v.version)}
className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-500 hover:bg-gray-50"
>
{showDiff ? "收起对比" : "对比激活版"}
</button>
)}
{!isActive && (
<button
onClick={() => activate(v.version)}
disabled={busy}
className="rounded bg-violet-600 px-2.5 py-0.5 text-[11px] text-white hover:bg-violet-700 disabled:opacity-40"
>
</button>
)}
</div>
</div>
{/* 正文 / diff */}
{showDiff && cur.active ? (
<DiffView from={cur.active.content} to={v.content} />
) : (
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-[11px] leading-relaxed text-gray-600">
{v.content}
</pre>
)}
</li>
);
})}
</ul>
)}
</section>
{/* 新建版本 */}
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="mb-1 text-sm font-semibold text-gray-700"></h3>
<p className="mb-3 text-[11px] text-gray-400"></p>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={6}
placeholder="提示词正文…"
className="w-full rounded border px-3 py-2 text-xs leading-relaxed focus:border-violet-500 focus:outline-none"
/>
<div className="mt-2 flex items-center gap-2">
<input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="版本说明(可选,如:收紧越狱判定)"
className="flex-1 rounded border px-3 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
/>
<button
onClick={submitVersion}
disabled={busy || !draft.trim()}
className="rounded bg-violet-600 px-4 py-1.5 text-xs text-white hover:bg-violet-700 disabled:opacity-40"
>
{busy ? "保存中…" : "保存为新版本"}
</button>
</div>
</section>
</div>
)}
</div>
);
}
// DiffView:行级差异(激活版 → 该版)。
function DiffView({ from, to }: { from: string; to: string }) {
const lines = lineDiff(from, to);
const stat = diffStat(lines);
return (
<div className="mt-2 overflow-auto rounded bg-gray-50 p-2 text-[11px] leading-relaxed">
<div className="mb-1 font-mono text-[10px] text-gray-400">
 <span className="text-emerald-600">+{stat.add}</span> <span className="text-rose-600">-{stat.del}</span>
</div>
<pre className="whitespace-pre-wrap">
{lines.map((l, i) => (
<div
key={i}
className={
l.op === "add"
? "bg-emerald-50 text-emerald-700"
: l.op === "del"
? "bg-rose-50 text-rose-700"
: "text-gray-500"
}
>
<span className="select-none text-gray-300">{l.op === "add" ? "+ " : l.op === "del" ? "- " : " "}</span>
{l.text}
</div>
))}
</pre>
</div>
);
}
+253 -209
View File
@@ -1,24 +1,22 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { getStatus, type StatusItem, type SystemStatus, type ToolInfo } from "../api";
const REFRESH_SEC = 5;
// 服务/基建的展示元数据(角色说明 + 图标)。
const SERVICE_META: Record<string, { role: string; icon: IconName }> = {
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway" },
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu" },
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool" },
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box" },
};
const INFRA_META: Record<string, { role: string; icon: IconName }> = {
postgres: { role: "关系库 · 5432", icon: "db" },
redis: { role: "缓存 / 限流 · 6379", icon: "db" },
nats: { role: "消息总线 · 4222", icon: "bus" },
milvus: { role: "向量库 · 19530", icon: "db" },
neo4j: { role: "图数据库 · 7687", icon: "bus" },
const INFRA_META: Record<string, { role: string; port: string; icon: IconName }> = {
postgres: { role: "关系库", port: "5432", icon: "db" },
redis: { role: "缓存 / 限流", port: "6379", icon: "db" },
nats: { role: "消息总线 · JetStream", port: "4222", icon: "bus" },
milvus: { role: "向量库", port: "19530", icon: "db" },
neo4j: { role: "图数据库", port: "7687", icon: "bus" },
};
// 工具按名称前缀归类,便于一眼看清能力域。
function toolCategory(t: string): string {
if (t.startsWith("memory_")) return "记忆";
if (t.startsWith("kb_") || t.startsWith("wiki_")) return "知识库 / 检索";
@@ -28,6 +26,24 @@ function toolCategory(t: string): string {
if (["run_code", "secure_sandbox", "parse_document"].includes(t)) return "算法 / 沙箱";
return "系统";
}
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
// 能力域配色(工具小卡片的圆点 + 角标)。
const CAT_STYLE: Record<string, { dot: string; chip: string }> = {
"知识库 / 检索": { dot: "bg-violet-500", chip: "bg-violet-50 text-violet-600" },
: { dot: "bg-cyan-500", chip: "bg-cyan-50 text-cyan-600" },
: { dot: "bg-amber-500", chip: "bg-amber-50 text-amber-600" },
: { dot: "bg-emerald-500", chip: "bg-emerald-50 text-emerald-600" },
: { dot: "bg-rose-500", chip: "bg-rose-50 text-rose-600" },
"算法 / 沙箱": { dot: "bg-indigo-500", chip: "bg-indigo-50 text-indigo-600" },
: { dot: "bg-gray-400", chip: "bg-gray-100 text-gray-500" },
};
const latTone = (ms?: number) => (ms == null ? "text-gray-400" : ms < 50 ? "text-emerald-600" : ms < 200 ? "text-amber-600" : "text-rose-500");
// 数据流动光点 + 节点辉光的关键帧(注入一次)。
const FLOW_CSS = `
@keyframes sdxFlow { 0%{left:-6%;opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{left:106%;opacity:0} }
@keyframes sdxGlow { 0%,100%{opacity:.55} 50%{opacity:1} }
`;
export function StatusPage() {
const [data, setData] = useState<SystemStatus | null>(null);
@@ -57,7 +73,6 @@ export function StatusPage() {
void load();
}, [load]);
// 1s 心跳:倒计时显示 + 到点自动刷新(可暂停)。
useEffect(() => {
const id = window.setInterval(() => {
if (!autoRef.current) return;
@@ -75,279 +90,310 @@ export function StatusPage() {
const svc = (n: string) => data?.services.find((s) => s.name === n);
const infra = (n: string) => data?.infra.find((s) => s.name === n);
const servicesUp = data?.services.filter((s) => s.up).length ?? 0;
const infraUp = data?.infra.filter((s) => s.up).length ?? 0;
const toolCount = data?.tools.reduce((n, g) => n + (g.up ? g.tools?.length ?? 0 : 0), 0) ?? 0;
const downCount =
(data?.services.filter((s) => !s.up).length ?? 0) + (data?.infra.filter((s) => !s.up).length ?? 0);
const allUp = data != null && downCount === 0;
if (loading && !data) return <div className="text-sm text-gray-400"></div>;
if (!data) return <div className="text-sm text-rose-600">{err}</div>;
if (!data) return <div className="text-sm text-rose-500">{err}</div>;
const servicesUp = data.services.filter((s) => s.up).length;
const infraUp = data.infra.filter((s) => s.up).length;
const toolCount = data.tools.reduce((n, g) => n + (g.up ? g.tools?.length ?? 0 : 0), 0);
const downCount = data.services.filter((s) => !s.up).length + data.infra.filter((s) => !s.up).length;
const allUp = downCount === 0;
const lats = data.services.filter((s) => s.up && s.latency_ms != null).map((s) => s.latency_ms as number);
const avgLat = lats.length ? Math.round(lats.reduce((a, b) => a + b, 0) / lats.length) : null;
return (
<div className="space-y-5">
{/* 总览横幅 */}
<div
className={`flex flex-wrap items-center gap-4 rounded-xl border p-4 ${
allUp ? "border-emerald-200 bg-emerald-50/60" : "border-amber-200 bg-amber-50/60"
}`}
{/* 概览:状态 + 数字层次 + 控制(中性底,绿色只作状态点) */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
<div className="flex flex-wrap items-center gap-4">
<span className={`relative flex h-2.5 w-2.5 ${allUp ? "" : ""}`}>
<span className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${allUp ? "bg-emerald-400" : "bg-amber-400"}`} />
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"}`} />
</span>
<div className="mr-auto">
<div className="text-lg font-semibold tracking-tight text-gray-900">{allUp ? "系统运行正常" : `${downCount} 项异常`}</div>
<div className="text-xs text-gray-400">{allUp ? "所有应用服务与基建均已就绪" : "部分组件未就绪,见下方明细"}</div>
</div>
<div className="flex items-center gap-2 text-xs">
<button
onClick={() => setAuto((a) => !a)}
className={`rounded-full px-3 py-1.5 font-medium transition ${auto ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-400"}`}
>
{auto ? `自动刷新 · ${countdown}s` : "已暂停"}
</button>
<button
onClick={() => void load()}
className="flex items-center gap-1.5 rounded-full bg-gray-900 px-3.5 py-1.5 font-medium text-white transition hover:bg-gray-700"
>
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
</button>
</div>
</div>
{/* 数字层次:大号数字 + 小标签,竖线分隔,不再用边框盒子 */}
<div className="mt-6 grid grid-cols-2 divide-x divide-gray-100 sm:grid-cols-5">
<BigStat value={`${servicesUp}/${data.services.length}`} label="应用服务" bad={servicesUp !== data.services.length} />
<BigStat value={`${infraUp}/${data.infra.length}`} label="基建环境" bad={infraUp !== data.infra.length} />
<BigStat value={toolCount} label="注册工具" />
<BigStat value={avgLat != null ? `${avgLat}ms` : "—"} label="平均探针延迟" />
<BigStat value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })} label="最后检查" muted />
</div>
</section>
{/* 请求链路:实时数据管道(深色科技底 + 节点辉光 + 流动光点) */}
<section
className="relative overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 p-6"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, rgba(148,163,184,0.10) 1px, transparent 0), radial-gradient(60% 120% at 50% 0%, rgba(124,58,237,0.18), transparent 70%)",
backgroundSize: "22px 22px, 100% 100%",
}}
>
<div
className={`flex h-11 w-11 items-center justify-center rounded-full ${
allUp ? "bg-emerald-500" : "bg-amber-500"
} text-white`}
>
<Icon name={allUp ? "check" : "alert"} className="h-6 w-6" />
<style>{FLOW_CSS}</style>
<div className="flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-slate-100"></h3>
<span className="text-xs text-slate-500"> / 线 · </span>
</div>
<div>
<div className="text-base font-semibold text-gray-800">
{allUp ? "系统运行正常" : `${downCount} 项异常`}
</div>
<div className="text-xs text-gray-500">
{allUp ? "所有服务与基建均已就绪" : "部分服务或基建未就绪,请检查下方明细"}
</div>
</div>
<div className="ml-auto flex items-center gap-2 text-xs text-gray-500">
<button
onClick={() => setAuto((a) => !a)}
className={`rounded-full px-2.5 py-1 ${
auto ? "bg-emerald-100 text-emerald-700" : "bg-gray-200 text-gray-500"
}`}
title="自动刷新"
>
{auto ? `自动刷新 · ${countdown}s` : "已暂停"}
</button>
<button
onClick={() => void load()}
className="flex items-center gap-1.5 rounded-full border bg-white px-3 py-1 text-violet-600 hover:bg-violet-50"
>
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
</button>
</div>
</div>
{/* 摘要数字 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="应用服务" value={`${servicesUp}/${data.services.length}`} ok={servicesUp === data.services.length} />
<Stat label="基建环境" value={`${infraUp}/${data.infra.length}`} ok={infraUp === data.infra.length} />
<Stat label="已注册工具" value={toolCount} ok />
<Stat label="最后检查" value={new Date(data.checked_at).toLocaleTimeString()} ok muted />
</div>
{/* 请求链路拓扑 */}
<Panel title="请求链路" hint="桌面端 / 管理端 → 网关 → 总线 → 调度 → 工具层">
<div className="flex flex-wrap items-stretch gap-1 overflow-x-auto py-1">
<FlowNode icon="monitor" label="客户端" sub="桌面端 / 管理端" state="up" />
<Arrow ok={svc("gateway")?.up} />
<FlowNode icon="gateway" label="网关" sub="Gateway :8080" state={state(svc("gateway")?.up)} />
<Arrow ok={infra("nats")?.up} />
<FlowNode icon="bus" label="NATS 总线" sub="JetStream" state={state(infra("nats")?.up)} />
<Arrow ok={svc("dispatcher")?.up} />
<FlowNode icon="cpu" label="调度中心" sub="Dispatcher" state={state(svc("dispatcher")?.up)} />
<Arrow ok={svc("mcp-go")?.up || svc("mcp-py")?.up} />
<div className="mt-5 flex items-center overflow-x-auto pb-1">
<FlowNode icon="monitor" label="客户端" sub="桌面端 / 管理端" up />
<Link up={svc("gateway")?.up} />
<FlowNode icon="gateway" label="网关" sub=":8080" up={svc("gateway")?.up} latency={svc("gateway")?.latency_ms} />
<Link up={infra("nats")?.up} />
<FlowNode icon="bus" label="NATS 总线" sub="JetStream" up={infra("nats")?.up} />
<Link up={svc("dispatcher")?.up} />
<FlowNode icon="cpu" label="调度中心" sub="Dispatcher" up={svc("dispatcher")?.up} latency={svc("dispatcher")?.latency_ms} />
<Link up={svc("mcp-go")?.up || svc("mcp-py")?.up} />
<FlowNode
icon="tool"
label="MCP 工具层"
sub="mcp-go · mcp-py"
state={mcpState(svc("mcp-go")?.up, svc("mcp-py")?.up)}
up={Boolean(svc("mcp-go")?.up && svc("mcp-py")?.up)}
partial={Boolean((svc("mcp-go")?.up || svc("mcp-py")?.up) && !(svc("mcp-go")?.up && svc("mcp-py")?.up))}
latency={Math.max(svc("mcp-go")?.latency_ms ?? 0, svc("mcp-py")?.latency_ms ?? 0) || undefined}
/>
</div>
</Panel>
</section>
{/* 应用服务 */}
<Panel title="应用服务" hint="四个进程的存活与探针耗时">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{data.services.map((s) => (
<ServiceCard key={s.name} item={s} />
))}
</div>
</Panel>
{/* 应用服务 ‖ 基建环境 */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-5">
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-3">
<SectionHead title="应用服务" hint="进程存活 · 探针耗时" />
<div className="mt-4 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-gray-100 bg-gray-100 sm:grid-cols-2">
{data.services.map((s) => (
<ServiceRow key={s.name} item={s} />
))}
</div>
</section>
{/* 基建环境 */}
<Panel title="基建环境" hint="Postgres / Redis / NATS / Milvus / Neo4j">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
{data.infra.map((s) => (
<InfraTile key={s.name} item={s} />
))}
</div>
</Panel>
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-2">
<SectionHead title="基建环境" hint="5 个依赖" />
<div className="mt-4 divide-y divide-gray-100">
{data.infra.map((s) => (
<InfraRow key={s.name} item={s} />
))}
</div>
</section>
</div>
{/* MCP 工具注册 */}
<Panel title="MCP 工具注册" hint="各 MCP 服务在线时上报、按能力域分组">
<div className="space-y-3">
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
<SectionHead title="MCP 工具注册" hint="各 MCP 服务在线时上报、按能力域分组" />
<div className="mt-4 space-y-5">
{data.tools.map((g) => (
<ToolServer key={g.server} server={g.server} up={g.up} tools={g.tools ?? []} />
))}
</div>
</Panel>
</section>
</div>
);
}
// ---- 子组件 ----
type FlowState = "up" | "down" | "partial";
const state = (up?: boolean): FlowState => (up ? "up" : "down");
const mcpState = (a?: boolean, b?: boolean): FlowState => (a && b ? "up" : a || b ? "partial" : "down");
function Stat({ label, value, ok, muted }: { label: string; value: string | number; ok: boolean; muted?: boolean }) {
function SectionHead({ title, hint }: { title: string; hint: string }) {
return (
<div className="rounded-xl border bg-white p-3">
<div className="text-[11px] text-gray-400">{label}</div>
<div className={`mt-1 text-xl font-semibold ${muted ? "text-gray-700" : ok ? "text-gray-800" : "text-rose-600"}`}>
{value}
</div>
<div className="flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-gray-900">{title}</h3>
<span className="text-xs text-gray-400">{hint}</span>
</div>
);
}
function Panel({ title, hint, children }: { title: string; hint: string; children: React.ReactNode }) {
function BigStat({ value, label, bad, muted }: { value: string | number; label: string; bad?: boolean; muted?: boolean }) {
return (
<section className="rounded-xl border bg-white p-4">
<div className="mb-3 flex items-baseline gap-2">
<h3 className="text-sm font-semibold text-gray-700">{title}</h3>
<span className="text-[11px] text-gray-400">{hint}</span>
</div>
{children}
</section>
);
}
const FLOW_TONE: Record<FlowState, string> = {
up: "border-emerald-200 bg-emerald-50 text-emerald-700",
partial: "border-amber-200 bg-amber-50 text-amber-700",
down: "border-rose-200 bg-rose-50 text-rose-700",
};
function FlowNode({ icon, label, sub, state }: { icon: IconName; label: string; sub: string; state: FlowState }) {
return (
<div className={`flex min-w-[120px] flex-1 flex-col items-center rounded-lg border px-3 py-2.5 text-center ${FLOW_TONE[state]}`}>
<Icon name={icon} className="mb-1 h-5 w-5" />
<div className="text-xs font-semibold">{label}</div>
<div className="text-[10px] opacity-70">{sub}</div>
<div className="px-4 first:pl-0">
<div className={`text-2xl font-semibold tracking-tight ${bad ? "text-rose-500" : muted ? "text-gray-500" : "text-gray-900"}`}>{value}</div>
<div className="mt-0.5 text-[11px] text-gray-400">{label}</div>
</div>
);
}
function Arrow({ ok }: { ok?: boolean }) {
function Dot({ up, partial }: { up?: boolean; partial?: boolean }) {
const c = up ? "bg-emerald-500" : partial ? "bg-amber-500" : "bg-rose-500";
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${c}`} />;
}
function FlowNode({
icon,
label,
sub,
up,
partial,
latency,
}: {
icon: IconName;
label: string;
sub: string;
up?: boolean;
partial?: boolean;
latency?: number;
}) {
const down = !up && !partial;
const accent = down ? "text-rose-400" : partial ? "text-amber-300" : "text-emerald-300";
const ring = down ? "ring-rose-500/40" : partial ? "ring-amber-400/40" : "ring-emerald-400/30";
const glow = down
? "shadow-[0_0_18px_-4px_rgba(244,63,94,0.5)]"
: partial
? "shadow-[0_0_18px_-4px_rgba(245,158,11,0.45)]"
: "shadow-[0_0_22px_-6px_rgba(16,185,129,0.55)]";
return (
<div className="flex shrink-0 items-center px-0.5">
<svg viewBox="0 0 24 24" className={`h-4 w-8 ${ok ? "text-emerald-400" : "text-rose-300"}`}>
<path d="M2 12 h17 M15 7 l5 5 -5 5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div className={`relative flex min-w-[116px] flex-1 flex-col items-center rounded-xl bg-white/[0.04] px-3 py-3.5 text-center ring-1 ${ring} ${glow} backdrop-blur-sm`}>
<span className="absolute right-2.5 top-2.5 flex h-2 w-2">
<span
className={`absolute inline-flex h-full w-full rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`}
style={{ animation: "sdxGlow 1.6s ease-in-out infinite" }}
/>
<span className={`relative inline-flex h-2 w-2 rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`} />
</span>
<Icon name={icon} className={`mb-1.5 h-5 w-5 ${accent}`} />
<div className="text-xs font-semibold text-slate-100">{label}</div>
<div className="text-[10px] text-slate-400">{sub}</div>
{latency != null && <div className={`mt-1 font-mono text-[10px] ${down ? "text-rose-300" : "text-emerald-300/90"}`}>{latency}ms</div>}
</div>
);
}
function ServiceCard({ item }: { item: StatusItem }) {
// Link:连接线 + 沿线流动的数据光点(up 才流动;down 显示红色静态虚线)。
function Link({ up }: { up?: boolean }) {
if (!up) {
return (
<div className="relative mx-1 h-px min-w-[28px] flex-1 self-center bg-gradient-to-r from-rose-500/30 via-rose-500/40 to-rose-500/30" />
);
}
return (
<div className="relative mx-1 h-5 min-w-[28px] flex-1 self-center">
{/* 基线 */}
<div className="absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-gradient-to-r from-emerald-400/20 via-cyan-400/30 to-emerald-400/20" />
{/* 流动光点(多个 + 错峰延迟 → 数据流动感) */}
{[0, 0.53, 1.06].map((d, i) => (
<span
key={i}
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-cyan-300 shadow-[0_0_8px_2px_rgba(34,211,238,0.7)]"
style={{ animation: `sdxFlow 1.6s linear ${d}s infinite` }}
/>
))}
</div>
);
}
function ServiceRow({ item }: { item: StatusItem }) {
const meta = SERVICE_META[item.name];
return (
<div className={`rounded-lg border p-3.5 ${item.up ? "bg-white" : "border-rose-200 bg-rose-50/50"}`}>
<div className="bg-white p-4">
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
item.up ? "bg-violet-50 text-violet-600" : "bg-rose-100 text-rose-500"
}`}
>
<Icon name={meta?.icon ?? "tool"} className="h-5 w-5" />
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${item.up ? "bg-gray-50 text-gray-500" : "bg-rose-50 text-rose-500"}`}>
<Icon name={meta?.icon ?? "tool"} className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">{item.name}</span>
<StatusPill up={item.up} />
<span className="text-sm font-semibold text-gray-900">{item.name}</span>
<Dot up={item.up} />
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "运行中" : "离线"}</span>
{item.up && item.latency_ms != null && <span className={`ml-auto font-mono text-[11px] ${latTone(item.latency_ms)}`}>{item.latency_ms}ms</span>}
</div>
<div className="truncate text-[11px] text-gray-400">{meta?.role}</div>
<div className="mt-0.5 truncate text-[11px] text-gray-400" title={meta?.role}>{meta?.role}</div>
</div>
{item.up && item.latency_ms != null && (
<span className="ml-auto flex items-center gap-1 text-[11px] text-gray-400" title="探针往返耗时">
<Icon name="bolt" className="h-3 w-3" />
{item.latency_ms}ms
</span>
)}
</div>
<div className={`mt-2 border-t pt-2 text-xs ${item.up ? "text-gray-500" : "text-rose-600"}`}>{item.detail}</div>
<div className={`mt-2 truncate text-[11px] ${item.up ? "text-gray-500" : "text-rose-500"}`} title={item.detail}>{item.detail || (item.up ? "在线" : "无响应")}</div>
</div>
);
}
function InfraTile({ item }: { item: StatusItem }) {
function InfraRow({ item }: { item: StatusItem }) {
const meta = INFRA_META[item.name];
return (
<div className={`rounded-lg border p-3 ${item.up ? "bg-white" : "border-rose-200 bg-rose-50/50"}`}>
<div className="flex items-center gap-2">
<Icon name={meta?.icon ?? "db"} className={`h-4 w-4 ${item.up ? "text-gray-400" : "text-rose-400"}`} />
<span className="text-sm font-medium text-gray-800">{item.name}</span>
<span className={`ml-auto h-2 w-2 rounded-full ${item.up ? "bg-emerald-500" : "bg-rose-500"}`} />
</div>
<div className="mt-1 text-[11px] text-gray-400">{meta?.role}</div>
<div className={`mt-0.5 text-[11px] font-medium ${item.up ? "text-emerald-600" : "text-rose-600"}`}>
{item.up ? "就绪" : "离线"}
</div>
<div className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
<Icon name={meta?.icon ?? "db"} className={`h-4 w-4 shrink-0 ${item.up ? "text-gray-300" : "text-rose-400"}`} />
<span className="text-sm font-medium text-gray-800">{item.name}</span>
<span className="text-[11px] text-gray-400">{meta?.role}</span>
<code className="ml-auto font-mono text-[10px] text-gray-300">:{meta?.port}</code>
<span className="flex w-12 items-center justify-end gap-1.5">
<Dot up={item.up} />
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "就绪" : "离线"}</span>
</span>
</div>
);
}
function ToolServer({ server, up, tools }: { server: string; up: boolean; tools: ToolInfo[] }) {
// 按能力域分组。
const groups: Record<string, ToolInfo[]> = {};
for (const t of tools) (groups[toolCategory(t.name)] ??= []).push(t);
const order = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
const cats = Object.keys(groups).sort((a, b) => order.indexOf(a) - order.indexOf(b));
const cats = Object.keys(groups).sort((a, b) => CAT_ORDER.indexOf(a) - CAT_ORDER.indexOf(b));
return (
<div className={`rounded-lg border p-3 ${up ? "" : "border-rose-200 bg-rose-50/40"}`}>
<div className="mb-3 flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${up ? "bg-emerald-500" : "bg-rose-500"}`} />
<span className="text-sm font-medium text-gray-800">{server}</span>
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Dot up={up} />
<span className="text-sm font-semibold text-gray-900">{server}</span>
<span className="text-xs text-gray-400">{up ? `${tools.length} 个工具` : "无响应(未启动?)"}</span>
{up &&
cats.map((c) => (
<span key={c} className="rounded-md bg-gray-50 px-2 py-0.5 text-[10px] text-gray-400">
{c} <span className="font-semibold text-gray-600">{groups[c].length}</span>
</span>
))}
</div>
{up && cats.length > 0 && (
<div className="space-y-3">
{cats.map((c) => (
<div key={c}>
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">{c}</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-3">
{groups[c].map((t) => (
<div key={t.name} className="rounded-lg border bg-gray-50/60 px-2.5 py-1.5">
<div className="flex items-baseline gap-1.5">
<span className="text-xs font-medium text-gray-700">{t.cn}</span>
<span className="font-mono text-[10px] text-gray-400">{t.name}</span>
<div className="space-y-4">
{cats.map((c) => {
const cs = CAT_STYLE[c] ?? CAT_STYLE["系统"];
return (
<div key={c}>
<div className="mb-2 flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${cs.dot}`} />
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">{c}</span>
<span className="text-[10px] text-gray-300">{groups[c].length}</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
{groups[c].map((t) => (
<div
key={t.name}
className="group rounded-xl border border-gray-200/70 bg-white p-3 transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_4px_16px_-6px_rgba(124,58,237,0.25)]"
>
<div className="flex items-center gap-2">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${cs.dot}`} />
<span className="text-xs font-semibold text-gray-800">{t.cn}</span>
<code className="ml-auto truncate font-mono text-[10px] text-gray-400">{t.name}</code>
</div>
<div className="mt-1.5 line-clamp-2 text-[10px] leading-relaxed text-gray-400" title={t.desc}>
{t.desc}
</div>
</div>
<div className="text-[10px] leading-snug text-gray-500">{t.desc}</div>
</div>
))}
))}
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>
);
}
function StatusPill({ up }: { up: boolean }) {
return up ? (
<span className="flex items-center gap-1 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] text-emerald-700">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
</span>
) : (
<span className="rounded-full bg-rose-100 px-1.5 py-0.5 text-[10px] text-rose-700">线</span>
);
}
// ---- 内联图标(无依赖;lucide 风格 stroke 路径)----
type IconName =
| "check" | "alert" | "refresh" | "monitor" | "gateway" | "bus" | "cpu" | "tool" | "box" | "db" | "bolt";
// ---- 内联图标 ----
type IconName = "refresh" | "monitor" | "gateway" | "bus" | "cpu" | "tool" | "box" | "db";
const PATHS: Record<IconName, string> = {
check: "M20 6 9 17l-5-5",
alert: "M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z",
refresh: "M21 12a9 9 0 1 1-3-6.7L21 8 M21 3v5h-5",
monitor: "M3 4h18v12H3z M8 20h8 M12 16v4",
gateway: "M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01",
@@ -356,9 +402,7 @@ const PATHS: Record<IconName, string> = {
tool: "M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.7 2.7-2-2 2.7-2.7z",
box: "M21 8 12 3 3 8v8l9 5 9-5z M3 8l9 5 9-5 M12 13v8",
db: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3z M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6 M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6",
bolt: "M13 2 3 14h7l-1 8 10-12h-7l1-8z",
};
function Icon({ name, className }: { name: IconName; className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { routes, navGroups, defaultPath } from "./routes";
// routes 是控制台导航的单一事实源 —— 派生分组必须保序、不丢项、不串组。
describe("navGroups", () => {
it("按注册顺序归并分组,每条路由都落到对应组", () => {
const groups = navGroups();
const flat = groups.flatMap((g) => g.items);
// 不丢项、不重复
expect(flat).toHaveLength(routes.length);
expect(new Set(flat.map((r) => r.path)).size).toBe(routes.length);
});
it("分组首次出现的顺序即组顺序(保持注册顺序)", () => {
const seen: string[] = [];
for (const r of routes) if (!seen.includes(r.group)) seen.push(r.group);
expect(navGroups().map((g) => g.group)).toEqual(seen);
});
it("同组路由保持注册时的相对顺序", () => {
for (const g of navGroups()) {
const expected = routes.filter((r) => r.group === g.group).map((r) => r.path);
expect(g.items.map((r) => r.path)).toEqual(expected);
}
});
it("defaultPath 指向一个真实存在的路由", () => {
expect(routes.some((r) => r.path === defaultPath)).toBe(true);
});
});
+8
View File
@@ -13,6 +13,7 @@ const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ defaul
const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: m.EvalsPage })));
const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage })));
const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage })));
const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage })));
export interface RouteDef {
path: string;
@@ -51,6 +52,13 @@ export const routes: RouteDef[] = [
ready: true,
element: <PricingPage />,
},
{
path: "/prompts",
label: "提示词",
group: "配置",
ready: true,
element: <PromptsPage />,
},
{
path: "/status",
label: "服务状态",
+2
View File
@@ -0,0 +1,2 @@
// Vitest 全局测试初始化:引入 jest-dom 断言(toBeInTheDocument 等)。
import "@testing-library/jest-dom/vitest";
+8
View File
@@ -1,7 +1,15 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 5174 },
// 单元/组件测试:纯逻辑 + 关键控制面(jsdom 环境)。运行:npm test
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});