Remove pre-1.0 audit and protected request scaffolding
This commit is contained in:
@@ -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 workspace,Platform 保留 build、artifact 与审计历史。`, 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 workspace,Platform 保留 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>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("shared operation dialogs", () => {
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="确认停用"
|
||||
description="会保留审计记录"
|
||||
description="该操作会更新用户状态"
|
||||
confirmLabel="停用"
|
||||
busy
|
||||
onConfirm={() => undefined}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
+5
-6
@@ -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 "已排队";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user