- 新增 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 下限限定)
435 lines
17 KiB
TypeScript
435 lines
17 KiB
TypeScript
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]}`;
|
||
}
|