功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
@@ -0,0 +1,77 @@
import { FileCheck2, RotateCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AIConfigDiffPreviewResponse } from "../api/types";
import { ConfirmDialog } from "./OperationControls";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
export function AIConfigDiffReviewPanel() {
const [items, setItems] = useState<AIConfigDiffPreviewResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selected, setSelected] = useState<AIConfigDiffPreviewResponse | null>(null);
const [busyId, setBusyId] = useState("");
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await platformApiClient.listAIConfigDiffs();
setItems(response.items);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "AI 配置审查队列加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function approve() {
if (!selected || busyId) return;
setBusyId(selected.id);
setResult(null);
try {
const response = await platformApiClient.approveAIConfigDiff(selected.id, `web:ai.config.approve:${selected.id}`);
setResult({ status: "succeeded", label: `已审批 ${response.preview.id} · 写入任务 ${response.dispatch.job.id}` });
setSelected(null);
await refresh();
} catch (caught) {
setResult({ status: "failed", label: caught instanceof Error ? caught.message : "AI 配置审批失败" });
setSelected(null);
} finally {
setBusyId("");
}
}
return (
<section className="console-panel ai-diff-review-panel" aria-label="AI config diff review">
<div className="panel-header">
<h2><FileCheck2 size={16} /> AI </h2>
<button type="button" className="icon-command" disabled={loading || Boolean(busyId)} onClick={() => void refresh()}><RotateCw size={14} /><span></span></button>
</div>
{result && <ResultBadge status={result.status} label={result.label} />}
{loading && <LoadingState label="正在同步 AI 配置差异…" compact />}
{!loading && error && <ErrorState title="AI 配置审查不可用" reason={error} diagnosticId="ai-config-diffs" onRetry={() => void refresh()} compact />}
{!loading && !error && (
<div className="operation-list">
{items.length === 0 && <p className="operations-module-empty"> AI </p>}
{items.slice(0, 12).map((item) => (
<div key={item.id} className="operation-item">
<div className="operation-item-head"><strong>{item.serverInstanceId} · {item.key}</strong><span className={`status-pill status-${item.state === "approved" ? "succeeded" : item.state === "pending" ? "warning" : "disabled"}`}>{item.state}</span></div>
<div className="operation-meta"><span> {item.requestId}</span><span> {item.configVersion}</span><span>{item.model || "Platform model"}</span><span> {new Date(item.expiresAt).toLocaleString()}</span>{item.jobId && <span> {item.jobId}</span>}</div>
<p>{item.diffSummary}</p>
{item.proposedConfig && <pre className="log-view ai-config-proposal">{item.proposedConfig}</pre>}
{item.state === "pending" && <div className="row-actions"><button type="button" disabled={Boolean(busyId)} onClick={() => setSelected(item)}><FileCheck2 size={14} /><span></span></button></div>}
</div>
))}
</div>
)}
<ConfirmDialog open={selected !== null} title="批准 AI 配置差异" description={selected ? `服务器 ${selected.serverInstanceId},配置版本 ${selected.configVersion},差异 ${selected.id}` : "确认 AI 配置差异。"} confirmLabel="批准并派发" busy={Boolean(busyId)} onCancel={() => { if (!busyId) setSelected(null); }} onConfirm={() => void approve()} />
</section>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { PageRoute } from "../contracts/page";
import type { CurrentUserView } from "../contracts/workspace";
import appShellSource from "./AppShell.tsx?raw";
import { AppShell } from "./AppShell";
const routes: PageRoute[] = [
{ id: "home", label: "首页", path: "/home", hash: "#/home", description: "概览", requiredCapability: "platform.overview.read", showInNav: true },
{ id: "servers", label: "服务器管理", path: "/servers", hash: "#/servers", description: "服务器", requiredCapability: "servers.read", showInNav: true }
];
const session: CurrentUserView = {
id: "operator",
displayName: "Operator",
status: "active",
roles: ["platformAdmin"],
capabilities: ["platform.overview.read", "servers.read"],
profile: {},
source: "api"
};
describe("AppShell mobile navigation", () => {
it("renders a labelled edge control and vertical menu labels", () => {
const html = renderToStaticMarkup(
<AppShell routes={routes} currentPage="home" session={session} operations={[]} onNavigate={() => undefined}>
<p></p>
</AppShell>
);
expect(html).toContain('class="mobile-sidebar-handle"');
expect(html).toContain('aria-controls="primary-sidebar"');
expect(html).toContain('id="primary-sidebar"');
expect(html).toContain("平台概览");
expect(html).toContain("服务器管理");
expect(html).toContain("关闭导航菜单");
});
it("uses bounded edge-swipe thresholds and accessible dismissal", () => {
expect(appShellSource).toContain("start.x <= 28 && deltaX >= 56");
expect(appShellSource).toContain("deltaX <= -56");
expect(appShellSource).toContain('event.key === "Escape"');
expect(appShellSource).toContain("setIsMobileSidebarOpen(false)");
});
});
+95 -7
View File
@@ -9,13 +9,15 @@ import {
ShieldCheck,
UserRoundPen,
WandSparkles,
Wrench
Wrench,
X
} from "lucide-react";
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
import { type ComponentType, type ReactNode, type TouchEvent, useEffect, useRef, useState } from "react";
import type { PageId, PageParams, PageRoute } from "../contracts/page";
import type { CurrentUserView } from "../contracts/workspace";
import type { CurrentUserView, OperationRecord } from "../contracts/workspace";
import { MagicalParticleLayer } from "./MagicalParticleLayer";
import { OperationsTray } from "./OperationsTray";
import {
applyBackgroundImage,
applyThemeBackgroundPreset,
@@ -33,6 +35,7 @@ interface AppShellProps {
routes: PageRoute[];
currentPage: PageId;
session: CurrentUserView;
operations: OperationRecord[];
onNavigate: (pageId: PageId, params?: PageParams) => void;
children: ReactNode;
}
@@ -59,9 +62,11 @@ const roleLabels: Record<CurrentUserView["roles"][number], string> = {
serverAdmin: "服务器管理员"
};
export function AppShell({ routes, currentPage, session, onNavigate, children }: AppShellProps) {
export function AppShell({ routes, currentPage, session, operations, onNavigate, children }: AppShellProps) {
const [themeState, setThemeState] = useState<WorkspaceThemeState>(() => loadThemeState());
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const touchStart = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
function handleThemePaletteChange(event: Event) {
@@ -81,6 +86,29 @@ export function AppShell({ routes, currentPage, session, onNavigate, children }:
return () => window.removeEventListener(themePaletteChangeEvent, handleThemePaletteChange);
}, []);
useEffect(() => {
if (!isMobileSidebarOpen) {
return;
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsMobileSidebarOpen(false);
}
}
const isNarrow = window.matchMedia("(max-width: 760px)").matches;
const previousOverflow = document.body.style.overflow;
if (isNarrow) {
document.body.style.overflow = "hidden";
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
if (isNarrow) {
document.body.style.overflow = previousOverflow;
}
};
}, [isMobileSidebarOpen]);
const activePalette = themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0];
const routesById = new Map(routes.map((route) => [route.id, route]));
const visibleGroups = menuGroups
@@ -91,15 +119,66 @@ export function AppShell({ routes, currentPage, session, onNavigate, children }:
.filter((group) => group.routes.length > 0);
function activateGroup(group: (typeof visibleGroups)[number]) {
setIsMobileSidebarOpen(false);
if (group.routes[0].id !== currentPage) {
onNavigate(group.routes[0].id);
}
}
function handleTouchStart(event: TouchEvent<HTMLDivElement>) {
const touch = event.touches[0];
touchStart.current = touch ? { x: touch.clientX, y: touch.clientY } : null;
}
function handleTouchEnd(event: TouchEvent<HTMLDivElement>) {
const start = touchStart.current;
const touch = event.changedTouches[0];
touchStart.current = null;
if (!start || !touch || !window.matchMedia("(max-width: 760px)").matches) {
return;
}
const deltaX = touch.clientX - start.x;
const deltaY = touch.clientY - start.y;
if (Math.abs(deltaY) >= Math.abs(deltaX)) {
return;
}
if (!isMobileSidebarOpen && start.x <= 28 && deltaX >= 56) {
setIsMobileSidebarOpen(true);
} else if (isMobileSidebarOpen && deltaX <= -56) {
setIsMobileSidebarOpen(false);
}
}
return (
<div className={cx("app-shell", isSidebarCollapsed && "app-shell-sidebar-collapsed")}>
<div
className={cx("app-shell", isSidebarCollapsed && "app-shell-sidebar-collapsed", isMobileSidebarOpen && "app-shell-mobile-sidebar-open")}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<MagicalParticleLayer />
<aside className="app-sidebar">
<button
type="button"
className="mobile-sidebar-handle"
aria-label="打开导航菜单"
aria-controls="primary-sidebar"
aria-expanded={isMobileSidebarOpen}
onClick={() => setIsMobileSidebarOpen(true)}
>
<WandSparkles size={17} aria-hidden="true" />
<span></span>
</button>
<button
type="button"
className="mobile-sidebar-backdrop"
aria-label="关闭导航菜单"
aria-hidden={!isMobileSidebarOpen}
tabIndex={isMobileSidebarOpen ? 0 : -1}
onClick={() => setIsMobileSidebarOpen(false)}
/>
<aside id="primary-sidebar" className={cx("app-sidebar", isMobileSidebarOpen && "app-sidebar-mobile-open")}>
<button type="button" className="mobile-sidebar-close" aria-label="关闭导航菜单" onClick={() => setIsMobileSidebarOpen(false)}>
<X size={18} aria-hidden="true" />
</button>
<div className="app-brand" aria-label={themeTokens.appName}>
<span className="app-brand-mark">
<WandSparkles size={17} />
@@ -140,8 +219,17 @@ export function AppShell({ routes, currentPage, session, onNavigate, children }:
);
})}
</nav>
<OperationsTray operations={operations} />
<div className="app-session">
<button type="button" className="app-account-button" aria-current={currentPage === "profileSettings" ? "page" : undefined} onClick={() => onNavigate("profileSettings")}>
<button
type="button"
className="app-account-button"
aria-current={currentPage === "profileSettings" ? "page" : undefined}
onClick={() => {
setIsMobileSidebarOpen(false);
onNavigate("profileSettings");
}}
>
<span className="account-avatar" aria-hidden="true">
{session.profile.avatarUrl ? <img src={session.profile.avatarUrl} alt="" /> : <Heart size={17} />}
</span>
@@ -0,0 +1,221 @@
import { Activity, Ban, KeyRound, PackageCheck, Play, RefreshCw, RotateCcw, ShieldAlert, Square, Trash2, UploadCloud } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { ClientManagerInstallationResponse, ClientManagerLifecycleOperation } from "../api/types";
import type { CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
import { cx } from "../utils/classes";
import { ConfirmDialog } from "./OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: ClientManagerInstallationResponse[] };
interface ClientManagerLifecyclePanelProps {
serverId: string;
serverName: string;
session: CurrentUserView;
operations: OperationTracker;
}
interface PendingConfirmation {
title: string;
description: string;
danger?: boolean;
execute: () => Promise<void>;
}
export function ClientManagerLifecyclePanel({ serverId, serverName, session, operations }: ClientManagerLifecyclePanelProps) {
const [state, setState] = useState<LoadState>({ status: "loading" });
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [confirmation, setConfirmation] = useState<PendingConfirmation | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const refresh = useCallback(async (showLoading = false) => {
if (showLoading) setState({ status: "loading" });
try {
const response = await platformApiClient.listClientManagerLifecycles(serverId);
setState({ status: "ready", items: response.items });
} catch (error) {
setState({ status: "error", reason: safeError(error, "Client Manager 状态加载失败") });
}
}, [serverId]);
useEffect(() => { void refresh(true); }, [refresh]);
const hasActiveJob = state.status === "ready" && state.items.some((item) => item.job && ["queued", "accepted", "running", "retrying"].includes(item.job.state));
useEffect(() => {
if (!hasActiveJob) return undefined;
const timer = window.setInterval(() => void refresh(), 2500);
return () => window.clearInterval(timer);
}, [hasActiveJob, refresh]);
async function runCommand(item: ClientManagerInstallationResponse, intent: string, execute: () => Promise<ClientManagerInstallationResponse>) {
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${serverId}:client-manager:${item.profileKey}`, requester: session.displayName });
setResult({ status: "pending", label: `${intent} 已提交,等待 Platform/Run 返回真实状态` });
try {
const next = await execute();
setState((current) => current.status === "ready" ? { status: "ready", items: current.items.map((entry) => entry.id === next.id ? next : entry) } : current);
const label = next.job ? `${intent} 已排队,job ${next.job.id}` : `${intent} 已完成状态更新`;
operations.succeed(operationId, label);
setResult({ status: "succeeded", label });
await refresh();
} catch (error) {
const reason = safeError(error, `${intent} 失败`);
operations.fail(operationId, reason, operationId);
setResult({ status: "failed", label: reason });
}
}
function confirmCommand(config: PendingConfirmation) {
setConfirmation(config);
}
return (
<article className="console-panel client-manager-lifecycle-panel" aria-label="Client Manager 生命周期">
<div className="panel-header">
<h2><PackageCheck size={17} /> Client Manager </h2>
<div className="action-strip">
{result && <ResultBadge status={result.status} label={result.label} />}
<button type="button" className="icon-command" title="刷新 Client Manager 状态" onClick={() => void refresh()}>
<RefreshCw size={15} /><span></span>
</button>
</div>
</div>
{state.status === "loading" && <LoadingState label="正在读取 Client Manager 部署与组件健康状态…" />}
{state.status === "error" && <ErrorState title="Client Manager 状态不可用" reason={state.reason} diagnosticId={`client-manager:${serverId}`} onRetry={() => void refresh(true)} />}
{state.status === "ready" && state.items.length === 0 && <EmptyState title="尚无 Client Manager 生命周期记录" description="先在运行分发区按插件声明构建 Client Manager;可用 artifact 会在这里进入部署闭环。" />}
{state.status === "ready" && state.items.length > 0 && (
<div className="client-manager-lifecycle-list">
{state.items.map((item) => (
<ClientManagerLifecycleRow
key={item.id}
item={item}
serverName={serverName}
runCommand={runCommand}
confirmCommand={confirmCommand}
/>
))}
</div>
)}
<ConfirmDialog
open={confirmation !== null}
title={confirmation?.title ?? ""}
description={confirmation?.description ?? ""}
confirmLabel="确认执行"
danger={confirmation?.danger}
busy={confirmBusy}
onCancel={() => setConfirmation(null)}
onConfirm={() => {
if (!confirmation) return;
setConfirmBusy(true);
void confirmation.execute().finally(() => {
setConfirmBusy(false);
setConfirmation(null);
});
}}
/>
</article>
);
}
interface ClientManagerLifecycleRowProps {
item: ClientManagerInstallationResponse;
serverName: string;
runCommand: (item: ClientManagerInstallationResponse, intent: string, execute: () => Promise<ClientManagerInstallationResponse>) => Promise<void>;
confirmCommand: (config: PendingConfirmation) => void;
}
function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmCommand }: ClientManagerLifecycleRowProps) {
const actionMap = useMemo(() => new Map(item.actions.map((action) => [action.operation, action])), [item.actions]);
const available = (operation: ClientManagerLifecycleOperation) => actionMap.get(operation)?.available ?? false;
const reason = (operation: ClientManagerLifecycleOperation) => actionMap.get(operation)?.reason ?? "Platform 当前状态不允许此操作";
const distributionId = item.distribution?.id ?? "";
const idempotency = (operation: string) => `client-manager.${operation}:${item.serverInstanceId}:${item.profileKey}:${Date.now()}`;
const control = (operation: "start" | "stop" | "restart" | "status" | "rollback") =>
platformApiClient.controlClientManager(item.serverInstanceId, { profileKey: item.profileKey, operation, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency(operation) });
const deploy = () => runCommand(item, item.requiresRedeploy ? "重新部署 Client Manager" : "部署 Client Manager", () => platformApiClient.deployClientManager(item.serverInstanceId, {
profileKey: item.profileKey, distributionId, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("deploy")
}));
const update = () => runCommand(item, "更新 Client Manager", () => platformApiClient.updateClientManager(item.serverInstanceId, {
profileKey: item.profileKey, distributionId, expectedDeploymentGeneration: item.deploymentGeneration, approved: true, idempotencyKey: idempotency("update")
}));
return (
<section className="client-manager-lifecycle-row" aria-label={`${item.profileKey} lifecycle`}>
<div className="client-manager-lifecycle-head">
<div>
<strong>{item.profileKey}</strong>
<span className="provider-id">{item.targetOs}/{item.targetArch} · deployment generation {item.deploymentGeneration} · key generation {item.keyGeneration}</span>
</div>
<div className="tag-list">
<span className={cx("status-pill", lifecycleTone(item.status))}>{lifecycleLabel(item.status)}</span>
<span className={cx("status-pill", healthTone(item.health))}><Activity size={12} /> {healthLabel(item.health)}</span>
</div>
</div>
<div className="client-manager-version-grid">
<VersionCell label="目标版本" version={item.desiredVersion} revision={item.desiredRevision} artifact={item.desiredArtifactId} />
<VersionCell label="当前版本" version={item.activeVersion} revision={item.activeRevision} artifact={item.activeArtifactId} />
<VersionCell label="回滚版本" version={item.previousVersion} revision={item.previousRevision} artifact={item.previousArtifactId} />
<div className="client-manager-version-cell"><span></span><strong>{item.lastSeenAt ? `最后心跳 ${formatTime(item.lastSeenAt)}` : "等待独立注册"}</strong><small>{item.healthReason || "未收到安全健康原因"}</small></div>
</div>
<div className="client-manager-phase-line">
<span><ShieldAlert size={14} /> {item.phase || "等待生命周期事件"}</span>
{item.lastOperation && <span> {item.lastOperation}</span>}
{item.lastSuccessfulJobId && <span> job {item.lastSuccessfulJobId}</span>}
</div>
{item.job && (
<div className="client-manager-job-progress" aria-label="Client Manager job progress">
<div><span>job {item.job.id} · attempt {item.job.attempt} · {item.job.state}</span><strong>{item.job.progress.percent}%</strong></div>
<progress max={100} value={item.job.progress.percent} />
<small>{item.job.progress.message || "等待 Run 回报真实阶段"}</small>
</div>
)}
{(item.retryable || item.requiresRedeploy || item.status === "failed") && (
<div className="client-manager-recovery">
<ShieldAlert size={16} />
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 Platform 审计与 job 失败原因后选择重新部署、回滚或卸载。"}</span>
</div>
)}
<div className="client-manager-command-grid">
<LifecycleButton icon={<UploadCloud size={14} />} label={item.activeArtifactId ? "重新部署" : "部署"} disabled={!available("deploy") || !distributionId} reason={!distributionId ? "没有可用 distribution" : reason("deploy")} onClick={deploy} />
<LifecycleButton icon={<Play size={14} />} label="启动" disabled={!available("start")} reason={reason("start")} onClick={() => void runCommand(item, "启动 Client Manager", () => control("start"))} />
<LifecycleButton icon={<Square size={14} />} label="停止" disabled={!available("stop")} reason={reason("stop")} onClick={() => void runCommand(item, "停止 Client Manager", () => control("stop"))} />
<LifecycleButton icon={<RefreshCw size={14} />} label="重启" disabled={!available("restart")} reason={reason("restart")} onClick={() => void runCommand(item, "重启 Client Manager", () => control("restart"))} />
<LifecycleButton icon={<Activity size={14} />} label="检查状态" disabled={!available("status")} reason={reason("status")} onClick={() => void runCommand(item, "检查 Client Manager 状态", () => control("status"))} />
<LifecycleButton icon={<UploadCloud size={14} />} label="更新" disabled={!available("update") || !distributionId} reason={!distributionId ? "没有兼容的可用 distribution" : reason("update")} onClick={() => confirmCommand({ title: "批准 Client Manager 更新", description: `${serverName}${item.profileKey}${item.activeVersion || "未安装"} 更新到 ${item.desiredVersion || "目标版本"}。Run 将 staged activate、健康确认,并在失败时恢复 previous slot。`, execute: update })} />
<LifecycleButton icon={<RotateCcw size={14} />} label="回滚" disabled={!available("rollback")} reason={reason("rollback")} onClick={() => confirmCommand({ title: "回滚 Client Manager", description: `确认将 ${item.profileKey} 回滚到 ${item.previousVersion || "previous slot"}?当前组件 session 将被撤销并需要重新注册。`, danger: true, execute: () => runCommand(item, "回滚 Client Manager", () => control("rollback")) })} />
<LifecycleButton icon={<RefreshCw size={14} />} label="重试" disabled={!item.retryable} reason="当前失败不可重试" onClick={() => void runCommand(item, "重试 Client Manager", () => platformApiClient.retryClientManagerLifecycle(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("retry") }))} />
<LifecycleButton icon={<Ban size={14} />} label="撤销会话" disabled={!item.activeArtifactId || item.status === "uninstalled"} reason="组件尚未安装" onClick={() => confirmCommand({ title: "撤销 Client Manager 会话", description: `撤销 ${item.profileKey} 的独立组件 session。Run session 与 job lease 不受影响,组件必须使用当前 key generation 重新注册。`, danger: true, execute: () => runCommand(item, "撤销 Client Manager 会话", () => platformApiClient.revokeClientManagerSession(item.serverInstanceId, { profileKey: item.profileKey, reason: "operator revoked component session" })) })} />
<LifecycleButton icon={<KeyRound size={14} />} label="重置密钥" disabled={item.status === "uninstalled"} reason="已卸载" onClick={() => confirmCommand({ title: "重置 Client Manager 密钥", description: `重置 ${item.profileKey} 的 component key 会撤销旧 session/artifact generation。必须重新构建并重新部署,不会显示或导出原始密钥。`, danger: true, execute: async () => { await platformApiClient.resetClientManagerKey(item.serverInstanceId, { componentKind: "client-manager", componentKey: item.profileKey }); await runCommand(item, "刷新密钥重置状态", () => platformApiClient.getClientManagerLifecycle(item.serverInstanceId, item.profileKey)); } })} />
<LifecycleButton icon={<Trash2 size={14} />} label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName}${item.profileKey}Run 只会清理受控 Client Manager workspacePlatform 保留 build、artifact 与审计历史。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
</div>
</section>
);
}
function VersionCell({ label, version, revision, artifact }: { label: string; version?: string; revision?: string; artifact?: string }) {
return <div className="client-manager-version-cell"><span>{label}</span><strong>{version || "--"}</strong><small>{revision ? `revision ${shortRef(revision)}` : "revision --"}{artifact ? ` · artifact ${shortRef(artifact)}` : ""}</small></div>;
}
function LifecycleButton({ icon, label, disabled, reason, danger, onClick }: { icon: ReactNode; label: string; disabled: boolean; reason: string; danger?: boolean; onClick: () => void }) {
return <button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : label} onClick={onClick}>{icon}<span>{label}</span></button>;
}
function lifecycleLabel(status: ClientManagerInstallationResponse["status"]): string {
return ({ requested: "已请求", building: "构建中", available: "可部署", deploying: "部署中", installed: "已安装", registering: "等待注册", online: "在线", degraded: "降级", offline: "离线", updating: "更新中", rolling_back: "回滚中", stopping: "停止中", uninstalled: "已卸载", failed: "失败" })[status];
}
function lifecycleTone(status: ClientManagerInstallationResponse["status"]): string { return ["online", "installed"].includes(status) ? "status-active" : ["failed", "offline", "uninstalled"].includes(status) ? "status-disabled" : "status-pending"; }
function healthLabel(health: ClientManagerInstallationResponse["health"]): string { return ({ unknown: "健康未知", healthy: "健康", degraded: "健康降级", unhealthy: "不健康", offline: "心跳离线" })[health]; }
function healthTone(health: ClientManagerInstallationResponse["health"]): string { return health === "healthy" ? "status-active" : health === "unknown" || health === "degraded" ? "status-pending" : "status-disabled"; }
function shortRef(value: string): string { return value.length > 18 ? `${value.slice(0, 18)}` : value; }
function formatTime(value: string): string { const time = new Date(value); return Number.isNaN(time.getTime()) ? "未知" : time.toLocaleString(); }
function safeError(error: unknown, fallback: string): string { const message = error instanceof Error ? error.message : fallback; return message.replace(/Bearer\s+\S+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]").slice(0, 240); }
@@ -0,0 +1,40 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import operationControlsSource from "./OperationControls.tsx?raw";
import { ConfirmDialog, ManagementDialog } from "./OperationControls";
describe("shared operation dialogs", () => {
it("renders labelled, busy-aware confirmation semantics", () => {
const html = renderToStaticMarkup(
<ConfirmDialog
open
title="确认停用"
description="会保留审计记录"
confirmLabel="停用"
busy
onConfirm={() => undefined}
onCancel={() => undefined}
/>
);
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-labelledby=');
expect(html).toContain('aria-describedby=');
expect(html).toContain('aria-busy="true"');
expect(operationControlsSource).toContain('event.key === "Escape"');
expect(operationControlsSource).toContain("previousFocus?.focus()");
});
it("keeps management forms in a dialog surface", () => {
const html = renderToStaticMarkup(
<ManagementDialog open title="编辑资源" onClose={() => undefined}>
<p></p>
</ManagementDialog>
);
expect(html).toContain('role="dialog"');
expect(html).toContain("编辑资源");
expect(html).toContain('aria-modal="true"');
});
});
+61 -9
View File
@@ -1,5 +1,5 @@
import { X } from "lucide-react";
import type { ReactNode } from "react";
import { type ReactNode, useEffect, useId, useRef } from "react";
interface ConfirmDialogProps {
open: boolean;
@@ -14,14 +14,26 @@ interface ConfirmDialogProps {
}
export function ConfirmDialog({ open, title, description, confirmLabel, danger, busy, onConfirm, onCancel, children }: ConfirmDialogProps) {
const titleId = useId();
const descriptionId = useId();
const panelRef = useDialogLifecycle(open, onCancel, !busy);
if (!open) {
return null;
}
return (
<div className="confirm-backdrop" role="presentation" onClick={onCancel}>
<div className="confirm-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
<h2>{title}</h2>
<p>{description}</p>
<div className="confirm-backdrop" role="presentation" onClick={() => !busy && onCancel()}>
<div
ref={panelRef}
className="confirm-panel"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
aria-busy={busy}
onClick={(event) => event.stopPropagation()}
>
<h2 id={titleId}>{title}</h2>
<p id={descriptionId}>{description}</p>
{children}
<div className="confirm-actions">
<button type="button" onClick={onCancel} disabled={busy}>
@@ -46,26 +58,66 @@ interface ManagementDialogProps {
}
export function ManagementDialog({ open, title, description, wide, onClose, children }: ManagementDialogProps) {
const titleId = useId();
const descriptionId = useId();
const panelRef = useDialogLifecycle(open, onClose, true);
if (!open) {
return null;
}
return (
<div className="confirm-backdrop management-dialog-backdrop" role="presentation" onClick={onClose}>
<div className={`drawer-panel management-dialog-panel${wide ? " management-dialog-wide" : ""}`} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
<div
ref={panelRef}
className={`drawer-panel management-dialog-panel${wide ? " management-dialog-wide" : ""}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onClick={(event) => event.stopPropagation()}
>
<div className="panel-header">
<h2>{title}</h2>
<button type="button" className="theme-upload drawer-close" onClick={onClose}>
<h2 id={titleId}>{title}</h2>
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}>
<X size={14} />
<span></span>
</button>
</div>
{description && <p className="dialog-description">{description}</p>}
{description && <p id={descriptionId} className="dialog-description">{description}</p>}
{children}
</div>
</div>
);
}
function useDialogLifecycle(open: boolean, onClose: () => void, canClose: boolean) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const panel = panelRef.current;
const focusTarget = panel?.querySelector<HTMLElement>("button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])");
focusTarget?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && canClose) {
event.preventDefault();
onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previousFocus?.focus();
};
}, [canClose, onClose, open]);
return panelRef;
}
interface UsageMeterProps {
label: string;
percent?: number;
@@ -0,0 +1,79 @@
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, Clock3, ListChecks } from "lucide-react";
import { useMemo, useState } from "react";
import type { OperationRecord } from "../contracts/workspace";
import { projectOperationForTray } from "../contracts/operationsConsole";
import { cx } from "../utils/classes";
interface OperationsTrayProps {
operations: OperationRecord[];
}
export function OperationsTray({ operations }: OperationsTrayProps) {
const [open, setOpen] = useState(false);
const items = useMemo(() => operations.slice(0, 6).map(projectOperationForTray), [operations]);
const pendingCount = operations.filter((operation) => operation.status === "pending").length;
const failedCount = operations.filter((operation) => operation.status === "failed").length;
return (
<section className={cx("operations-tray", open && "operations-tray-open")} aria-label="当前会话操作">
<button
type="button"
className="operations-tray-trigger"
aria-expanded={open}
aria-controls="session-operations-panel"
onClick={() => setOpen((current) => !current)}
>
<span className="operations-tray-glyph" aria-hidden="true">
<ListChecks size={17} />
</span>
<span className="operations-tray-copy">
<strong></strong>
<span>{pendingCount > 0 ? `${pendingCount} 项处理中` : failedCount > 0 ? `${failedCount} 项失败` : `${operations.length} 条记录`}</span>
</span>
{open ? <ChevronDown size={15} aria-hidden="true" /> : <ChevronUp size={15} aria-hidden="true" />}
</button>
{open && (
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
<div className="operations-tray-heading">
<strong></strong>
<span> Platform </span>
</div>
{items.length === 0 ? (
<p className="operations-tray-empty"></p>
) : (
<ol className="operations-tray-list">
{items.map((item) => (
<li key={item.id} className={cx("operations-tray-item", `operations-tray-item-${item.status}`)}>
<span className="operations-tray-status" aria-hidden="true">
{item.status === "pending" ? <Clock3 size={14} /> : item.status === "succeeded" ? <CheckCircle2 size={14} /> : <AlertTriangle size={14} />}
</span>
<span className="operations-tray-item-copy">
<strong>{item.intent}</strong>
<span>{item.targetLabel}</span>
<span>{item.status === "failed" ? item.errorReason : item.message ?? operationStatusLabel(item.status)}</span>
{item.diagnosticId && <code>{item.diagnosticId}</code>}
</span>
<time dateTime={item.updatedAt}>{formatOperationTime(item.updatedAt)}</time>
</li>
))}
</ol>
)}
</div>
)}
</section>
);
}
function operationStatusLabel(status: OperationRecord["status"]): string {
if (status === "pending") {
return "等待 Platform 响应";
}
return status === "succeeded" ? "Platform 已确认" : "Platform 请求失败";
}
function formatOperationTime(value: string): string {
const time = new Date(value);
return Number.isNaN(time.getTime()) ? "时间未知" : time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
@@ -0,0 +1,122 @@
import { PackageCheck, RotateCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { PluginLifecycleInstallationResponse, PluginLifecycleOperation, ServerInstanceResponse } from "../api/types";
import { ConfirmDialog } from "./OperationControls";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
interface PluginLifecycleWorkbenchProps {
pluginId: string;
pluginName: string;
operations?: PluginLifecycleOperation[];
serverId?: string;
disabled?: boolean;
}
export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = lifecycleOperations, serverId, disabled = false }: PluginLifecycleWorkbenchProps) {
const [servers, setServers] = useState<ServerInstanceResponse[]>([]);
const [installations, setInstallations] = useState<PluginLifecycleInstallationResponse[]>([]);
const [selectedServerId, setSelectedServerId] = useState(serverId ?? "");
const [operation, setOperation] = useState<PluginLifecycleOperation>(operations[0] ?? "install");
const [targetVersion, setTargetVersion] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [serverResponse, lifecycleResponse] = await Promise.all([
platformApiClient.listServerInstances(),
platformApiClient.listPluginLifecycles({ pluginId, ...(serverId ? { serverInstanceId: serverId } : {}) })
]);
const compatibleServers = serverResponse.items.filter((server) => server.pluginId === pluginId && (!serverId || server.id === serverId));
setServers(compatibleServers);
setInstallations(lifecycleResponse.items);
setSelectedServerId((current) => current || compatibleServers[0]?.id || "");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "插件生命周期加载失败");
} finally {
setLoading(false);
}
}, [pluginId, serverId]);
useEffect(() => {
void refresh();
}, [refresh]);
const installation = useMemo(() => installations.find((item) => item.serverInstanceId === selectedServerId), [installations, selectedServerId]);
async function submit() {
if (!selectedServerId || busy) return;
setBusy(true);
setResult({ status: "pending", label: `${lifecycleOperationLabel(operation)}提交中…` });
try {
const response = await platformApiClient.runPluginLifecycle(pluginId, {
serverInstanceId: selectedServerId,
operation,
targetVersion: targetVersion.trim() || undefined,
idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`,
confirmed: disruptiveOperations.includes(operation)
});
const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.auditEventId && `审计 ${response.installation.auditEventId}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · ");
setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}${response.status}${evidence ? ` · ${evidence}` : ""}` });
setConfirming(false);
await refresh();
} catch (caught) {
setResult({ status: "failed", label: caught instanceof Error ? caught.message : "插件生命周期操作失败" });
setConfirming(false);
} finally {
setBusy(false);
}
}
return (
<div className="plugin-lifecycle-workbench" aria-label={`${pluginName} production lifecycle`}>
<div className="panel-header">
<h3><PackageCheck size={15} /> </h3>
<button type="button" className="icon-command" disabled={loading || busy} onClick={() => void refresh()} title="刷新插件生命周期"><RotateCw size={14} /><span></span></button>
</div>
{result && <ResultBadge status={result.status} label={result.label} />}
{loading && <LoadingState label="正在同步插件生命周期…" compact />}
{!loading && error && <ErrorState title="插件生命周期不可用" reason={error} diagnosticId={`plugin-lifecycle:${pluginId}`} onRetry={() => void refresh()} compact />}
{!loading && !error && (
<>
<div className="server-toolbar plugin-lifecycle-controls">
<select aria-label="生命周期服务器" value={selectedServerId} disabled={Boolean(serverId) || busy} onChange={(event) => setSelectedServerId(event.target.value)}>
{servers.length === 0 && <option value=""></option>}
{servers.map((server) => <option key={server.id} value={server.id}>{server.name} · {server.id}</option>)}
</select>
<select aria-label="生命周期操作" value={operation} disabled={busy} onChange={(event) => setOperation(event.target.value as PluginLifecycleOperation)}>
{operations.map((item) => <option key={item} value={item}>{lifecycleOperationLabel(item)}</option>)}
</select>
{(operation === "install" || operation === "upgrade") && <input aria-label="目标版本" placeholder="目标版本" value={targetVersion} disabled={busy} onChange={(event) => setTargetVersion(event.target.value)} />}
<button type="button" className="primary-command" disabled={disabled || busy || !selectedServerId} onClick={() => setConfirming(true)}>{busy ? "提交中…" : "执行"}</button>
</div>
{installation ? (
<div className="operation-item plugin-lifecycle-state">
<div className="operation-item-head"><strong>{installation.currentState} {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
<div className="operation-meta">
<span> {installation.currentVersion || "--"}</span><span> {installation.targetVersion || "--"}</span><span> {installation.dependencyState || "unknown"}</span>
{installation.jobId && <span> {installation.jobId}</span>}{installation.auditEventId && <span> {installation.auditEventId}</span>}{installation.alertId && <span> {installation.alertId}</span>}
</div>
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
</div>
) : <p className="operations-module-empty"></p>}
</>
)}
<ConfirmDialog open={confirming} title={`确认${lifecycleOperationLabel(operation)}`} description={`插件 ${pluginName},服务器 ${selectedServerId || "--"}${targetVersion ? `,目标版本 ${targetVersion}` : ""}`} confirmLabel={lifecycleOperationLabel(operation)} danger={disruptiveOperations.includes(operation)} busy={busy} onCancel={() => { if (!busy) setConfirming(false); }} onConfirm={() => void submit()} />
</div>
);
}
const lifecycleOperations: PluginLifecycleOperation[] = ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"];
const disruptiveOperations: PluginLifecycleOperation[] = ["disable", "rollback", "retire"];
function lifecycleOperationLabel(operation: PluginLifecycleOperation) {
return ({ install: "安装", enable: "启用", disable: "停用", upgrade: "升级", rollback: "回滚", retire: "退役", "dependency-check": "依赖检查" } as Record<PluginLifecycleOperation, string>)[operation];
}
@@ -0,0 +1,147 @@
import { Activity, AlertTriangle, Check, CheckCheck, RotateCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AlertResponse, ProductionCapacitySummaryResponse } from "../api/types";
import { cx } from "../utils/classes";
import { ConfirmDialog } from "./OperationControls";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
type AlertAction = "acknowledge" | "resolve" | "retry";
interface ProductionGovernancePanelProps {
compact?: boolean;
title?: string;
}
export function ProductionGovernancePanel({ compact = false, title = "容量与告警" }: ProductionGovernancePanelProps) {
const [capacity, setCapacity] = useState<ProductionCapacitySummaryResponse | null>(null);
const [alerts, setAlerts] = useState<AlertResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [intent, setIntent] = useState<{ alert: AlertResponse; action: AlertAction } | null>(null);
const [busyKey, setBusyKey] = useState("");
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [capacityResponse, alertResponse] = await Promise.all([platformApiClient.getProductionCapacity(), platformApiClient.listAlerts()]);
setCapacity(capacityResponse);
setAlerts(alertResponse.items);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "生产治理状态加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function submitIntent() {
if (!intent || busyKey) return;
const key = `${intent.alert.id}:${intent.action}`;
setBusyKey(key);
setResult(null);
try {
if (intent.action === "acknowledge") {
await platformApiClient.acknowledgeAlert(intent.alert.id, "operator acknowledged from production console");
} else if (intent.action === "resolve") {
await platformApiClient.resolveAlert(intent.alert.id, "operator resolved after production review");
} else {
await platformApiClient.retryAlert(intent.alert.id, `web:alert.retry:${intent.alert.id}:${Date.now()}`);
}
setResult({ status: "succeeded", label: `${alertActionLabel(intent.action)}已由 Platform 持久化` });
setIntent(null);
await refresh();
} catch (caught) {
setResult({ status: "failed", label: caught instanceof Error ? caught.message : `${alertActionLabel(intent.action)}失败` });
setIntent(null);
} finally {
setBusyKey("");
}
}
const visibleAlerts = compact ? alerts.filter((alert) => alert.state !== "resolved").slice(0, 3) : alerts.slice(0, 12);
const visibleEndpoints = compact ? capacity?.endpoints.slice(0, 3) ?? [] : capacity?.endpoints ?? [];
return (
<section className="console-panel production-governance-panel" aria-label="production capacity and alerts">
<div className="panel-header">
<h2><AlertTriangle size={16} /> {title}</h2>
<button type="button" className="icon-command" disabled={loading || Boolean(busyKey)} onClick={() => void refresh()} title="刷新容量与告警">
<RotateCw size={14} />
<span></span>
</button>
</div>
{result && <ResultBadge status={result.status} label={result.label} />}
{loading && <LoadingState label="正在同步容量与告警…" compact />}
{!loading && error && <ErrorState title="生产治理状态不可用" reason={error} diagnosticId="production-governance" onRetry={() => void refresh()} compact />}
{!loading && !error && capacity && (
<>
<dl className="operations-pulse-strip production-capacity-strip">
<div><dt></dt><dd>{capacity.totalRunningJobs}/{capacity.totalMaxJobs}</dd></div>
<div><dt></dt><dd>{capacity.totalQueuedJobs}</dd></div>
<div><dt></dt><dd>{capacity.activeAlerts}</dd></div>
</dl>
<div className="operations-endpoint-list" aria-label="capacity endpoints">
{visibleEndpoints.map((endpoint) => (
<div key={endpoint.runEndpointId} className="operations-endpoint-row">
<span><strong>{endpoint.displayName}</strong><small>{endpoint.pressureCodes?.join(", ") || "capacity.available"}</small></span>
<span className={cx("status-pill", endpoint.pressureCodes?.length ? "status-warning" : `status-${endpoint.status}`)}>{endpoint.pressureCodes?.length ? "压力" : endpoint.status}</span>
<span>{endpoint.runningJobs}/{endpoint.maxJobs} · {endpoint.queuedJobs}</span>
</div>
))}
</div>
<div className="operation-list production-alert-list" aria-label="durable alerts">
{visibleAlerts.length === 0 && <p className="operations-module-empty"></p>}
{visibleAlerts.map((alert) => {
const pending = busyKey.startsWith(`${alert.id}:`);
return (
<div key={alert.id} className="operation-item">
<div className="operation-item-head">
<strong>{alert.title}</strong>
<span className={cx("status-pill", alert.severity === "critical" ? "status-failed" : alert.state === "resolved" ? "status-succeeded" : "status-warning")}>{alert.state}</span>
</div>
<p>{alert.message}</p>
<div className="operation-meta">
<span>{alert.sourceKind} · {alert.sourceId}</span>
<span> {alert.occurrenceCount} </span>
{alert.lastJobId && <span> {alert.lastJobId}</span>}
{alert.lastAuditEventId && <span> {alert.lastAuditEventId}</span>}
</div>
{alert.state !== "resolved" && (
<div className="row-actions production-alert-actions">
{alert.state === "active" && <button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "acknowledge" })}><Check size={14} /><span></span></button>}
<button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "resolve" })}><CheckCheck size={14} /><span></span></button>
{alert.retryable && <button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "retry" })}><Activity size={14} /><span></span></button>}
</div>
)}
</div>
);
})}
</div>
</>
)}
<ConfirmDialog
open={intent !== null}
title={intent ? `${alertActionLabel(intent.action)}告警` : "告警操作"}
description={intent ? `目标 ${intent.alert.id},仅处理来源 ${intent.alert.sourceKind}/${intent.alert.sourceId}` : "确认告警操作。"}
confirmLabel={intent ? alertActionLabel(intent.action) : "确认"}
danger={intent?.action === "resolve"}
busy={Boolean(busyKey)}
onCancel={() => { if (!busyKey) setIntent(null); }}
onConfirm={() => void submitIntent()}
/>
</section>
);
}
function alertActionLabel(action: AlertAction) {
if (action === "acknowledge") return "确认";
if (action === "resolve") return "解决";
return "重试来源";
}
@@ -0,0 +1,23 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
import { ProductionGovernancePanel } from "./ProductionGovernancePanel";
import governanceSource from "./ProductionGovernancePanel.tsx?raw";
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
describe("production operations components", () => {
it("renders persisted loading states without optimistic terminal success", () => {
expect(renderToStaticMarkup(<ProductionGovernancePanel />)).toContain("正在同步容量与告警");
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
for (const source of [governanceSource, lifecycleSource, diffSource]) {
expect(source).not.toContain("setTimeout");
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
expect(source).toContain("disabled=");
}
expect(governanceSource).toContain("if (!intent || busyKey) return");
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
expect(diffSource).toContain("if (!selected || busyId) return");
});
});
@@ -0,0 +1,101 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { GameClientBridgeSnapshotResponse } from "../api/types";
import type { ScumOperationsPageContract } from "../contracts/scumOperations";
import { ScumOperationsPanel, type ScumOperationsPanelData } from "./ScumOperationsPanel";
import scumOperationsPanelSource from "./ScumOperationsPanel.tsx?raw";
const now = "2026-07-20T08:00:00Z";
const contract: ScumOperationsPageContract = {
pluginId: "game.scum",
routeKey: "operations",
serverInstanceId: "server-1",
title: "SCUM 运维",
permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"],
bridgeActions: ["server.instances.read", "logs.query", "client-manager.request"],
commands: [
{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 },
{ type: "companion.diagnostics", title: "Collect diagnostics", permission: "server.game-client.read", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/diagnostics.json", timeoutSeconds: 30, maxPayloadBytes: 2048 },
{ type: "restart.prepare", title: "Prepare restart", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", payloadSchemaRef: "schemas/bridge/restart.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }
],
snapshots: ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"].map((type) => ({ type, schemaVersion: "1", schemaRef: `schemas/bridge/${type}.json`, keepForSeconds: 3600, maxRecords: 24 })),
queryTemplates: [],
logSources: [{ key: "scum-chat-events", kind: "file.tail", streamKey: "scum.chat", retentionDays: 30 }],
logEvents: [
{ key: "scum-chat", title: "SCUM chat", sourceKey: "scum-chat-events", eventType: "scum.chat", permission: "server.logs.read", schemaRef: "schemas/log-events/chat.json", retentionDays: 30, severity: "info" },
{ key: "scum-kill", title: "SCUM kill", sourceKey: "scum-chat-events", eventType: "scum.kill", permission: "server.logs.read", schemaRef: "schemas/log-events/kill.json", retentionDays: 30, severity: "warning" }
],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }
};
function snapshot(type: string, payload: GameClientBridgeSnapshotResponse["payload"]): GameClientBridgeSnapshotResponse {
return { id: `snapshot-${type}`, serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client", type, schemaVersion: "1", streamKey: "current", sequence: 1, observedAt: now, payload, retention: { keepForSeconds: 3600, maxRecords: 24 }, createdAt: now, expiresAt: "2026-07-20T09:00:00Z" };
}
const data: ScumOperationsPanelData = {
status: { serverInstanceId: "server-1", pluginId: "game.scum", available: true, profiles: [{ pluginId: "game.scum", profileKey: "scum-client", available: true, commandTypes: ["announcement.send", "companion.diagnostics", "restart.prepare"], snapshotTypes: contract.snapshots.map((item) => item.type), queryTemplateKeys: [] }] },
commands: [{ id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client", commandType: "announcement.send", priority: 50, state: "succeeded", approvalState: "approved", resultSummary: "announcement delivered", result: { status: "succeeded", summary: "announcement delivered", payload: { delivered: true }, completedAt: now }, expiresAt: now, createdAt: now, updatedAt: now, completedAt: now }],
snapshots: [
snapshot("companion.health", { status: "online", observedAt: now, version: "1.0.0", latencyMs: 20 }),
snapshot("online.sessions", { observedAt: now, onlineCount: 1, sessions: [{ sessionId: "game-session-1", playerName: "Moonlight" }] }),
snapshot("players", { observedAt: now, players: [{ playerId: "player-1", playerName: "Moonlight", status: "online" }] }),
snapshot("squads", { observedAt: now, squads: [{ squadId: "squad-1", name: "Lunar", memberCount: 4 }] }),
snapshot("vehicles", { observedAt: now, vehicles: [{ vehicleId: "vehicle-1", vehicleType: "truck", status: "parked" }] }),
snapshot("flags", { observedAt: now, flags: [{ flagId: "flag-1", status: "active" }] })
],
logs: [{ streamKey: "scum.chat", eventType: "scum.chat", entry: { seq: 1, timestamp: now, level: "info", line: "token=raw-secret /Users/operator/scum.log", redacted: true } }],
backups: [{ id: "backup-1", serverInstanceId: "server-1", artifactId: "artifact-1", checksum: "sha256:safe", sizeBytes: 2048, state: "available", recoveryStatus: "verified", retentionUntil: "2026-07-27T08:00:00Z", createdAt: now, updatedAt: now }],
errors: []
};
describe("ScumOperationsPanel", () => {
it("renders the first safe SCUM operations surface", () => {
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={data} />);
for (const label of ["Companion", "命令队列与结果", "玩家与世界状态快照", "玩家 1", "会话 1", "小队 1", "载具 1", "旗帜 1", "语义日志", "scum.chat", "维护与备份策略", "backup-1", "已批准"]) {
expect(html).toContain(label);
}
expect(html).toContain("不触发自动封禁或惩罚");
expect(html).not.toContain("raw-secret");
expect(html).not.toContain("/Users/");
expect(html).not.toMatch(/sessionToken|componentKey|secretRef|hostPath|dsn|runSocket|credential/i);
});
it("keeps commands disabled with a visible bridge availability reason", () => {
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, status: { ...data.status!, available: false, reason: "compatible companion is offline", profiles: data.status!.profiles.map((profile) => ({ ...profile, available: false, reason: "component heartbeat is unavailable" })) } }} />);
expect(html).toContain("compatible companion is offline");
expect(html).toContain("disabled");
const profileReasonHtml = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, status: { ...data.status!, available: true, reason: undefined, profiles: data.status!.profiles.map((profile) => ({ ...profile, available: false, reason: "component heartbeat is unavailable" })) } }} />);
expect(profileReasonHtml).toContain("component heartbeat is unavailable");
expect(profileReasonHtml).toContain("disabled");
});
it("renders every command approval state without leaking unsafe result details", () => {
const approvalStates = ["not_required", "pending", "approved", "rejected"] as const;
const unsafeSummary = "token=raw-command-secret /Users/operator/result.json unix:///var/run/scum.sock";
const commands = approvalStates.map((approvalState, index) => ({
...data.commands[0]!,
id: `command-${index + 1}`,
approvalState,
resultSummary: approvalState === "rejected" ? unsafeSummary : `approval ${approvalState}`,
result: approvalState === "rejected" ? { ...data.commands[0]!.result!, summary: unsafeSummary } : data.commands[0]!.result
}));
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, commands }} />);
for (const label of ["无需审批", "待审批", "已批准", "已拒绝"]) {
expect(html).toContain(label);
}
expect(html).not.toContain("raw-command-secret");
expect(html).not.toContain("/Users/operator");
expect(html).not.toContain("unix:///var/run");
});
it("uses shared console surfaces without page-local ambient decoration", () => {
expect(scumOperationsPanelSource).toContain('className="console-panel"');
expect(scumOperationsPanelSource).toContain('className="resource-table-wrap"');
expect(scumOperationsPanelSource).not.toMatch(/position:\s*fixed|sparkle|snowflake|magic-circle|backdrop-layer/i);
expect(scumOperationsPanelSource).not.toContain("InsecureSkipVerify");
});
});
@@ -0,0 +1,436 @@
import { Activity, BellRing, Database, RefreshCw, ShieldCheck, UsersRound, Wrench } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
BackupResponse,
GameClientBridgeCommandResponse,
GameClientBridgeJsonObject,
GameClientBridgeSnapshotResponse,
GameClientBridgeStatusResponse,
LogEntryBody,
LogStreamResponse
} from "../api/types";
import type { ScumOperationsPageContract, ScumOperationsSnapshotView } from "../contracts/scumOperations";
import { projectScumOperationsSnapshots } from "../schemas/scumOperations";
import { cx } from "../utils/classes";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
type SnapshotSection = "players" | "sessions" | "squads" | "vehicles" | "flags";
export interface ScumSemanticLogView {
streamKey: string;
eventType: string;
entry: LogEntryBody;
}
export interface ScumOperationsPanelData {
status?: GameClientBridgeStatusResponse;
commands: GameClientBridgeCommandResponse[];
snapshots: GameClientBridgeSnapshotResponse[];
logs: ScumSemanticLogView[];
backups: BackupResponse[];
errors: string[];
}
type ScumOperationsPanelState =
| { status: "loading" }
| { status: "error"; reason: string }
| { status: "ready"; data: ScumOperationsPanelData };
interface ScumOperationsPanelProps {
contract: ScumOperationsPageContract;
initialData?: ScumOperationsPanelData;
}
export function ScumOperationsPanel({ contract, initialData }: ScumOperationsPanelProps) {
const [state, setState] = useState<ScumOperationsPanelState>(() => initialData ? { status: "ready", data: initialData } : { status: "loading" });
const [snapshotSection, setSnapshotSection] = useState<SnapshotSection>("players");
const [announcement, setAnnouncement] = useState("");
const [pendingCommand, setPendingCommand] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const refresh = useCallback(async () => {
setState({ status: "loading" });
const results = await Promise.allSettled([
platformApiClient.getGameClientBridgeStatus(contract.serverInstanceId),
platformApiClient.listGameClientBridgeCommands(contract.serverInstanceId),
platformApiClient.listGameClientBridgeSnapshots(contract.serverInstanceId, { limit: 200 }),
loadSemanticLogs(contract),
platformApiClient.listBackups(contract.serverInstanceId)
]);
const errors = results.flatMap((result, index) => result.status === "rejected" ? [loadErrorLabel(index, result.reason)] : []);
const status = settledValue(results[0]);
const commands = settledValue(results[1]);
const snapshots = settledValue(results[2]);
const logs = settledValue(results[3]);
const backups = settledValue(results[4]);
if (!status && !commands && !snapshots) {
setState({ status: "error", reason: errors.join("") || "Game Client Bridge 核心投影不可用。" });
return;
}
setState({
status: "ready",
data: {
status,
commands: commands?.items ?? [],
snapshots: snapshots?.items ?? [],
logs: logs ?? [],
backups: backups?.items ?? [],
errors
}
});
}, [contract]);
useEffect(() => {
if (!initialData) {
void refresh();
}
}, [initialData, refresh]);
const snapshots = useMemo(() => state.status === "ready" ? projectScumOperationsSnapshots(state.data.snapshots) : emptySnapshotView(), [state]);
if (state.status === "loading") {
return <LoadingState label="正在加载 SCUM Bridge 运维投影…" />;
}
if (state.status === "error") {
return <ErrorState title="SCUM 运维投影不可用" reason={state.reason} onRetry={() => void refresh()} />;
}
const data = state.data;
const bridgeAvailable = data.status?.available === true;
const bridgeReason = data.status?.reason || data.status?.profiles.find((profile) => !profile.available)?.reason;
const profile = data.status?.profiles.find((candidate) => candidate.available) ?? data.status?.profiles[0];
const canQueue = bridgeAvailable && profile?.available === true;
const diagnostics = contract.commands.find((command) => command.type === "companion.diagnostics");
const announcementDeclaration = contract.commands.find((command) => command.type === "announcement.send");
const announcementAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("announcement.send")) === true;
const diagnosticsAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("companion.diagnostics")) === true;
const latestSnapshotAt = data.snapshots.reduce((latest, snapshot) => snapshot.observedAt > latest ? snapshot.observedAt : latest, "");
async function queueCommand(commandType: string, payload: GameClientBridgeJsonObject) {
const declaration = contract.commands.find((command) => command.type === commandType);
const commandProfile = data.status?.profiles.find((candidate) => candidate.available && candidate.commandTypes.includes(commandType));
if (!declaration || !commandProfile) {
setActionResult({ status: "failed", label: "命令未在当前可用 Bridge profile 中声明。" });
return;
}
setPendingCommand(commandType);
setActionResult({ status: "pending", label: `正在提交 ${declaration.title}` });
try {
const expirySeconds = Math.max(300, Math.min(3600, declaration.timeoutSeconds * 2));
const queued = await platformApiClient.queueGameClientBridgeCommand(contract.serverInstanceId, {
profileKey: commandProfile.profileKey,
commandType,
payload,
idempotencyKey: `web-scum-${commandType.replaceAll(".", "-")}-${Date.now()}`,
priority: 50,
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString()
});
setState((current) => current.status === "ready" ? {
status: "ready",
data: { ...current.data, commands: [queued, ...current.data.commands.filter((command) => command.id !== queued.id)] }
} : current);
setAnnouncement("");
setActionResult({ status: "succeeded", label: `命令已进入队列,审批状态:${approvalLabel(queued.approvalState)}` });
} catch (error) {
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "命令提交失败" });
} finally {
setPendingCommand(null);
}
}
function submitAnnouncement(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const message = announcement.trim();
if (!message || message.length > 500) {
setActionResult({ status: "failed", label: "公告内容必须为 1500 个字符。" });
return;
}
void queueCommand("announcement.send", { message });
}
return (
<div className="console-page" aria-label="SCUM operations">
<div className="panel-header">
<div>
<strong>Game Client Bridge </strong>
<p className="provider-id"> Platform DTO game.scum Run socket</p>
</div>
<button type="button" className="icon-command" onClick={() => void refresh()}>
<RefreshCw size={14} aria-hidden="true" />
<span></span>
</button>
</div>
{data.errors.length > 0 && <ErrorState title="部分运维投影不可用" reason={data.errors.join("")} compact />}
<div className="console-grid" aria-label="SCUM operations summary">
<Metric label="Bridge" value={bridgeAvailable ? "可用" : "不可用"} detail={bridgeReason || profile?.profileKey || "未声明 profile"} tone={bridgeAvailable ? "success" : "warning"} />
<Metric label="Companion" value={healthLabel(snapshots.health?.status)} detail={snapshots.health?.observedAt ? `观测于 ${formatTime(snapshots.health.observedAt)}` : "暂无健康快照"} tone={snapshots.health?.status === "online" ? "success" : "warning"} />
<Metric label="在线会话" value={String(snapshots.sessions.total)} detail={snapshots.sessions.observedAt ? `快照 ${formatTime(snapshots.sessions.observedAt)}` : "暂无会话快照"} tone="neutral" />
<Metric label="最近快照" value={latestSnapshotAt ? formatTime(latestSnapshotAt) : "--"} detail={`${data.snapshots.length} 条安全投影`} tone="neutral" />
</div>
<section className="console-panel" aria-label="SCUM command queue">
<div className="panel-header">
<h2><BellRing size={16} aria-hidden="true" /> </h2>
<span className="page-status">{canQueue ? "Bridge 可提交" : bridgeReason || "Bridge 不可提交"}</span>
</div>
<div className="action-list">
{announcementDeclaration && (
<form className="provider-form" onSubmit={submitAnnouncement} aria-label="发送 SCUM 公告">
<div className="form-grid">
<label>
<input value={announcement} maxLength={500} onChange={(event) => setAnnouncement(event.target.value)} placeholder="输入 1500 字公告" disabled={!announcementAvailable || pendingCommand !== null} />
</label>
</div>
<button type="submit" className="icon-command" disabled={!announcementAvailable || pendingCommand !== null || !announcement.trim()}>
<BellRing size={14} aria-hidden="true" />
<span>{pendingCommand === "announcement.send" ? "提交中" : `提交公告 · ${approvalLevelLabel(announcementDeclaration.approvalLevel)}`}</span>
</button>
</form>
)}
{diagnostics && (
<div className="plugin-control-row">
<span><strong>{diagnostics.title}</strong><p> 10 </p></span>
<button type="button" className="icon-command" disabled={!diagnosticsAvailable || pendingCommand !== null} onClick={() => void queueCommand("companion.diagnostics", { includeWindowState: true, maxEntries: 10 })}>
<Activity size={14} aria-hidden="true" />
<span>{pendingCommand === "companion.diagnostics" ? "提交中" : "采集诊断"}</span>
</button>
</div>
)}
{actionResult && <ResultBadge status={actionResult.status} label={safeDiagnosticText(actionResult.label) ?? actionResult.label} />}
</div>
<CommandTable commands={data.commands} />
</section>
<section className="console-panel" aria-label="SCUM snapshot projections">
<div className="panel-header">
<h2><UsersRound size={16} aria-hidden="true" /> </h2>
<div className="action-strip" role="group" aria-label="SCUM snapshot sections">
{snapshotTabs(snapshots).map((tab) => (
<button key={tab.key} type="button" className={cx("icon-command")} aria-pressed={snapshotSection === tab.key} onClick={() => setSnapshotSection(tab.key)}>
<span>{tab.label} {tab.count}</span>
</button>
))}
</div>
</div>
<SnapshotTable section={snapshotSection} snapshots={snapshots} />
</section>
<section className="console-panel" aria-label="SCUM semantic logs">
<div className="panel-header">
<h2><Database size={16} aria-hidden="true" /> </h2>
<span className="page-status">{contract.logEvents.length} </span>
</div>
<div className="action-list">
<span>{contract.logEvents.map((event) => event.eventType).join(" / ") || "插件未声明语义日志事件"}</span>
<span>线</span>
</div>
<SemanticLogTable logs={data.logs} />
</section>
<section className="console-panel" aria-label="SCUM maintenance policy">
<div className="panel-header">
<h2><Wrench size={16} aria-hidden="true" /> </h2>
<span className="page-status"></span>
</div>
<div className="action-list">
<span><strong></strong> {contract.productionLifecycle.dependencyPolicy === "required" ? "必须满足依赖后执行" : "依赖为可选项"}</span>
<span><strong></strong> {contract.productionLifecycle.approvalRequired.join(" / ") || "无额外声明"}</span>
{contract.commands.filter((command) => command.permission === "server.game-client.maintenance").map((command) => (
<span key={command.type}><ShieldCheck size={14} aria-hidden="true" /> {command.title} · {approvalLevelLabel(command.approvalLevel)}</span>
))}
<span>Platform </span>
</div>
<BackupTable backups={data.backups} />
</section>
</div>
);
}
async function loadSemanticLogs(contract: ScumOperationsPageContract): Promise<ScumSemanticLogView[]> {
const streamsResponse = await platformApiClient.listLogStreams();
const sourceByKey = new Map(contract.logSources.map((source) => [source.key, source]));
const eventTypesByStream = new Map<string, string[]>();
for (const event of contract.logEvents) {
const streamKey = sourceByKey.get(event.sourceKey)?.streamKey;
if (!streamKey) continue;
eventTypesByStream.set(streamKey, [...(eventTypesByStream.get(streamKey) ?? []), event.eventType]);
}
const streams = streamsResponse.items.filter((stream) => stream.serverInstanceId === contract.serverInstanceId && eventTypesByStream.has(stream.streamKey)).slice(0, 12);
const results = await Promise.allSettled(streams.map(async (stream) => ({
stream,
response: await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 50), limit: 50 })
})));
return results.flatMap((result) => {
if (result.status === "rejected") return [];
return result.value.response.entries.map((entry) => ({
streamKey: result.value.stream.streamKey,
eventType: declaredEventType(entry, result.value.stream, eventTypesByStream),
entry
}));
}).sort((left, right) => right.entry.timestamp.localeCompare(left.entry.timestamp)).slice(0, 100);
}
function declaredEventType(entry: LogEntryBody, stream: LogStreamResponse, eventTypesByStream: Map<string, string[]>): string {
const declared = eventTypesByStream.get(stream.streamKey) ?? [];
const projected = entry.fields?.eventType;
return projected && declared.includes(projected) ? projected : declared.join(" / ") || stream.streamKey;
}
function settledValue<T>(result: PromiseSettledResult<T>): T | undefined {
return result.status === "fulfilled" ? result.value : undefined;
}
function loadErrorLabel(index: number, reason: unknown): string {
const labels = ["Bridge 状态", "命令队列", "快照", "语义日志", "备份记录"];
const detail = safeDiagnosticText(reason instanceof Error ? reason.message : String(reason), "加载失败");
return `${labels[index] ?? "运维数据"}${detail}`;
}
function Metric({ label, value, detail, tone }: { label: string; value: string; detail: string; tone: "neutral" | "success" | "warning" }) {
return (
<article className={cx("metric-card", `metric-tone-${tone}`)}>
<span className="metric-label">{label}</span>
<strong className="metric-value">{value}</strong>
<p className="metric-detail">{detail}</p>
</article>
);
}
function CommandTable({ commands }: { commands: GameClientBridgeCommandResponse[] }) {
if (commands.length === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{commands.slice(0, 50).map((command) => (
<tr key={command.id}>
<td><strong>{command.commandType}</strong><span className="provider-id">{command.id}</span></td>
<td>{commandStateLabel(command.state)}</td>
<td>{approvalLabel(command.approvalState)}</td>
<td>{safeDiagnosticText(command.result?.summary || command.resultSummary, "--") || "--"}</td>
<td>{formatTime(command.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function snapshotTabs(snapshots: ScumOperationsSnapshotView): Array<{ key: SnapshotSection; label: string; count: number }> {
return [
{ key: "players", label: "玩家", count: snapshots.players.total },
{ key: "sessions", label: "会话", count: snapshots.sessions.total },
{ key: "squads", label: "小队", count: snapshots.squads.total },
{ key: "vehicles", label: "载具", count: snapshots.vehicles.total },
{ key: "flags", label: "旗帜", count: snapshots.flags.total }
];
}
function SnapshotTable({ section, snapshots }: { section: SnapshotSection; snapshots: ScumOperationsSnapshotView }) {
const configuration: Record<SnapshotSection, { headers: string[]; rows: ReactNode[][]; total: number }> = {
players: { headers: ["玩家", "状态", "小队", "延迟", "最后出现"], total: snapshots.players.total, rows: snapshots.players.items.map((item) => [`${item.playerName} · ${item.playerId}`, item.status, item.squadId ?? "--", item.pingMs === undefined ? "--" : `${item.pingMs} ms`, formatTime(item.lastSeenAt)]) },
sessions: { headers: ["会话", "玩家", "开始时间"], total: snapshots.sessions.total, rows: snapshots.sessions.items.map((item) => [item.sessionId, item.playerName, formatTime(item.startedAt)]) },
squads: { headers: ["小队", "成员", "队长", "最后活跃"], total: snapshots.squads.total, rows: snapshots.squads.items.map((item) => [`${item.name} · ${item.squadId}`, item.memberCount, item.leaderPlayerId ?? "--", formatTime(item.lastActiveAt)]) },
vehicles: { headers: ["载具", "状态", "所有者 / 小队", "燃油 / 耐久", "最后出现"], total: snapshots.vehicles.total, rows: snapshots.vehicles.items.map((item) => [`${item.vehicleType} · ${item.vehicleId}`, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, `${percent(item.fuelPercent)} / ${percent(item.healthPercent)}`, formatTime(item.lastSeenAt)]) },
flags: { headers: ["旗帜", "状态", "所有者 / 小队", "半径", "更新时间"], total: snapshots.flags.total, rows: snapshots.flags.items.map((item) => [item.flagId, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, item.radiusMeters === undefined ? "--" : `${item.radiusMeters} m`, formatTime(item.lastUpdatedAt)]) }
};
const table = configuration[section];
if (table.total === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr>{table.headers.map((header) => <th key={header}>{header}</th>)}</tr></thead>
<tbody>{table.rows.map((row, rowIndex) => <tr key={`${section}-${rowIndex}`}>{row.map((cell, index) => <td key={`${index}-${String(cell)}`}>{cell}</td>)}</tr>)}</tbody>
</table>
{table.total > table.rows.length && <p className="provider-id"> {table.rows.length} / {table.total} </p>}
</div>
);
}
function SemanticLogTable({ logs }: { logs: ScumSemanticLogView[] }) {
if (logs.length === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{logs.slice(0, 100).map((log) => (
<tr key={`${log.streamKey}-${log.entry.seq}`}>
<td>{log.eventType}</td>
<td>{formatTime(log.entry.timestamp)}</td>
<td>{log.entry.level || "info"}{log.entry.redacted ? " · 已脱敏" : ""}</td>
<td>{safeDiagnosticText(log.entry.line, "日志内容已隐藏")}</td>
</tr>
))}</tbody>
</table>
</div>
);
}
function BackupTable({ backups }: { backups: BackupResponse[] }) {
if (backups.length === 0) return <p className="provider-id"> Platform </p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{backups.slice(0, 20).map((backup) => (
<tr key={backup.id}><td>{backup.id}</td><td>{backup.state}</td><td>{formatBytes(backup.sizeBytes)}</td><td>{backup.recoveryStatus || "--"}</td><td>{formatTime(backup.retentionUntil)}</td></tr>
))}</tbody>
</table>
</div>
);
}
function emptySnapshotView(): ScumOperationsSnapshotView {
const empty = { total: 0, items: [] };
return { sessions: { ...empty }, players: { ...empty }, squads: { ...empty }, vehicles: { ...empty }, flags: { ...empty } };
}
function approvalLevelLabel(level: string): string {
if (level === "platform-admin") return "需平台管理员审批";
if (level === "operator") return "需操作员审批";
return "无需额外审批";
}
function approvalLabel(state: string): string {
if (state === "not_required") return "无需审批";
if (state === "approved") return "已批准";
if (state === "rejected") return "已拒绝";
return "待审批";
}
function commandStateLabel(state: string): string {
const labels: Record<string, string> = { pending: "等待领取", claimed: "执行中", succeeded: "成功", failed: "失败", cancelled: "已取消", expired: "已过期" };
return labels[state] ?? state;
}
function healthLabel(status: string | undefined): string {
if (status === "online") return "在线";
if (status === "degraded") return "降级";
if (status === "offline") return "离线";
return "未知";
}
function formatTime(value: string | undefined): string {
if (!value) return "--";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "--" : parsed.toLocaleString("zh-CN", { hour12: false });
}
function percent(value: number | undefined): string {
return value === undefined ? "--" : `${Math.round(value)}%`;
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0 B";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { AlertTriangle, CheckCircle2, Copy, Loader2, MoonStar, Sparkles, XCircle
import type { ReactNode } from "react";
import { cx } from "../utils/classes";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
interface EmptyStateProps {
title: string;
@@ -58,7 +59,7 @@ export function ErrorState({ title, reason, diagnosticId, onRetry, compact }: Er
<AlertTriangle size={compact ? 16 : 24} />
</span>
<strong>{title}</strong>
{reason && <p>{reason}</p>}
{reason && <p>{safeDiagnosticText(reason)}</p>}
{diagnosticId && <DiagnosticSummary diagnosticId={diagnosticId} />}
{onRetry && (
<button type="button" className="state-action" onClick={onRetry}>
@@ -81,7 +82,7 @@ export function ResultBadge({ status, label }: ResultBadgeProps) {
return (
<span className={cx("result-badge", `result-badge-${status}`)}>
{icon}
<span>{label}</span>
<span>{status === "failed" ? safeDiagnosticText(label) : label}</span>
</span>
);
}