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:
Generated
+2420
-1
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("guard:401 清令牌并广播登出", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// 行级 diff(LCS)—— 用于 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 };
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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("");
|
||||
|
||||
// 选某版做 diff(against 当前激活版);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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: "服务状态",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Vitest 全局测试初始化:引入 jest-dom 断言(toBeInTheDocument 等)。
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -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}"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user