Remove pre-1.0 audit and protected request scaffolding

This commit is contained in:
npc0-hue
2026-08-20 23:42:02 +08:00
parent 40b35b05c7
commit a7e2e4c6c0
130 changed files with 526 additions and 3767 deletions
@@ -181,7 +181,7 @@ function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmComman
{(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>
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 job 失败原因后选择重新部署、回滚或卸载。"}</span>
</div>
)}
@@ -196,7 +196,7 @@ function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmComman
<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 保留 buildartifact 与审计历史`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
<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 保留 buildartifact 记录`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
</div>
</section>
);
@@ -10,7 +10,7 @@ describe("shared operation dialogs", () => {
<ConfirmDialog
open
title="确认停用"
description="会保留审计记录"
description="该操作会更新用户状态"
confirmLabel="停用"
busy
onConfirm={() => undefined}
+1 -1
View File
@@ -38,7 +38,7 @@ export function OperationsTray({ operations }: OperationsTrayProps) {
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
<div className="operations-tray-heading">
<strong></strong>
<span> Platform </span>
<span> Platform </span>
</div>
{items.length === 0 ? (
<p className="operations-tray-empty"></p>
@@ -63,7 +63,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
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(" · ");
const evidence = [response.job?.id && `任务 ${response.job.id}`, 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();
@@ -102,7 +102,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
<div className="console-record-head"><strong>{installation.currentState} {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
<div className="console-record-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>}
{installation.jobId && <span> {installation.jobId}</span>}{installation.alertId && <span> {installation.alertId}</span>}
</div>
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
</div>
@@ -2,21 +2,21 @@ 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 { ProductionOperationsPanel } from "./ProductionOperationsPanel";
import operationsSource from "./ProductionOperationsPanel.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(<ProductionOperationsPanel />)).toContain("正在同步容量与告警");
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
for (const source of [governanceSource, lifecycleSource, diffSource]) {
for (const source of [operationsSource, 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(operationsSource).toContain("if (!intent || busyKey) return");
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
expect(diffSource).toContain("if (!selected || busyId) return");
});
@@ -9,12 +9,12 @@ import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
type AlertAction = "acknowledge" | "resolve" | "retry";
interface ProductionGovernancePanelProps {
interface ProductionOperationsPanelProps {
compact?: boolean;
title?: string;
}
export function ProductionGovernancePanel({ compact = false, title = "容量与告警" }: ProductionGovernancePanelProps) {
export function ProductionOperationsPanel({ compact = false, title = "容量与告警" }: ProductionOperationsPanelProps) {
const [capacity, setCapacity] = useState<ProductionCapacitySummaryResponse | null>(null);
const [alerts, setAlerts] = useState<AlertResponse[]>([]);
const [loading, setLoading] = useState(true);
@@ -31,7 +31,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
setCapacity(capacityResponse);
setAlerts(alertResponse.items);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "生产治理状态加载失败");
setError(caught instanceof Error ? caught.message : "生产状态加载失败");
} finally {
setLoading(false);
}
@@ -69,7 +69,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
const visibleEndpoints = compact ? capacity?.endpoints.slice(0, 3) ?? [] : capacity?.endpoints ?? [];
return (
<section className="console-panel console-module production-governance-panel" aria-label="production capacity and alerts">
<section className="console-panel console-module production-operations-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="刷新容量与告警">
@@ -79,7 +79,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
</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 && <ErrorState title="生产状态不可用" reason={error} diagnosticId="production-operations" onRetry={() => void refresh()} compact />}
{!loading && !error && capacity && (
<>
<dl className="console-stat-strip console-stat-strip-spaced">
@@ -111,7 +111,6 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
<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 console-row-actions production-alert-actions">
@@ -87,7 +87,7 @@ export const runtimeKeyResetStages: RuntimeTaskStage[] = [
export const runtimeDependencyStages: RuntimeTaskStage[] = [
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
{ key: "install_prepare", label: "安装环境", description: "准备安全、可审计的依赖安装任务。" },
{ key: "install_prepare", label: "安装环境", description: "准备依赖安装任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
@@ -111,14 +111,14 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong> Run </strong><span>Run </span></div></div>
<div className="form-grid"><label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
</div>}
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy"></p>{isScum && <div className="form-guidance"><strong>SCUM </strong><span>Run </span></div>}<div className="deployment-mode-grid">
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy"></p>{isScum && <div className="form-guidance"><strong>SCUM </strong><span>Run </span></div>}<div className="deployment-mode-grid">
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由 Run 策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
</div></div>}
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong></strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}></button></>}</div>}<div className="form-grid">
{kind === "edit" && <label><select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select><small className="field-help">Run </small></label>}
{form.deploymentMode === "guided-install" && <label>{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">SCUM </small></label>}
{form.deploymentMode === "guided-install" && <label>{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">SCUM </small></label>}
{form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>}
{form.deploymentMode === "custom-command" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help"></small></label>}
{form.deploymentMode === "guided-install" && pluginFields.map((field) => (
@@ -150,7 +150,7 @@ function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum:
] : [
{ icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" },
{ icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" },
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给受控部署流程。" },
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给自动部署流程。" },
{ icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" }
];
@@ -162,15 +162,15 @@ function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写包含 SCUM 服务端文件、数据与配置的目录,不是 Steam 库或 SteamCMD 目录。" },
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、可执行文件、版本、Steam App 标记和所需端口。" },
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "识别 ServerSettings.ini 与现有参数;接管不会写入或覆盖它们。" },
{ icon: ServerCog, title: "建立受控生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走受控通道。" },
{ icon: ServerCog, title: "建立生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走平台通道。" },
{ icon: HeartPulse, title: "健康验证", copy: "确认端口、进程与配置可读后,才标记为接管成功。" }
] : [
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" },
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" },
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" },
{ icon: ServerCog, title: "建立受控生命周期", copy: "后续运行操作由自动识别的 Run 通过平台通道执行。" },
{ icon: ServerCog, title: "建立生命周期", copy: "后续运行操作由自动识别的 Run 通过平台通道执行。" },
{ icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" }
];
return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span> Run 使</span></div><small></small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM SteamCMD</strong> SteamCMD Run SteamCMD <br /><strong></strong> SCUM SteamCMD /</p> : <p><strong></strong></p>}</section>;
return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span> Run 使</span></div><small></small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM SteamCMD</strong> SteamCMD Run SteamCMD <br /><strong></strong> SCUM SteamCMD /</p> : <p><strong></strong></p>}</section>;
}
@@ -4,15 +4,15 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types";
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
const apiMocks = vi.hoisted(() => ({
getGameClientBridgeCommand: vi.fn(),
dispatchSourceRCONCommand: vi.fn(),
getJob: vi.fn(),
listLogStreams: vi.fn(),
openServerLogEvents: vi.fn(),
queryLogStream: vi.fn(),
queueGameClientBridgeCommand: vi.fn()
queryLogStream: vi.fn()
}));
vi.mock("../api/client", () => ({ platformApiClient: apiMocks }));
@@ -85,7 +85,7 @@ describe("ServerManagementTerminalDrawer", () => {
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1);
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
});
it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
@@ -97,7 +97,7 @@ describe("ServerManagementTerminalDrawer", () => {
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
expect(container?.textContent).not.toContain("legacy output must stay historical");
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
});
it("clears generation A on a new session and rejects late generation A events", async () => {
@@ -166,18 +166,18 @@ describe("ServerManagementTerminalDrawer", () => {
expect(container?.textContent).not.toContain("selected historical output");
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 });
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
});
it("uses RCON only after an operator submits a command", async () => {
const pending = bridgeCommand("pending");
const succeeded = bridgeCommand("succeeded");
apiMocks.queueGameClientBridgeCommand.mockResolvedValue(pending);
apiMocks.getGameClientBridgeCommand.mockResolvedValue(succeeded);
const pending = sourceRCONDispatch("queued");
const succeeded = jobResponse("succeeded");
apiMocks.dispatchSourceRCONCommand.mockResolvedValue(pending);
apiMocks.getJob.mockResolvedValue(succeeded);
await renderDrawer();
await emitSession("session-current");
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output"));
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
const input = container?.querySelector<HTMLInputElement>('.terminal-command-form input');
const form = container?.querySelector<HTMLFormElement>('.terminal-command-form');
@@ -186,8 +186,9 @@ describe("ServerManagementTerminalDrawer", () => {
await act(async () => form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })));
await flushPromises();
expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledTimes(1);
expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", pending.id);
expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledTimes(1);
expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledWith("server-1", expect.objectContaining({ kind: "command", command: "#ListPlayers" }));
expect(apiMocks.getJob).toHaveBeenCalledWith(pending.jobId);
expect(container?.textContent).toContain("ordinary live output");
});
});
@@ -246,10 +247,24 @@ function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${se
return { seq, timestamp, line, redacted: true };
}
function bridgeCommand(state: GameClientBridgeCommandResponse["state"]): GameClientBridgeCommandResponse {
function sourceRCONDispatch(status: SourceRCONCommandResponse["status"]): SourceRCONCommandResponse {
return { jobId: "job-rcon-1", serverInstanceId: "server-1", status, message: "queued" };
}
function jobResponse(state: JobResponse["state"]): JobResponse {
return {
id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-rcon", commandType: "management.command", priority: 50, state, approvalState: "not_required",
result: state === "succeeded" ? { status: "succeeded", summary: "command completed", completedAt: "2026-08-14T00:00:03Z" } : undefined,
expiresAt: "2026-08-14T00:01:00Z", createdAt: "2026-08-14T00:00:00Z", updatedAt: "2026-08-14T00:00:03Z", completedAt: state === "succeeded" ? "2026-08-14T00:00:03Z" : undefined
id: "job-rcon-1",
serverInstanceId: "server-1",
runEndpointId: "run-1",
capability: "remote.run.rcon.command",
targetKey: "source-rcon/command",
idempotencyKey: "idem-rcon-1",
state,
progress: { percent: state === "succeeded" ? 100 : 50, message: state === "succeeded" ? "command completed" : "running" },
retryPolicy: { maxAttempts: 1, initialBackoffSeconds: 1, maxBackoffSeconds: 1 },
attempt: 1,
reconcileCount: 0,
createdAt: "2026-08-14T00:00:00Z",
updatedAt: "2026-08-14T00:00:03Z"
};
}
@@ -2,8 +2,8 @@ import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
import { cx } from "../utils/classes";
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
import { EmptyState, ResultBadge } from "./StateViews";
@@ -13,8 +13,8 @@ type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>;
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
type TerminalQuickCommand = { label: string; command: string; hint: string };
const terminalBridgeResultPollMs = 1000;
const terminalBridgeResultPollAttempts = 30;
const terminalJobResultPollMs = 1000;
const terminalJobResultPollAttempts = 30;
const terminalInitialHistoryWindow = 500;
const maxTerminalLines = 10000;
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
@@ -285,17 +285,17 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setResult({ status: "pending", label: "正在提交命令" });
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
try {
const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted));
const label = bridgeCommandDispatchLabel(response.state, response.id);
const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted));
const label = rconJobDispatchLabel(response.status, response.jobId);
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]);
const finalCommand = await waitForBridgeCommandTerminal(response.id);
if (finalCommand) {
const outcome = terminalLineFromBridgeCommand(finalCommand);
appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`)]);
const finalJob = await waitForRCONJobTerminal(response.jobId);
if (finalJob) {
const outcome = terminalLineFromJob(finalJob);
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
appendLines([outcome]);
} else {
const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`);
const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`);
setResult({ status: "pending", label: "等待 Run 返回结果" });
appendLines([timeoutLine]);
}
@@ -308,11 +308,11 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
}
async function waitForBridgeCommandTerminal(commandId: string): Promise<GameClientBridgeCommandResponse | null> {
for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) {
const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId);
if (isTerminalBridgeCommandState(current.state)) return current;
await delay(terminalBridgeResultPollMs);
async function waitForRCONJobTerminal(jobId: string): Promise<JobResponse | null> {
for (let attempt = 0; attempt < terminalJobResultPollAttempts; attempt += 1) {
const current = await platformApiClient.getJob(jobId);
if (isTerminalJobState(current.state)) return current;
await delay(terminalJobResultPollMs);
}
return null;
}
@@ -401,8 +401,8 @@ function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[
return terminalQuickCommandCatalog[pluginId] ?? [];
}
function bridgeCommandDispatchLabel(state: string, commandId: string): string {
return `${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
function rconJobDispatchLabel(state: string, jobId: string): string {
return `${state === "queued" ? "排队" : "提交"} · RCON 任务 ${jobId}`;
}
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
@@ -433,7 +433,6 @@ function terminalSourceClass(value?: string): string {
if (key.includes("stdout")) return "stdout";
if (key.includes("command")) return "command";
if (key.includes("platform")) return "platform";
if (key.includes("bridge")) return "bridge";
if (key.includes("system")) return "system";
return "log";
}
@@ -448,27 +447,27 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId);
}
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
function isTerminalJobState(state: JobResponse["state"]): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled";
}
function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine {
const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state);
const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt;
function terminalLineFromJob(job: JobResponse): TerminalLine {
const summary = job.progress.message || job.executionResult?.summary || job.cancelReason || jobStateLabel(job.state);
const completed = job.updatedAt;
const sortKey = Date.parse(completed) || Date.now();
const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn";
return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" };
const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn";
return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "PLATFORM" };
}
function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string {
function jobStateLabel(state: JobResponse["state"]): string {
switch (state) {
case "succeeded": return "已成功";
case "failed": return "已失败";
case "cancelled": return "已取消";
case "expired": return "已过期";
case "unknown": return "状态未知";
case "claimed": return "Run 已领取";
case "pending": return "已排队";
case "accepted": return "Run 已领取";
case "running": return "运行中";
case "retrying": return "等待重试";
case "queued": return "已排队";
}
}