Files
Cyrene_v2/frontend/web/src/admin/modules/System.tsx
T
AskaEth 34cef95e17 feat(gateway): 配置只读事实面 + 后台思考行「档位/穿透」(契约 v0.68)
- 新增 GET /api/v1/admin/config-facts:记忆整理 7 键 + 安静时段 2 键的**进程生效值** + 声明默认值(反射读 default 标签,不读 .env)
- 面板:记忆整理卡 + 安静时段卡(Miuix 只读),文案按新语义「只服务主动搭话,不静默回复」
- 后台思考行加 urgency(**只能是下限**,存档不可分 high/critical)+ quietHoursBypassed(与既有 bypassed 是两件事)
- 红线实测:往响应里塞 JWT ⇒ TestAdminConfigFacts_NeverLeaksSecretValues 打红(连断言消息都把整个响应打出来了)
- 前端:configFacts 24 checks / ConfigFactsCards 29 / BackthinkUrgencyTags 19 全过
- 契约 v0.68(纯增量:B12 新端点 + B8 两字段 + §4.10 形状与语义 + §7.4-17 下限限定)
2026-10-05 13:54:56 +08:00

435 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type ReactNode } from "react";
import {
adminSystemBackup,
adminSystemRestore,
adminSystemRevokeKeys,
adminSystemRotateJWT,
getStoredRole,
} from "../../lib/api";
import type {
SystemBackupResp,
SystemRestoreResp,
SystemRevokeKeysResp,
SystemRotateJwtResp,
} from "../../lib/types";
import { useAuditStore } from "../audit";
import { KeyValueRow, Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
/**
* 系统设置 (design/10 §3 模块14 + design/11 §4 敏感操作代审)。后端已实现 4 个
* owner-only 且审计的**可写**系统操作:吊销全部 API key / 轮换 JWT 密钥 / 备份 /
* 恢复(见 internal/services/gateway/admin_system.go)。这里把它们做成真实可点的
* Miuix 卡片,每步带确认弹窗防误触;只读事实卡保持不变。
* 未实现(编辑环境变量 / 重启服务 / 检查更新)诚实标注为「后端未实现,不提供」。
*/
export function SystemModule(): React.ReactNode {
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
// 结果反馈(成功 tone="ok" / 失败 tone="err")。
const [msg, setMsg] = useState<{ text: string; tone: "ok" | "err" } | null>(null);
const [busy, setBusy] = useState(false);
// 当前打开的确认弹窗(null 表示无)。rotate / restore 需要输入,故各自持有字段。
const [pending, setPending] = useState<"revoke" | "rotate" | "backup" | "restore" | null>(null);
const [newSecret, setNewSecret] = useState("");
const [secretErr, setSecretErr] = useState<string | null>(null);
const [restorePath, setRestorePath] = useState("");
const [restoreErr, setRestoreErr] = useState<string | null>(null);
const close = () => {
setPending(null);
setSecretErr(null);
setRestoreErr(null);
};
const done = (tone: "ok" | "err", text: string) => setMsg({ tone, text });
const doRevoke = (): void => {
setBusy(true);
setMsg(null);
adminSystemRevokeKeys()
.then((r: SystemRevokeKeysResp) => {
log("system.revoke_keys", "ok");
done("ok", `已吊销 ${r.revoked} 个 API key;这些 key 立即失效。`);
close();
})
.catch((e: unknown) => {
log("system.revoke_keys", "error");
done("err", errText(e, "吊销失败"));
})
.finally(() => setBusy(false));
};
const doRotate = (): void => {
const secret = newSecret.trim();
if (secret.length < 32) {
setSecretErr("新密钥长度不足 32 位");
return;
}
setBusy(true);
setMsg(null);
adminSystemRotateJWT(secret)
.then((r: SystemRotateJwtResp) => {
log("system.rotate_jwt", "ok");
done("ok", `JWT 密钥已轮换。${r.note}——请返回登录页重新登录以继续。`);
setNewSecret("");
close();
})
.catch((e: unknown) => {
log("system.rotate_jwt", "error");
done("err", errText(e, "轮换失败"));
})
.finally(() => setBusy(false));
};
const doBackup = (): void => {
setBusy(true);
setMsg(null);
adminSystemBackup()
.then((r: SystemBackupResp) => {
log("system.backup", "ok");
done("ok", `备份完成:${formatBytes(r.size)} → ${r.path}`);
close();
})
.catch((e: unknown) => {
log("system.backup", "error");
done("err", errText(e, "备份失败"));
})
.finally(() => setBusy(false));
};
const doRestore = (): void => {
const path = restorePath.trim();
if (!path) {
setRestoreErr("请填写要恢复的备份文件路径");
return;
}
setBusy(true);
setMsg(null);
adminSystemRestore(path)
.then((r: SystemRestoreResp) => {
log("system.restore", "ok");
done("ok", `已安排恢复:${r.note}(恢复前已备份到 ${r.backup})`);
setRestorePath("");
close();
})
.catch((e: unknown) => {
log("system.restore", "error");
done("err", errText(e, "恢复失败"));
})
.finally(() => setBusy(false));
};
const genSecret = (): void => {
let out = "";
if (typeof crypto !== "undefined" && "getRandomValues" in crypto) {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
out = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
} else {
for (let i = 0; i < 48; i += 1) out += Math.floor(Math.random() * 16).toString(16);
}
setNewSecret(out);
setSecretErr(null);
};
return (
<div className="space-y-5">
<PageHeader title="系统设置" subtitle="安全(密钥/JWT) + 运行时 + 存储 + 环境变量(只读);可写系统操作见「可写操作」" />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="shield" size={18} />} title="安全 & 密钥" ownerOnly />
<KeyValueRow label="JWT 密钥" value="••••••••" mono />
<KeyValueRow label="鉴权" value="JWT + RBAC + API key" />
<KeyValueRow label="审计" value="who / what / when / result" />
</Surface>
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="storage" size={18} />} title="存储" />
<KeyValueRow label="默认" value="SQLite" />
<KeyValueRow label="可选增强" value="PG / pgvector(语义/向量路径)" />
<KeyValueRow label="单二进制 / 单端口" value=":8080 · 零跨进程 IPC" />
</Surface>
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="package" size={18} />} title="环境变量" />
{/* 键名必须与 `.env` / `.env.example` **逐字一致**:面板是只读事实卡,
用户会照着这里抄键名去改 .env —— 写错一个字母就是静默不生效(AGENTS §10 E6)。
真键:HOST / PORT / ADMIN_USERNAME / ADMIN_PASSWORD(见 .env.example)。 */}
<KeyValueRow label="HOST" value="0.0.0.0" mono />
<KeyValueRow label="PORT" value="8080" mono />
<KeyValueRow label="ADMIN_USERNAME" value="admin" mono />
<KeyValueRow label="ADMIN_PASSWORD" value="••••••••" mono />
<p className="pt-1 text-xs text-[var(--mu-text-lo)]">
以上为 <span className="font-mono">.env.example</span> 的默认值(这几条没有只读读取端点,不是运行实例的实时值;
带凭据的键面板刻意不回值)。改值请编辑 <span className="font-mono">.env</span> 后重启服务。
</p>
<p className="pt-1 text-xs text-[var(--mu-text-lo)]">
要看运行实例的实时生效值,去两处只读事实卡:记忆管理页的「记忆整理」与后台思考页的「安静时段」
(<span className="font-mono">GET /api/v1/admin/config-facts</span>,契约 v0.68)。
</p>
</Surface>
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="refresh" size={18} />} title="运行时" />
<KeyValueRow label="部署形态" value="单二进制 · 单端口 · 零跨进程 IPC" />
<KeyValueRow label="日志级别" value="后端配置,面板暂不可改" />
</Surface>
</div>
{msg ? <Notice tone={msg.tone}>{msg.text}</Notice> : null}
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">可写系统操作</h3>
<Tag tone="warn">owner 专属</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">真实读写 · 已审计 · 每步需确认</span>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<WriteAction
icon={<Icon name="lock" size={18} />}
title="吊销全部 API key"
desc="清空 key 表:所有已注册的 API key 立即失效,需重新签发。不可撤销。"
buttonText="吊销"
variant="danger"
disabled={!isOwner || busy}
onClick={() => setPending("revoke")}
/>
<WriteAction
icon={<Icon name="refresh" size={18} />}
title="轮换 JWT 密钥"
desc="换用新的签名密钥(≥32 位)。现有 token 立即失效,需重新登录;新密钥不回显。"
buttonText="轮换"
variant="outline"
disabled={!isOwner || busy}
onClick={() => setPending("rotate")}
/>
<WriteAction
icon={<Icon name="storage" size={18} />}
title="备份数据库"
desc="用 SQLite VACUUM INTO 快照当前库(含已提交 WAL)到数据目录,返回路径与大小。"
buttonText="备份"
variant="primary"
disabled={!isOwner || busy}
onClick={() => setPending("backup")}
/>
<WriteAction
icon={<Icon name="package" size={18} />}
title="恢复数据库"
desc="从数据目录内的备份文件恢复;恢复前自动备份当前库,替换需重启服务生效。"
buttonText="恢复"
variant="danger"
disabled={!isOwner || busy}
onClick={() => setPending("restore")}
/>
</div>
</Surface>
<Notice tone="warn">
以下系统操作后端尚未实现,当前不提供:编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位按钮。
</Notice>
{!isOwner ? (
<Notice tone="warn">安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。</Notice>
) : null}
{pending === "revoke" ? (
<ConfirmDialog
danger
busy={busy}
title="吊销全部 API key"
confirmText="确认吊销"
body={
<p className="text-xs text-[var(--mu-text-mid)]">
将吊销<b>全部</b>已注册的 API key;操作完成后所有使用这些 key 的客户端立即失效,
需重新签发。此操作<b>不可撤销</b>,确定继续?
</p>
}
onConfirm={doRevoke}
onClose={close}
/>
) : null}
{pending === "rotate" ? (
<ConfirmDialog
busy={busy}
title="轮换 JWT 密钥"
confirmText="确认轮换"
body={
<>
<p className="text-xs text-[var(--mu-text-mid)]">
输入新的 JWT 签名密钥(≥32 位)。轮换后<b>所有现有 token 立即失效,需重新登录</b>;
新密钥<b>不会被后端回显</b>,请在提交前保存好(提交后输入框即清空)。
</p>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">新密钥(≥32 位)</span>
<input
value={newSecret}
onChange={(e) => {
setNewSecret(e.target.value);
setSecretErr(null);
}}
className={`${inputCls} font-mono`}
placeholder="至少 32 位,可点「随机生成」"
/>
</label>
{secretErr ? <span className="text-xs text-[var(--mu-err)]">{secretErr}</span> : null}
<Button size="sm" variant="ghost" disabled={busy} onClick={genSecret}>
随机生成
</Button>
</>
}
onConfirm={doRotate}
onClose={close}
/>
) : null}
{pending === "backup" ? (
<ConfirmDialog
busy={busy}
title="备份数据库"
confirmText="确认备份"
body={
<p className="text-xs text-[var(--mu-text-mid)]">
用 SQLite <span className="font-mono">VACUUM INTO</span> 把当前数据库
(含已提交 WAL 内容)快照到数据目录,返回备份文件的路径与大小。不会影响正在运行的服务。
</p>
}
onConfirm={doBackup}
onClose={close}
/>
) : null}
{pending === "restore" ? (
<ConfirmDialog
danger
busy={busy}
title="恢复数据库"
confirmText="确认恢复"
body={
<>
<p className="text-xs text-[var(--mu-text-mid)]">
从<b>数据目录内</b>的备份文件恢复。恢复前会自动备份当前数据库;替换后需
<b>重启服务</b> 才生效,且会<b>覆盖现有数据</b>。
</p>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">备份文件路径(须在数据目录内)</span>
<input
value={restorePath}
onChange={(e) => {
setRestorePath(e.target.value);
setRestoreErr(null);
}}
className={`${inputCls} font-mono`}
placeholder="如 backup-20260908T120000.000000000Z.db"
/>
</label>
{restoreErr ? <span className="text-xs text-[var(--mu-err)]">{restoreErr}</span> : null}
</>
}
onConfirm={doRestore}
onClose={close}
/>
) : null}
</div>
);
}
function SectionTitle({ icon, title, ownerOnly = false }: { icon: ReactNode; title: string; ownerOnly?: boolean }): ReactNode {
return (
<h3 className="flex items-center gap-2 text-sm font-semibold">
<span className="text-[var(--mu-accent)]">{icon}</span>
<span>{title}</span>
{ownerOnly ? <Tag tone="warn">owner</Tag> : null}
</h3>
);
}
interface WriteActionProps {
icon: ReactNode;
title: string;
desc: string;
buttonText: string;
variant: "primary" | "soft" | "ghost" | "outline" | "danger";
disabled: boolean;
onClick: () => void;
}
/** One owner-only write action as a Miuix card; clicking opens the confirm dialog. */
function WriteAction({ icon, title, desc, buttonText, variant, disabled, onClick }: WriteActionProps): ReactNode {
return (
<Surface className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-[var(--mu-accent)]">{icon}</span>
<span className="text-sm font-semibold">{title}</span>
<Tag tone="ok" className="ml-auto">已实现</Tag>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">{desc}</p>
<div className="mt-auto pt-1">
<Button variant={variant} size="sm" disabled={disabled} onClick={onClick}>
{buttonText}
</Button>
</div>
</Surface>
);
}
interface ConfirmDialogProps {
title: string;
body: ReactNode;
confirmText: string;
danger?: boolean;
busy: boolean;
onConfirm: () => void;
onClose: () => void;
}
/** A Miuix confirmation overlay (防误触). Blocks the action until the owner confirms. */
function ConfirmDialog({ title, body, confirmText, danger = false, busy, onConfirm, onClose }: ConfirmDialogProps): ReactNode {
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-lg rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-1)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 border-b border-[var(--mu-glass-border)] px-5 py-3">
<span className="text-sm font-semibold">{title}</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">确认操作</span>
<Tag tone={danger ? "err" : "warn"}>{danger ? "危险" : "请确认"}</Tag>
</div>
<div className="space-y-3 px-5 py-4">{body}</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--mu-glass-border)] px-5 py-3">
<Button size="sm" variant="ghost" disabled={busy} onClick={onClose}>
取消
</Button>
<Button size="sm" variant={danger ? "danger" : "primary"} disabled={busy} onClick={onConfirm}>
{busy ? "处理中…" : confirmText}
</Button>
</div>
</div>
</div>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
function errText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback;
}
/** Human-readable byte size (MiB default unit for a DB snapshot). */
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let v = n;
let u = -1;
do {
v /= 1024;
u += 1;
} while (v >= 1024 && u < units.length - 1);
return `${v.toFixed(1)} ${units[u]}`;
}