功能修改

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
+5
View File
@@ -105,3 +105,8 @@ LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/priv
```
This command verifies the API-backed local debug console path, first-party route markers, plugin/server operation proof, fallback rejection, and forbidden-fragment scans. It writes evidence under `<LOCAL_DEBUG_ROOT>/browser-acceptance/`.
# Client Manager workspace
Server Detail includes a Client Manager lifecycle workspace backed by the typed Platform installation projection. It shows profile/target, desired-active-previous versions and revisions, artifact/checksum metadata, key/deployment generations, registration/health/last-seen, real job phases/progress, action gating reasons, retry guidance, and confirmed deploy/control/update/rollback/revoke/key-reset/uninstall workflows. The browser polls active jobs and never fabricates later phases.
The UI keeps the black-mecha and magical-girl crystal-moonlight console materials and uses shared panel/command tokens. It renders no raw key, token, secret ref/value, host path, PID, socket, credential, DSN, RCON password, or Run endpoint address. 401/403 responses remain platform auth/capability errors, not local fallback success.
+350 -33
View File
@@ -17,7 +17,12 @@ const forbiddenFragments = [
{ name: "tcp socket", pattern: /tcp:\/\// },
{ name: "bearer token", pattern: /Bearer\s+/ },
{ name: "raw api key", pattern: /sk-[A-Za-z0-9_-]+/ },
{ name: "password query", pattern: /password=/ },
{ name: "password query", pattern: /password\s*=/i },
{ name: "credential value", pattern: /credential\s*[:=]\s*\S+/i },
{ name: "DSN value", pattern: /\b(?:postgres(?:ql)?|mysql|redis|mongodb(?:\+srv)?):\/\//i },
{ name: "RCON value", pattern: /\brcon(?:[_-]?(?:password|token))?\s*[:=]\s*\S+/i },
{ name: "host path", pattern: /\/(?:home|var|etc|opt|root|tmp)\/|[A-Za-z]:\\/ },
{ name: "PID value", pattern: /\bpid\s*[:=#]?\s*\d+/i },
{ name: "raw api key ref field", pattern: /apiKeyRef/ },
{ name: "raw api key field", pattern: /rawApiKey/ },
{ name: "session token", pattern: /sessionToken|run session token/i },
@@ -67,8 +72,17 @@ async function main() {
assertIncludes(plugin.requiredRunCapabilities, "process.start", "plugin requires process.start");
assertIncludes(plugin.bridgeActions, "logs.query", "plugin exposes logs.query bridge action");
assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action");
assertIncludes(plugin.bridgeActions, "plugin-lifecycle.request", "plugin exposes Platform lifecycle bridge action");
assertIncludes(plugin.bridgeActions, "ai.invoke", "plugin exposes Platform AI bridge action");
assertIncludes(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback governance");
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
assertSafeRedactedRef(aiProvider.apiKeyRef, "AI provider key reference");
assertEqual(aiProvider.apiKeyConfigured, true, "AI provider key presence projection");
assertEqual(aiProvider.baseUrlConfigured, true, "AI provider base URL presence projection");
if (Object.hasOwn(aiProvider, "baseUrl") || Object.hasOwn(aiProvider, "apiKeyRef")) {
throw new Error("AI provider response exposed Platform-owned endpoint or secret reference");
}
const productionSeed = await prepareProductionOperations(authHeaders, server, plugin);
const chrome = await startChrome();
const evidence = {
@@ -90,11 +104,12 @@ async function main() {
plugin: pick(plugin, ["id", "name", "version", "status", "manifestRef", "requiredRunCapabilities", "bridgeActions", "declaredPermissions"]),
marketplacePlugin: pick(marketplacePlugin, ["id", "name", "version", "status", "manifestRef", "capabilities", "bridgeActions", "declaredPermissions"]),
operator: pick(operator, ["id", "displayName", "email", "status", "roles"]),
aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "apiKeyRef"]),
aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "baseUrlConfigured", "apiKeyConfigured"]),
jobs: jobs.items.map((job) => pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"])),
logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])),
artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])),
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"])
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"]),
production: productionSeed.apiProof
},
routes: [],
safety: {
@@ -112,7 +127,7 @@ async function main() {
{
name: "首页",
hash: "#/home",
markers: ["平台概览", "数据已加载", "game.example", "运行节点", "CPU"]
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU", "生产容量与告警", "运行槽位"]
},
{
name: "服务器管理",
@@ -145,7 +160,12 @@ async function main() {
{
name: "AI 提供商管理",
hash: "#/aiProviders",
markers: ["AI 提供商管理", "平台 API", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", productionSeed.diff.diffSummary, server.id]
},
{
name: "系统维护",
hash: "#/maintenance",
markers: ["系统维护", "容量治理与告警闭环", "运行槽位", productionSeed.alert.title]
},
{
name: "服务器详情",
@@ -181,9 +201,15 @@ async function main() {
}
}
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
evidence.routes.push({ name: "服务器详情 / 插件控制", url: await chrome.url(), ...pluginControls });
evidence.productionInteractions = {
alert: await verifyAlertInteraction(chrome, authHeaders, productionSeed.alert),
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, productionSeed.diff)
};
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
const operationProof = await verifyLifecycleOperation(authHeaders, server, chrome);
@@ -229,12 +255,12 @@ async function loginInBrowser(chrome) {
setInputValue(password, "operator-local");
submit.click();
});
await chrome.waitForText(["平台概览", "数据已加载"], "post-login home");
await chrome.waitForText(["平台概览", "运营数据已同步"], "post-login home");
const visibleText = await chrome.visibleText();
scanText(visibleText, "登录后首页");
return {
url: await chrome.url(),
requiredMarkers: ["平台概览", "数据已加载"],
requiredMarkers: ["平台概览", "运营数据已同步"],
fallbackScan: "passed",
forbiddenFragmentScan: "passed"
};
@@ -294,9 +320,21 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
for (const scenario of scenarios) {
await chrome.setViewport(scenario.viewport);
await applyBrowserTheme(chrome, scenario.paletteId, scenario.backgroundId);
const appliedPalette = await chrome.evaluate(() => document.documentElement.dataset.themePalette || "");
assertEqual(appliedPalette, scenario.paletteId, `${scenario.name} root theme marker`);
const routeEvidence = [];
if (scenario.viewport.mobile) {
const mobileNavigation = await verifyMobileNavigation(chrome, scenario.name, scenario.themeMarkers);
routeEvidence.push({
name: "移动导航抽屉",
url: await chrome.url(),
...mobileNavigation,
layout: await chrome.layoutSnapshot()
});
}
for (const route of routeChecks) {
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...scenario.themeMarkers], `${scenario.name} / ${route.name}`);
const routeThemeMarkers = scenario.viewport.mobile ? [] : scenario.themeMarkers;
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...routeThemeMarkers], `${scenario.name} / ${route.name}`);
const layout = await chrome.layoutSnapshot();
assertNoVisibleLayoutIssues(layout, `${scenario.name} / ${route.name}`);
routeEvidence.push({
@@ -328,7 +366,7 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
window.location.hash = "#/servers/server-local-debug";
});
await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`);
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
const pluginLayout = await chrome.layoutSnapshot();
assertNoVisibleLayoutIssues(pluginLayout, `${scenario.name} / plugin controls`);
routeEvidence.push({
@@ -402,15 +440,11 @@ async function clickAndVerify(chrome, buttonText, markers) {
async function verifyServerQuickRuntimeMenu(chrome, label) {
const markers = ["生成 run", "下载 run", "推送更新", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
await chrome.evaluate(() => {
const summary = Array.from(document.querySelectorAll("summary")).find((item) => item.textContent?.includes("运行操作"));
if (!(summary instanceof HTMLElement)) {
throw new Error("runtime action menu summary not found");
const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作"));
if (!(trigger instanceof HTMLButtonElement)) {
throw new Error("runtime action menu trigger not found");
}
const details = summary.closest("details");
if (!(details instanceof HTMLDetailsElement)) {
throw new Error("runtime action menu container not found");
}
details.open = true;
trigger.click();
});
await chrome.waitForText(markers, `${label} runtime action menu`);
const visibleText = await chrome.visibleText();
@@ -424,6 +458,63 @@ async function verifyServerQuickRuntimeMenu(chrome, label) {
};
}
async function verifyMobileNavigation(chrome, label, themeMarkers) {
const menuLabels = ["平台概览", "服务器管理", "插件市场", "用户管理", "AI 提供商", "系统工具"];
const closed = await chrome.evaluate(() => {
const handle = document.querySelector(".mobile-sidebar-handle");
const sidebar = document.querySelector("#primary-sidebar");
return {
handle: handle instanceof HTMLButtonElement,
expanded: handle?.getAttribute("aria-expanded") === "true",
sidebarOpen: sidebar?.classList.contains("app-sidebar-mobile-open") ?? false,
mainTop: document.querySelector("main")?.getBoundingClientRect().top ?? null
};
});
if (!closed.handle || closed.expanded || closed.sidebarOpen || closed.mainTop !== 0) {
throw new Error(`${label} mobile drawer was not closed and out of page flow: ${JSON.stringify(closed)}`);
}
await chrome.evaluate(() => {
const handle = document.querySelector(".mobile-sidebar-handle");
if (!(handle instanceof HTMLButtonElement)) throw new Error("mobile drawer handle not found");
handle.click();
});
await chrome.waitForText([...menuLabels, ...themeMarkers], `${label} mobile drawer open`);
const opened = await chrome.evaluate(() => ({
expanded: document.querySelector(".mobile-sidebar-handle")?.getAttribute("aria-expanded") === "true",
sidebarOpen: document.querySelector("#primary-sidebar")?.classList.contains("app-sidebar-mobile-open") ?? false,
width: document.querySelector("#primary-sidebar")?.getBoundingClientRect().width ?? 0
}));
if (!opened.expanded || !opened.sidebarOpen || opened.width <= 0 || opened.width > 320) {
throw new Error(`${label} mobile drawer did not open as a bounded text sidebar: ${JSON.stringify(opened)}`);
}
const visibleText = await chrome.visibleText();
assertMarkers(visibleText, [...menuLabels, ...themeMarkers], `${label} mobile drawer`);
scanText(visibleText, `${label} mobile drawer`);
await chrome.evaluate(() => {
const backdrop = document.querySelector(".mobile-sidebar-backdrop");
if (!(backdrop instanceof HTMLButtonElement)) throw new Error("mobile drawer backdrop not found");
backdrop.click();
});
await delay(220);
const closedAgain = await chrome.evaluate(() => {
const backdrop = document.querySelector(".mobile-sidebar-backdrop");
const style = backdrop ? window.getComputedStyle(backdrop) : null;
return document.querySelector(".mobile-sidebar-handle")?.getAttribute("aria-expanded") !== "true"
&& style?.visibility === "hidden"
&& style?.pointerEvents === "none";
});
if (!closedAgain) throw new Error(`${label} mobile drawer did not close from backdrop`);
return {
requiredMarkers: [...menuLabels, ...themeMarkers],
fallbackScan: "passed",
forbiddenFragmentScan: "passed",
closed,
opened,
closedAgain,
textSample: visibleText.slice(0, 1200)
};
}
async function ensureAiProvider(headers) {
const providers = await getJson("/ai-providers", headers);
if (providers.items.some((item) => item.id === "ai.openai")) {
@@ -447,6 +538,218 @@ async function ensureAiProvider(headers) {
);
}
async function prepareProductionOperations(headers, server, plugin) {
const stamp = Date.now();
const lifecycle = await postJson(
`/plugin-lifecycles/${encodeURIComponent(plugin.id)}/actions`,
{
serverInstanceId: server.id,
operation: "install",
targetVersion: plugin.version,
idempotencyKey: `browser-acceptance-plugin-install-${stamp}`,
confirmed: false
},
headers
);
if (lifecycle.status !== "queued" || !lifecycle.job?.id || !lifecycle.installation?.id) {
throw new Error(`plugin lifecycle seed did not create one durable job: ${JSON.stringify(lifecycle)}`);
}
const aiInvocation = await postJson(
"/ai/invocations",
{
requestId: `browser-acceptance-ai-config-${stamp}`,
serverInstanceId: server.id,
purpose: "config.suggest",
prompt: "Keep existing settings and add a reviewed max players recommendation."
},
headers
);
if (aiInvocation.status !== "ok" || !aiInvocation.configRecommendation?.diffId) {
throw new Error(`AI invocation did not persist a reviewable diff: ${JSON.stringify(aiInvocation)}`);
}
const admission = await postJson(
"/production/capacity/admission",
{
serverInstanceId: server.id,
capability: "process.restart",
idempotencyKey: `browser-acceptance-capacity-gap-${stamp}`
},
headers
);
if (admission.accepted || admission.state !== "denied" || !admission.alertId || !admission.auditEventId) {
throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`);
}
const [capacity, alerts, lifecycles, diffs] = await Promise.all([
getJson("/production/capacity", headers),
getJson("/alerts", headers),
getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers),
getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(server.id)}`, headers)
]);
const alert = findRequired(alerts.items, (item) => item.id === admission.alertId && item.state === "active", "active capacity alert");
const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation");
const diff = findRequired(diffs.items, (item) => item.id === aiInvocation.configRecommendation.diffId && item.state === "pending", "pending AI config diff");
if (capacity.activeAlerts < 1 || !capacity.endpoints.some((item) => item.runEndpointId === server.runEndpointId)) {
throw new Error(`production capacity summary did not include seeded state: ${JSON.stringify(capacity)}`);
}
for (const [label, value] of Object.entries({ admission, capacity, alert, installation, diff, aiInvocation })) {
assertNoForbiddenProjection(value, `production seed ${label}`);
}
return {
alert,
diff,
apiProof: {
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId", "auditEventId"]),
capacity: {
...pick(capacity, ["totalMaxJobs", "totalRunningJobs", "totalQueuedJobs", "activeAlerts", "generatedAt"]),
endpoints: capacity.endpoints.map((item) => pick(item, ["runEndpointId", "status", "maxJobs", "runningJobs", "queuedJobs", "logBacklogBatches", "artifactBacklogChunks", "pressureCodes"]))
},
alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount", "lastAuditEventId"]),
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId", "auditEventId"]),
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
}
};
}
async function verifyAlertInteraction(chrome, headers, seededAlert) {
await chrome.navigate(`${webUrl}/#/home`);
await chrome.waitForText(["生产容量与告警", seededAlert.title, "确认"], "production alert interaction");
await chrome.evaluate((title) => {
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found");
button.click();
}, seededAlert.title);
await chrome.waitForText(["确认告警", seededAlert.id, "取消"], "alert confirmation dialog");
await chrome.evaluate(() => {
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
if (!(cancel instanceof HTMLButtonElement)) throw new Error("alert confirmation cancel button not found");
cancel.click();
});
await delay(100);
if (await chrome.evaluate(() => Boolean(document.querySelector(".confirm-panel")))) {
throw new Error("alert confirmation dialog did not close after cancel");
}
const afterCancel = await getJson("/alerts", headers);
const stillActive = findRequired(afterCancel.items, (item) => item.id === seededAlert.id, "alert after confirmation cancel");
assertEqual(stillActive.state, "active", "cancel keeps durable alert active");
await chrome.evaluate((title) => {
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found after cancel");
button.click();
}, seededAlert.title);
await chrome.waitForText(["确认告警", seededAlert.id], "alert confirmation reopen");
await chrome.evaluate(() => {
const confirm = document.querySelector(".confirm-panel .confirm-primary");
if (!(confirm instanceof HTMLButtonElement)) throw new Error("alert confirmation submit button not found");
confirm.click();
});
await chrome.waitForText(["确认已由 Platform 持久化", "acknowledged"], "durable alert acknowledgement");
const alerts = await getJson("/alerts", headers);
const acknowledged = findRequired(alerts.items, (item) => item.id === seededAlert.id, "acknowledged capacity alert");
assertEqual(acknowledged.state, "acknowledged", "browser alert acknowledgement persisted");
assertNoForbiddenProjection(acknowledged, "acknowledged alert response");
return {
cancelPreservedState: stillActive.state,
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt", "lastAuditEventId"]),
forbiddenFragmentScan: "passed",
textSample: (await chrome.visibleText()).slice(0, 1200)
};
}
async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server) {
await chrome.navigate(`${webUrl}/#/plugins`);
await chrome.waitForText(["插件市场", plugin.id, "查看详情"], "plugin lifecycle marketplace");
await chrome.evaluate((pluginId) => {
const card = Array.from(document.querySelectorAll(".catalog-card")).find((candidate) => candidate.textContent?.includes(pluginId));
const button = card?.querySelector("button[title='查看插件详情']");
if (!(button instanceof HTMLButtonElement)) throw new Error("plugin detail button not found");
button.click();
}, plugin.id);
await chrome.waitForText([plugin.name, "生产生命周期", server.id, "compatible"], "plugin lifecycle detail");
await chrome.evaluate(() => {
const select = document.querySelector("select[aria-label='生命周期操作']");
if (!(select instanceof HTMLSelectElement)) throw new Error("plugin lifecycle operation select not found");
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
setter.call(select, "enable");
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await chrome.evaluate(() => {
const button = document.querySelector(".plugin-lifecycle-controls .primary-command");
if (!(button instanceof HTMLButtonElement)) throw new Error("plugin lifecycle execute button not found");
button.click();
});
await chrome.waitForText(["确认启用", plugin.name, server.id], "plugin lifecycle confirmation");
await chrome.evaluate(() => {
const confirm = Array.from(document.querySelectorAll(".confirm-panel .confirm-actions button")).find((item) => item.textContent?.trim() === "启用");
if (!(confirm instanceof HTMLButtonElement)) throw new Error("plugin lifecycle confirmation submit not found");
confirm.click();
});
await chrome.waitForText(["启用:queued", "任务"], "plugin lifecycle durable dispatch");
const response = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers);
const installation = findRequired(response.items, (item) => item.pluginId === plugin.id && item.serverInstanceId === server.id, "plugin lifecycle after browser dispatch");
assertEqual(installation.lastOperation, "enable", "browser lifecycle operation persisted");
if (!installation.jobId || !installation.auditEventId) {
throw new Error(`browser lifecycle dispatch missed job/audit linkage: ${JSON.stringify(installation)}`);
}
assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
return {
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "auditEventId", "alertId"]),
forbiddenFragmentScan: "passed",
textSample: (await chrome.visibleText()).slice(0, 1200)
};
}
async function verifyAIConfigDiffInteraction(chrome, headers, seededDiff) {
await chrome.navigate(`${webUrl}/#/aiProviders`);
await chrome.waitForText(["AI 配置审查", seededDiff.serverInstanceId, seededDiff.diffSummary, "审查并批准"], "AI config diff review");
await chrome.evaluate(() => {
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found");
button.click();
});
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id, "取消"], "AI diff approval confirmation");
await chrome.evaluate(() => {
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
if (!(cancel instanceof HTMLButtonElement)) throw new Error("AI diff approval cancel button not found");
cancel.click();
});
const pendingResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
const pending = findRequired(pendingResponse.items, (item) => item.id === seededDiff.id, "AI diff after approval cancel");
assertEqual(pending.state, "pending", "cancel keeps AI diff pending");
await chrome.evaluate(() => {
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found after cancel");
button.click();
});
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id], "AI diff approval confirmation reopen");
await chrome.evaluate(() => {
const confirm = document.querySelector(".confirm-panel .confirm-primary");
if (!(confirm instanceof HTMLButtonElement)) throw new Error("AI diff approval submit button not found");
confirm.click();
});
await chrome.waitForText(["已审批", "写入任务"], "AI diff durable approval");
const approvedResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
const approved = findRequired(approvedResponse.items, (item) => item.id === seededDiff.id, "approved AI config diff");
assertEqual(approved.state, "approved", "browser AI diff approval persisted");
if (!approved.jobId || !approved.approvedBy || !approved.approvedAt) {
throw new Error(`approved AI diff missed durable approval linkage: ${JSON.stringify(approved)}`);
}
assertNoForbiddenProjection(approved, "approved AI diff response");
return {
cancelPreservedState: pending.state,
persisted: pick(approved, ["id", "serverInstanceId", "state", "approvedBy", "approvedAt", "jobId", "configVersion", "currentConfigChecksum"]),
forbiddenFragmentScan: "passed",
textSample: (await chrome.visibleText()).slice(0, 1200)
};
}
async function verifyFrontendEnvironment() {
const [html, packageJsonText, viteConfigText] = await Promise.all([
fetchText(webUrl),
@@ -470,34 +773,42 @@ async function verifyFrontendEnvironment() {
}
async function verifyLifecycleOperation(headers, server, chrome) {
const request = { expectedConfigVersion: server.configVersion, idempotencyKey: `browser-acceptance-start-${Date.now()}` };
const result = await postJson(`/server-instances/${encodeURIComponent(server.id)}/start`, request, headers);
const currentServer = await getJson(`/server-instances/${encodeURIComponent(server.id)}`, headers);
if (currentServer.configVersion <= server.configVersion) {
throw new Error(`AI config write did not advance the durable server config version: before=${server.configVersion} after=${currentServer.configVersion}`);
}
const action = currentServer.state === "running" ? "stop" : "start";
const expectedCapability = action === "stop" ? "process.stop" : "process.start";
const request = { expectedConfigVersion: currentServer.configVersion, idempotencyKey: `browser-acceptance-${action}-${Date.now()}` };
const result = await postJson(`/server-instances/${encodeURIComponent(currentServer.id)}/${action}`, request, headers);
if (!result.accepted || !result.job?.id) {
throw new Error("lifecycle start operation did not return accepted job evidence");
}
if (result.job.capability !== "process.start") {
if (result.job.capability !== expectedCapability) {
throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`);
}
if (result.job.runEndpointId !== "run-local-debug") {
throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`);
}
const job = await waitForJob(headers, server.id, result.job.id);
const job = await waitForJob(headers, currentServer.id, result.job.id);
await chrome.navigate(`${webUrl}/#/servers/server-local-debug`);
await chrome.waitForText([server.name, "操作历史"], "server detail after lifecycle operation");
await chrome.waitForText([currentServer.name, "操作历史"], "server detail after lifecycle operation");
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
return {
action: "start",
action,
accepted: result.accepted,
request: {
expectedConfigVersion: request.expectedConfigVersion,
idempotencyKey: request.idempotencyKey
idempotencyKey: request.idempotencyKey,
configVersionBeforeAIWrite: server.configVersion,
configVersionAfterAIWrite: currentServer.configVersion
},
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
proof: "platform API accepted process.start and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access",
proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access`,
browserEvidence: {
...historyState,
proofMode: "operation-history-entry-point"
@@ -894,6 +1205,18 @@ function scanText(text, routeName) {
}
}
function assertNoForbiddenProjection(value, label) {
const serialized = JSON.stringify(value);
const forbidden = forbiddenFragments.find((item) => item.pattern.test(serialized));
if (forbidden) {
throw new Error(`${label} contains forbidden API fragment: ${forbidden.name}`);
}
const forbiddenField = /"(?:apiKey|apiKeyRef|rawApiKey|baseUrl|hostPath|pid|socket|dsn|rcon|credential|directRunEndpoint)"\s*:/i.exec(serialized);
if (forbiddenField) {
throw new Error(`${label} contains forbidden API field ${forbiddenField[0]}`);
}
}
function assertEqual(actual, expected, label) {
if (actual !== expected) {
throw new Error(`${label}: expected ${expected}, got ${actual}`);
@@ -906,12 +1229,6 @@ function assertIncludes(values, expected, label) {
}
}
function assertSafeRedactedRef(value, label) {
if (typeof value !== "string" || (!value.startsWith("secret://") && !value.startsWith("env://"))) {
throw new Error(`${label} must be a safe secret/env reference`);
}
}
function pick(value, keys) {
return Object.fromEntries(keys.map((key) => [key, value?.[key]]));
}
+25 -3
View File
@@ -9,7 +9,7 @@ const provider: AiProviderResponse = {
id: "ai.openai",
name: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
baseUrlConfigured: true,
apiKeyConfigured: true,
models: ["gpt-4.1"],
defaultModel: "gpt-4.1",
@@ -35,6 +35,7 @@ const plugin: GamePluginResponse = {
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] },
status: "installed"
};
@@ -57,6 +58,7 @@ const marketplacePlugin: MarketplacePluginResponse = {
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
status: "installed",
source: "platform-registry"
};
@@ -181,7 +183,7 @@ describe("PlatformApiClient AI providers", () => {
id: provider.id,
name: provider.name,
kind: provider.kind,
baseUrl: provider.baseUrl,
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
models: provider.models,
defaultModel: provider.defaultModel,
@@ -725,11 +727,13 @@ describe("PlatformApiClient AI providers", () => {
);
});
it("keeps raw key fields out of provider responses", () => {
it("keeps raw key and base URL fields out of provider responses", () => {
expect("apiKey" in provider).toBe(false);
expect("rawApiKey" in provider).toBe(false);
expect(provider.apiKeyConfigured).toBe(true);
expect("apiKeyRef" in provider).toBe(false);
expect("baseUrl" in provider).toBe(false);
expect(provider.baseUrlConfigured).toBe(true);
});
it("calls auth endpoints and attaches bearer sessions", async () => {
@@ -804,6 +808,24 @@ describe("PlatformApiClient AI providers", () => {
});
expect(onAuthFailure).not.toHaveBeenCalled();
});
it("surfaces allow-listed plugin capability denials", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
code: "forbidden",
message: "plugin does not declare required permission: server.run.distribution"
}), { status: 403, headers: { "Content-Type": "application/json" } })));
const client = new PlatformApiClient("/api/v1", () => "admin-session");
await expect(client.generateRunDistribution("server-local-debug", {
targetOs: "linux",
targetArch: "amd64",
idempotencyKey: "test"
})).rejects.toMatchObject({
status: 403,
code: "forbidden",
message: "插件未声明所需权限:server.run.distribution"
});
});
});
function jsonResponse(body: unknown): Response {
+169 -1
View File
@@ -8,6 +8,11 @@ import type {
AiProviderUpdateRequest,
AIInvocationRequest,
AIInvocationResponse,
AIConfigDiffApprovalResponse,
AIConfigDiffListResponse,
AlertListResponse,
AlertResponse,
AlertRetryResponse,
ApiErrorResponse,
ArtifactContentChunk,
ArtifactDownloadReferenceResponse,
@@ -16,8 +21,16 @@ import type {
AuthSessionResponse,
AuditEventListResponse,
ClientManagerBuildRequest,
ClientManagerControlRequest,
ClientManagerDeployRequest,
ClientManagerDistributionResponse,
ClientManagerDownloadRequest,
ClientManagerInstallationListResponse,
ClientManagerInstallationResponse,
ClientManagerRetryRequest,
ClientManagerRevokeSessionRequest,
ClientManagerUninstallRequest,
ClientManagerUpdateRequest,
ComponentKeyResponse,
ComponentKeyResetRequest,
CurrentUserResponse,
@@ -25,6 +38,15 @@ import type {
DependencyJobRequest,
FileOperationDispatchRequest,
FileOperationDispatchResponse,
GameClientBridgeCancelRequest,
GameClientBridgeCancelResponse,
GameClientBridgeCommandFilterRequest,
GameClientBridgeCommandListResponse,
GameClientBridgeCommandResponse,
GameClientBridgeQueueRequest,
GameClientBridgeSnapshotListResponse,
GameClientBridgeSnapshotQuery,
GameClientBridgeStatusResponse,
GamePluginListResponse,
HealthResponse,
JobCreateRequest,
@@ -42,6 +64,11 @@ import type {
MarketplacePluginResponse,
MarketplacePluginStateRequest,
PlatformResourceUsageResponse,
ProductionCapacitySummaryResponse,
CapacityAdmissionDecisionResponse,
PluginLifecycleActionRequest,
PluginLifecycleActionResponse,
PluginLifecycleListResponse,
PluginBridgeAuthorizeRequest,
PluginBridgeAuthorizeResponse,
PluginBridgeExecuteRequest,
@@ -86,6 +113,15 @@ import type {
} from "./types";
import { readWebRuntimeEnv } from "../schemas/env";
import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates";
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "../schemas/clientManagerLifecycle";
import {
parseSafeGameClientBridgeCancellation,
parseSafeGameClientBridgeCommand,
parseSafeGameClientBridgeCommandList,
parseSafeGameClientBridgeSnapshotList,
parseSafeGameClientBridgeStatus
} from "../schemas/gameClientBridge";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
let platformApiSessionToken: string | null = null;
let platformApiAuthFailureHandler: ((error: PlatformApiError) => void) | null = null;
@@ -298,6 +334,74 @@ export class PlatformApiClient {
});
}
async listClientManagerLifecycles(id: string): Promise<ClientManagerInstallationListResponse> {
return parseSafeClientManagerLifecycleList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers`));
}
async getClientManagerLifecycle(id: string, profileKey: string): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/${encodeURIComponent(profileKey)}`));
}
async deployClientManager(id: string, request: ClientManagerDeployRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/deploy`, { method: "POST", body: request }));
}
async controlClientManager(id: string, request: ClientManagerControlRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/control`, { method: "POST", body: request }));
}
async updateClientManager(id: string, request: ClientManagerUpdateRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/update`, { method: "POST", body: request }));
}
async retryClientManagerLifecycle(id: string, request: ClientManagerRetryRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/retry`, { method: "POST", body: request }));
}
async revokeClientManagerSession(id: string, request: ClientManagerRevokeSessionRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/revoke-session`, { method: "POST", body: request }));
}
async uninstallClientManager(id: string, request: ClientManagerUninstallRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/uninstall`, { method: "POST", body: request }));
}
async getGameClientBridgeStatus(id: string): Promise<GameClientBridgeStatusResponse> {
return parseSafeGameClientBridgeStatus(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge`));
}
async listGameClientBridgeCommands(id: string, filter: GameClientBridgeCommandFilterRequest = {}): Promise<GameClientBridgeCommandListResponse> {
const params = new URLSearchParams();
if (filter.profileKey) params.set("profileKey", filter.profileKey);
if (filter.state) params.set("state", filter.state);
if (filter.commandType) params.set("commandType", filter.commandType);
const query = params.toString();
return parseSafeGameClientBridgeCommandList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands${query ? `?${query}` : ""}`));
}
async queueGameClientBridgeCommand(id: string, request: GameClientBridgeQueueRequest): Promise<GameClientBridgeCommandResponse> {
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands`, { method: "POST", body: request }));
}
async getGameClientBridgeCommand(id: string, commandId: string): Promise<GameClientBridgeCommandResponse> {
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}`));
}
async cancelGameClientBridgeCommand(id: string, commandId: string, request: GameClientBridgeCancelRequest = {}): Promise<GameClientBridgeCancelResponse> {
return parseSafeGameClientBridgeCancellation(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}/cancel`, { method: "POST", body: request }));
}
async listGameClientBridgeSnapshots(id: string, query: GameClientBridgeSnapshotQuery = {}): Promise<GameClientBridgeSnapshotListResponse> {
const params = new URLSearchParams();
if (query.profileKey) params.set("profileKey", query.profileKey);
if (query.type) params.set("type", query.type);
if (query.streamKey) params.set("streamKey", query.streamKey);
if (query.observedAfter) params.set("observedAfter", query.observedAfter);
if (query.limit !== undefined) params.set("limit", String(query.limit));
const search = params.toString();
return parseSafeGameClientBridgeSnapshotList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/snapshots${search ? `?${search}` : ""}`));
}
async checkDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/check`, {
method: "POST",
@@ -386,6 +490,55 @@ export class PlatformApiClient {
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
}
async getProductionCapacity(): Promise<ProductionCapacitySummaryResponse> {
return this.request<ProductionCapacitySummaryResponse>("/production/capacity");
}
async checkCapacityAdmission(request: { serverInstanceId?: string; runEndpointId?: string; capability: string; targetKey?: string; idempotencyKey?: string }): Promise<CapacityAdmissionDecisionResponse> {
return this.request<CapacityAdmissionDecisionResponse>("/production/capacity/admission", { method: "POST", body: request });
}
async listAlerts(filter: { state?: string; sourceKind?: string; sourceId?: string; severity?: string } = {}): Promise<AlertListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<AlertListResponse>(`/alerts${query ? `?${query}` : ""}`);
}
async acknowledgeAlert(id: string, note = ""): Promise<AlertResponse> {
return this.request<AlertResponse>(`/alerts/${encodeURIComponent(id)}/acknowledge`, { method: "POST", body: { note } });
}
async resolveAlert(id: string, note = ""): Promise<AlertResponse> {
return this.request<AlertResponse>(`/alerts/${encodeURIComponent(id)}/resolve`, { method: "POST", body: { note } });
}
async retryAlert(id: string, idempotencyKey: string): Promise<AlertRetryResponse> {
return this.request<AlertRetryResponse>(`/alerts/${encodeURIComponent(id)}/retry`, { method: "POST", body: { idempotencyKey } });
}
async listPluginLifecycles(filter: { pluginId?: string; serverInstanceId?: string; currentState?: string } = {}): Promise<PluginLifecycleListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<PluginLifecycleListResponse>(`/plugin-lifecycles${query ? `?${query}` : ""}`);
}
async runPluginLifecycle(pluginId: string, request: PluginLifecycleActionRequest): Promise<PluginLifecycleActionResponse> {
return this.request<PluginLifecycleActionResponse>(`/plugin-lifecycles/${encodeURIComponent(pluginId)}/actions`, { method: "POST", body: request });
}
async listAIConfigDiffs(filter: { serverInstanceId?: string; pluginId?: string; state?: string } = {}): Promise<AIConfigDiffListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<AIConfigDiffListResponse>(`/ai/config-diffs${query ? `?${query}` : ""}`);
}
async approveAIConfigDiff(id: string, idempotencyKey: string): Promise<AIConfigDiffApprovalResponse> {
return this.request<AIConfigDiffApprovalResponse>(`/ai/config-diffs/${encodeURIComponent(id)}/approve`, { method: "POST", body: { idempotencyKey } });
}
async listServerMetrics(): Promise<ServerMetricsListResponse> {
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
}
@@ -556,7 +709,7 @@ async function responseError(response: Response): Promise<PlatformApiError> {
const message = response.status === 401
? "会话已失效,请重新登录。"
: response.status === 403
? "没有权限访问该资源。"
? safeForbiddenMessage(apiError?.message)
: apiError?.message ?? `request failed: ${response.status}`;
const error = new PlatformApiError(response.status, apiError?.code ?? "request_failed", message);
if (response.status === 401) {
@@ -566,6 +719,21 @@ async function responseError(response: Response): Promise<PlatformApiError> {
return error;
}
function safeForbiddenMessage(apiMessage?: string): string {
const sanitized = safeDiagnosticText(apiMessage, "")?.trim();
if (!sanitized || sanitized === "account is not allowed to access this resource") {
return "没有权限访问该资源。";
}
const missingPermission = sanitized.match(/^plugin does not declare required permission:\s*([a-z0-9._-]+)$/i);
if (missingPermission) {
return `插件未声明所需权限:${missingPermission[1]}`;
}
if (sanitized === "plugin is not installed") {
return "插件未安装,不能执行该操作。";
}
return "没有权限访问该资源。";
}
function marketplaceQuery(filter: MarketplacePluginFilterRequest): string {
const params = new URLSearchParams();
if (filter.status && filter.status !== "all") {
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
const lifecycle = {
id: "installation-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client-manager", targetOs: "windows", targetArch: "amd64",
status: "available", phase: "artifact available", desiredVersion: "1.0.0", desiredRevision: "rev-1", desiredArtifactId: "artifact-1", keyGeneration: 1,
deploymentGeneration: 0, health: "unknown", healthReason: "component is not installed", retryable: false, requiresRedeploy: false, updatedAt: "2026-07-18T08:00:00Z",
distribution: { id: "distribution-1", artifactId: "artifact-1", sourceRevision: "rev-1", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 1, status: "available" },
actions: [{ operation: "deploy", available: true }, { operation: "uninstall", available: false, reason: "not installed" }]
};
describe("PlatformApiClient Client Manager lifecycle", () => {
afterEach(() => vi.unstubAllGlobals());
it("uses typed Platform lifecycle routes and preserves action bodies", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
return new Response(JSON.stringify(url.endsWith("/client-managers") && (init?.method ?? "GET") === "GET" ? { items: [lifecycle], count: 1 } : lifecycle), { status: 200, headers: { "Content-Type": "application/json" } });
}));
const client = new PlatformApiClient("/api/v1", () => "session-token");
await expect(client.listClientManagerLifecycles("server-1")).resolves.toMatchObject({ count: 1 });
await expect(client.getClientManagerLifecycle("server-1", "scum-client-manager")).resolves.toMatchObject({ profileKey: "scum-client-manager" });
await client.deployClientManager("server-1", { profileKey: "scum-client-manager", distributionId: "distribution-1", expectedDeploymentGeneration: 0, idempotencyKey: "deploy-1" });
await client.controlClientManager("server-1", { profileKey: "scum-client-manager", operation: "start", expectedDeploymentGeneration: 1, idempotencyKey: "start-1" });
await client.updateClientManager("server-1", { profileKey: "scum-client-manager", distributionId: "distribution-2", expectedDeploymentGeneration: 1, approved: true, idempotencyKey: "update-1" });
await client.retryClientManagerLifecycle("server-1", { profileKey: "scum-client-manager", expectedDeploymentGeneration: 2, idempotencyKey: "retry-1" });
await client.revokeClientManagerSession("server-1", { profileKey: "scum-client-manager", reason: "operator revoked component session" });
await client.uninstallClientManager("server-1", { profileKey: "scum-client-manager", expectedDeploymentGeneration: 2, confirmed: true, idempotencyKey: "uninstall-1" });
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"GET /api/v1/server-instances/server-1/client-managers",
"GET /api/v1/server-instances/server-1/client-managers/scum-client-manager",
"POST /api/v1/server-instances/server-1/client-managers/deploy",
"POST /api/v1/server-instances/server-1/client-managers/control",
"POST /api/v1/server-instances/server-1/client-managers/update",
"POST /api/v1/server-instances/server-1/client-managers/retry",
"POST /api/v1/server-instances/server-1/client-managers/revoke-session",
"POST /api/v1/server-instances/server-1/client-managers/uninstall"
]);
expect(calls[4]?.body).toMatchObject({ approved: true, distributionId: "distribution-2" });
expect(calls[7]?.body).toMatchObject({ confirmed: true });
});
});
+3
View File
@@ -61,3 +61,6 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
- Log filtering by level/keyword/time/source is applied client-side over `POST /api/v1/log-streams/query` (`LogStreamCursorRequest`) results until the platform exposes server-side filters.
# Client Manager API projection
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
import type { GameClientBridgeManifestResponse, GameClientBridgeQueueRequest, GamePluginResponse, MarketplacePluginResponse } from "./types";
const now = "2026-07-20T08:00:00Z";
const later = "2026-07-20T09:00:00Z";
const status = {
serverInstanceId: "server-1",
pluginId: "game.scum",
available: false,
reason: "compatible companion is offline",
profiles: [{
pluginId: "game.scum",
profileKey: "scum-client",
available: false,
reason: "component heartbeat is unavailable",
commandTypes: ["scum.announcement.send"],
snapshotTypes: ["scum.players"],
queryTemplateKeys: ["scum.player.search"]
}]
} as const;
const pendingCommand = {
id: "command-1",
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client",
commandType: "scum.announcement.send",
priority: 20,
state: "pending",
approvalState: "pending",
requesterId: "user-1",
auditReferences: ["audit-command-1"],
expiresAt: later,
createdAt: now,
updatedAt: now
} as const;
const completedCommand = {
...pendingCommand,
state: "succeeded",
approvalState: "approved",
resultSummary: "announcement delivered",
result: {
status: "succeeded",
summary: "announcement delivered",
payload: { delivered: true, recipientCount: 12 },
completedAt: later
},
completedAt: later,
updatedAt: later
} as const;
const cancellation = {
commandId: pendingCommand.id,
state: "cancelled",
cancellation: { requestedBy: "user-1", reason: "maintenance window changed", cancelledAt: later },
auditReferences: ["audit-command-1", "audit-command-cancel-1"],
updatedAt: later
} as const;
const snapshot = {
id: "snapshot-1",
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client",
type: "scum.players",
schemaVersion: "1",
streamKey: "current",
sequence: 7,
observedAt: now,
payload: { players: [{ playerId: "player-1", displayName: "Moonlight" }] },
retention: { keepForSeconds: 3600, maxRecords: 24 },
auditReferences: ["audit-snapshot-1"],
createdAt: now,
expiresAt: later
} as const;
const manifestDeclaration: GameClientBridgeManifestResponse = {
commands: [{
type: "scum.announcement.send",
title: "Send announcement",
permission: "server.game-client.command",
approvalLevel: "operator",
payloadSchemaRef: "schemas/bridge/commands/announcement.request.json",
resultSchemaRef: "schemas/bridge/commands/announcement.result.json",
timeoutSeconds: 30,
maxPayloadBytes: 4096
}],
snapshots: [{ type: "scum.players", schemaVersion: "1", schemaRef: "schemas/bridge/snapshots/players.json", keepForSeconds: 3600, maxRecords: 24 }],
queryTemplates: [{
key: "scum.player.search",
title: "Search players",
permission: "server.game-client.read",
engine: "sqlite",
transportKey: "scum-database",
targetKey: "scum-db",
parameterSchemaRef: "schemas/bridge/queries/player-search.request.json",
resultSchemaRef: "schemas/bridge/queries/player-search.result.json",
maxRows: 50,
timeoutSeconds: 10
}],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["scum.announcement.send"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }],
companion: {
profileKey: "scum-client-manager",
configTemplateKey: "client-config",
configSchemaRef: "schemas/companion/config.schema.json",
configFormat: "yaml",
platformBaseUrlSource: "run-control",
registrationProof: "hmac-sha256",
proofMaterialSource: "component-package",
proofMaterialEnv: "SCUM_COMPONENT_PROOF",
sessionMode: "component-session",
tlsPolicy: "verify-system-roots",
heartbeatIntervalSeconds: 30,
commandPollIntervalSeconds: 5,
requestTimeoutSeconds: 15
}
};
const pluginBridgeProjection: Pick<GamePluginResponse, "gameClientBridge"> & Pick<MarketplacePluginResponse, "gameClientBridge"> = {
gameClientBridge: manifestDeclaration
};
describe("PlatformApiClient Game Client Bridge operator API", () => {
afterEach(() => vi.unstubAllGlobals());
it("types plugin and marketplace manifest declarations with approval metadata", () => {
expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ approvalLevel: "operator" }], queryTemplates: [{ engine: "sqlite" }], companion: { tlsPolicy: "verify-system-roots", sessionMode: "component-session" } });
expect(JSON.stringify(pluginBridgeProjection)).not.toMatch(/authKey|componentKey|sessionToken|credential|secretRef/i);
});
it("uses only server-scoped operator routes and preserves bounded filters and bodies", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined });
if (url.endsWith("/game-client-bridge")) return jsonResponse(status);
if (url.includes("/game-client-bridge/commands?") && method === "GET") return jsonResponse({ items: [pendingCommand], count: 1 });
if (url.endsWith("/game-client-bridge/commands") && method === "POST") return jsonResponse(pendingCommand, 202);
if (url.endsWith(`/game-client-bridge/commands/${pendingCommand.id}`) && method === "GET") return jsonResponse(completedCommand);
if (url.endsWith(`/game-client-bridge/commands/${pendingCommand.id}/cancel`) && method === "POST") return jsonResponse(cancellation);
if (url.includes("/game-client-bridge/snapshots?")) return jsonResponse({ items: [snapshot], count: 1 });
throw new Error(`unexpected request: ${method} ${url}`);
}));
const client = new PlatformApiClient("/api/v1", () => "operator-session");
const queueRequest: GameClientBridgeQueueRequest = {
profileKey: "scum-client",
commandType: "scum.announcement.send",
payload: { message: "Restart in ten minutes", channels: ["global"] },
idempotencyKey: "announcement-1",
priority: 20,
expiresAt: later
};
await expect(client.getGameClientBridgeStatus("server-1")).resolves.toMatchObject({ available: false, profiles: [{ profileKey: "scum-client" }] });
await expect(client.listGameClientBridgeCommands("server-1", { profileKey: "scum-client", state: "pending", commandType: "scum.announcement.send" })).resolves.toMatchObject({ count: 1 });
await expect(client.queueGameClientBridgeCommand("server-1", queueRequest)).resolves.toMatchObject({ state: "pending", approvalState: "pending" });
await expect(client.getGameClientBridgeCommand("server-1", pendingCommand.id)).resolves.toMatchObject({ result: { status: "succeeded", payload: { delivered: true } } });
await expect(client.cancelGameClientBridgeCommand("server-1", pendingCommand.id, { reason: "maintenance window changed" })).resolves.toMatchObject({ state: "cancelled" });
await expect(client.listGameClientBridgeSnapshots("server-1", { profileKey: "scum-client", type: "scum.players", streamKey: "current", observedAfter: now, limit: 20 })).resolves.toMatchObject({ count: 1, items: [{ sequence: 7 }] });
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"GET /api/v1/server-instances/server-1/game-client-bridge",
"GET /api/v1/server-instances/server-1/game-client-bridge/commands?profileKey=scum-client&state=pending&commandType=scum.announcement.send",
"POST /api/v1/server-instances/server-1/game-client-bridge/commands",
"GET /api/v1/server-instances/server-1/game-client-bridge/commands/command-1",
"POST /api/v1/server-instances/server-1/game-client-bridge/commands/command-1/cancel",
"GET /api/v1/server-instances/server-1/game-client-bridge/snapshots?profileKey=scum-client&type=scum.players&streamKey=current&observedAfter=2026-07-20T08%3A00%3A00Z&limit=20"
]);
expect(calls[2]?.body).toEqual(queueRequest);
expect(calls[4]?.body).toEqual({ reason: "maintenance window changed" });
expect(JSON.stringify(calls)).not.toMatch(/sessionToken|componentKey|secretRef|hostPath|dsn|socket|credential|runEndpoint/i);
expect(calls.every((call) => !call.url.includes("/companion/"))).toBe(true);
});
it("URL-encodes server and command identifiers", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(completedCommand));
vi.stubGlobal("fetch", fetchMock);
await new PlatformApiClient("/api/v1").getGameClientBridgeCommand("server/unsafe", "command/unsafe");
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/server-instances/server%2Funsafe/game-client-bridge/commands/command%2Funsafe");
});
});
function jsonResponse(value: unknown, statusCode = 200): Response {
return new Response(JSON.stringify(value), { status: statusCode, headers: { "Content-Type": "application/json" } });
}
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
describe("PlatformApiClient production operations", () => {
afterEach(() => vi.unstubAllGlobals());
it("uses Platform-only governance routes and bounded request bodies", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
return new Response(JSON.stringify({ items: [], count: 0, endpoints: [], totalMaxJobs: 0, totalRunningJobs: 0, totalQueuedJobs: 0, activeAlerts: 0, generatedAt: "2026-07-18T00:00:00Z", status: "queued", installation: {}, job: {}, decision: {}, alert: {} }), { status: 200, headers: { "Content-Type": "application/json" } });
}));
const client = new PlatformApiClient("/api/v1", () => "session-token");
await client.getProductionCapacity();
await client.listAlerts({ state: "active" });
await client.acknowledgeAlert("alert-1", "reviewed");
await client.resolveAlert("alert-1", "resolved");
await client.retryAlert("alert-1", "retry-1");
await client.listPluginLifecycles({ pluginId: "game.scum" });
await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false });
await client.listAIConfigDiffs({ state: "pending" });
await client.approveAIConfigDiff("diff-1", "approve-1");
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"GET /api/v1/production/capacity",
"GET /api/v1/alerts?state=active",
"POST /api/v1/alerts/alert-1/acknowledge",
"POST /api/v1/alerts/alert-1/resolve",
"POST /api/v1/alerts/alert-1/retry",
"GET /api/v1/plugin-lifecycles?pluginId=game.scum",
"POST /api/v1/plugin-lifecycles/game.scum/actions",
"GET /api/v1/ai/config-diffs?state=pending",
"POST /api/v1/ai/config-diffs/diff-1/approve"
]);
const serialized = JSON.stringify(calls);
expect(serialized).not.toMatch(/apiKey|token|secret|providerBaseUrl|runSocket|runEndpointUrl|hostPath|credential|dsn|rcon/i);
expect(calls[6]?.body).toEqual({ serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false });
});
});
+472 -1
View File
@@ -13,6 +13,199 @@ export type DependencyState = "unknown" | "present" | "missing" | "installing" |
export type RunUpdatePhase = "queued" | "downloading" | "staged" | "restart-requested" | "activating" | "succeeded" | "rolled-back" | "failed";
export type ServerLifecycleAction = "create" | "start" | "stop" | "status";
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired";
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
export type GameClientBridgeResultStatus = "succeeded" | "failed" | "cancelled";
export type GameClientBridgeJsonValue = string | number | boolean | null | GameClientBridgeJsonValue[] | GameClientBridgeJsonObject;
export interface GameClientBridgeJsonObject {
[key: string]: GameClientBridgeJsonValue;
}
export interface GameClientBridgeCommandDeclarationResponse {
type: string;
title: string;
permission: string;
approvalLevel: GameClientBridgeApprovalLevel;
payloadSchemaRef: string;
resultSchemaRef?: string;
timeoutSeconds: number;
maxPayloadBytes: number;
}
export interface GameClientBridgeSnapshotDeclarationResponse {
type: string;
schemaVersion: string;
schemaRef: string;
keepForSeconds: number;
maxRecords: number;
}
export interface GameClientBridgeQueryTemplateDeclarationResponse {
key: string;
title: string;
permission: string;
engine: "sqlite";
transportKey: string;
targetKey: string;
parameterSchemaRef: string;
resultSchemaRef: string;
maxRows: number;
timeoutSeconds: number;
}
export interface GameClientBridgePageContractResponse {
pageKey: string;
commandTypes?: string[];
snapshotTypes?: string[];
queryTemplateKeys?: string[];
}
export interface GameClientBridgeCompanionDeclarationResponse {
profileKey: string;
configTemplateKey: string;
configSchemaRef: string;
configFormat: "yaml";
platformBaseUrlSource: "run-control";
registrationProof: "hmac-sha256";
proofMaterialSource: "component-package";
proofMaterialEnv: string;
sessionMode: "component-session";
tlsPolicy: "verify-system-roots";
heartbeatIntervalSeconds: number;
commandPollIntervalSeconds: number;
requestTimeoutSeconds: number;
}
export interface GameClientBridgeManifestResponse {
commands: GameClientBridgeCommandDeclarationResponse[];
snapshots: GameClientBridgeSnapshotDeclarationResponse[];
queryTemplates?: GameClientBridgeQueryTemplateDeclarationResponse[];
commandRetentionSeconds: number;
maxCommands: number;
pages?: GameClientBridgePageContractResponse[];
companion?: GameClientBridgeCompanionDeclarationResponse;
}
export interface GameClientBridgeProfileDeclarationResponse {
pluginId: string;
profileKey: string;
available: boolean;
reason?: string;
commandTypes: string[];
snapshotTypes: string[];
queryTemplateKeys: string[];
}
export interface GameClientBridgeStatusResponse {
serverInstanceId: string;
pluginId: string;
available: boolean;
reason?: string;
profiles: GameClientBridgeProfileDeclarationResponse[];
}
export interface GameClientBridgeCommandResultResponse {
status: GameClientBridgeResultStatus;
summary?: string;
payload?: GameClientBridgeJsonObject;
completedAt: string;
}
export interface GameClientBridgeCommandCancellationResponse {
requestedBy?: string;
reason?: string;
cancelledAt: string;
}
export interface GameClientBridgeCommandResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
commandType: string;
priority: number;
state: GameClientBridgeCommandState;
approvalState: GameClientBridgeApprovalState;
requesterId?: string;
resultSummary?: string;
result?: GameClientBridgeCommandResultResponse;
cancellation?: GameClientBridgeCommandCancellationResponse;
auditReferences?: string[];
expiresAt: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export interface GameClientBridgeCommandListResponse {
items: GameClientBridgeCommandResponse[];
count: number;
}
export interface GameClientBridgeCommandFilterRequest {
profileKey?: string;
state?: GameClientBridgeCommandState;
commandType?: string;
}
export interface GameClientBridgeQueueRequest {
profileKey: string;
commandType: string;
payload: GameClientBridgeJsonObject;
idempotencyKey: string;
priority?: number;
expiresAt: string;
}
export interface GameClientBridgeCancelRequest {
reason?: string;
}
export interface GameClientBridgeCancelResponse {
commandId: string;
state: GameClientBridgeCommandState;
cancellation: GameClientBridgeCommandCancellationResponse;
auditReferences?: string[];
updatedAt: string;
}
export interface GameClientBridgeRetentionResponse {
keepForSeconds: number;
maxRecords?: number;
}
export interface GameClientBridgeSnapshotResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
type: string;
schemaVersion: string;
streamKey: string;
sequence: number;
observedAt: string;
payload: GameClientBridgeJsonObject;
retention: GameClientBridgeRetentionResponse;
auditReferences?: string[];
createdAt: string;
expiresAt: string;
}
export interface GameClientBridgeSnapshotListResponse {
items: GameClientBridgeSnapshotResponse[];
count: number;
}
export interface GameClientBridgeSnapshotQuery {
profileKey?: string;
type?: string;
streamKey?: string;
observedAfter?: string;
limit?: number;
}
export interface PluginPermissionsResponse {
ai: boolean;
logs: boolean;
@@ -74,6 +267,17 @@ export interface RuntimeLogSourceResponse {
retentionDays?: number;
}
export interface RuntimeLogEventResponse {
key: string;
title: string;
sourceKey: string;
eventType: string;
permission: string;
schemaRef: string;
retentionDays: number;
severity: "info" | "notice" | "warning" | "critical";
}
export interface RuntimeTransportProfileResponse {
key: string;
kind: string;
@@ -84,11 +288,18 @@ export interface RuntimeTransportProfileResponse {
export interface RuntimeClientManagerProfileResponse {
key: string;
displayName?: string;
version?: string;
revision?: string;
repository: { url: string; revisionPolicy: string; branch?: string; tag?: string; revision?: string };
supportedTargets: Array<{ os: string; arch: string }>;
build: { system: string; workspaceRef?: string; entryRef?: string };
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
outputArtifacts: string[];
deployment?: { mode: string; executableRef: string; arguments: string[]; autoStart: boolean; requiredRunCapabilities: string[] };
lifecycle?: { actions: string[]; startupTimeoutSeconds: number; stopTimeoutSeconds: number };
health?: { mode: string; intervalSeconds: number; degradedAfterSeconds: number; offlineAfterSeconds: number; requiredCapabilities: string[] };
compatibility?: { minimumVersion?: string; maximumVersion?: string; allowDowngrade: boolean };
updatePolicy?: { strategy: string; requireApproval: boolean; healthConfirmationSeconds: number; retainPrevious: boolean };
}
export interface GamePluginRuntimeProfilesResponse {
@@ -97,6 +308,7 @@ export interface GamePluginRuntimeProfilesResponse {
dependencyProbes?: RuntimeDependencyProbeResponse[];
installPlans?: RuntimeInstallPlanResponse[];
logSources?: RuntimeLogSourceResponse[];
logEvents?: RuntimeLogEventResponse[];
transportProfiles?: RuntimeTransportProfileResponse[];
clientManagers?: RuntimeClientManagerProfileResponse[];
}
@@ -119,8 +331,10 @@ export interface GamePluginResponse {
pages: GamePluginPageResponse[];
tags: string[];
aiPurposes: string[];
productionLifecycle: PluginProductionLifecycleDeclaration;
validationViolations?: string[];
runtimeProfiles?: GamePluginRuntimeProfilesResponse;
gameClientBridge?: GameClientBridgeManifestResponse;
status: GamePluginStatus;
}
@@ -149,7 +363,9 @@ export interface MarketplacePluginResponse {
pages: GamePluginPageResponse[];
tags: string[];
aiPurposes: string[];
productionLifecycle: PluginProductionLifecycleDeclaration;
validationViolations?: string[];
gameClientBridge?: GameClientBridgeManifestResponse;
status: GamePluginStatus;
source: string;
}
@@ -476,6 +692,131 @@ export interface ClientManagerDownloadRequest {
profileKey?: string;
}
export type ClientManagerLifecycleStatus =
| "requested"
| "building"
| "available"
| "deploying"
| "installed"
| "registering"
| "online"
| "degraded"
| "offline"
| "updating"
| "rolling_back"
| "stopping"
| "uninstalled"
| "failed";
export type ClientManagerLifecycleOperation = "deploy" | "start" | "stop" | "restart" | "status" | "update" | "rollback" | "uninstall";
export interface ClientManagerLifecycleActionResponse {
operation: ClientManagerLifecycleOperation;
available: boolean;
reason?: string;
}
export interface ClientManagerLifecycleJobResponse {
id: string;
state: JobState;
progress: JobProgressBody;
attempt: number;
createdAt: string;
updatedAt: string;
}
export interface ClientManagerDistributionSummaryResponse {
id: string;
artifactId: string;
sourceRevision: string;
targetOs: string;
targetArch: string;
checksum: string;
keyGeneration: number;
status: string;
}
export interface ClientManagerInstallationResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
targetOs: string;
targetArch: string;
status: ClientManagerLifecycleStatus;
phase: string;
desiredVersion?: string;
activeVersion?: string;
previousVersion?: string;
desiredRevision?: string;
activeRevision?: string;
previousRevision?: string;
desiredArtifactId?: string;
activeArtifactId?: string;
previousArtifactId?: string;
keyGeneration: number;
deploymentGeneration: number;
currentJobId?: string;
lastSuccessfulJobId?: string;
lastOperation?: ClientManagerLifecycleOperation;
health: "unknown" | "healthy" | "degraded" | "unhealthy" | "offline";
healthReason?: string;
lastSeenAt?: string;
retryable: boolean;
requiresRedeploy: boolean;
installedAt?: string;
uninstalledAt?: string;
updatedAt: string;
distribution?: ClientManagerDistributionSummaryResponse;
job?: ClientManagerLifecycleJobResponse;
actions: ClientManagerLifecycleActionResponse[];
}
export interface ClientManagerInstallationListResponse {
items: ClientManagerInstallationResponse[];
count: number;
}
export interface ClientManagerDeployRequest {
profileKey: string;
distributionId: string;
expectedDeploymentGeneration?: number;
idempotencyKey: string;
}
export interface ClientManagerControlRequest {
profileKey: string;
operation: "start" | "stop" | "restart" | "status" | "rollback";
expectedDeploymentGeneration: number;
idempotencyKey: string;
}
export interface ClientManagerUpdateRequest {
profileKey: string;
distributionId: string;
expectedDeploymentGeneration: number;
approved: boolean;
idempotencyKey: string;
}
export interface ClientManagerRetryRequest {
profileKey: string;
expectedDeploymentGeneration: number;
idempotencyKey: string;
}
export interface ClientManagerRevokeSessionRequest {
profileKey: string;
reason: string;
}
export interface ClientManagerUninstallRequest {
profileKey: string;
expectedDeploymentGeneration: number;
confirmed: boolean;
idempotencyKey: string;
}
export interface ComponentKeyResetRequest {
componentKind: "run" | "client-manager" | string;
componentKey?: string;
@@ -625,7 +966,7 @@ export interface AiProviderResponse {
id: string;
name: string;
kind: AiProviderKind;
baseUrl: string;
baseUrlConfigured: boolean;
apiKeyConfigured: boolean;
models: string[];
defaultModel?: string;
@@ -1050,8 +1391,138 @@ export interface AIConfigRecommendationResponse {
key: string;
suggestedConfig?: string;
diffSummary: string;
diffId: string;
expiresAt: string;
}
export interface PluginProductionLifecycleDeclaration {
operations: PluginLifecycleOperation[];
dependencyPolicy: "required" | "optional";
approvalRequired: Array<"disable" | "rollback" | "retire">;
}
export type CapacityAdmissionState = "accepted" | "deferred" | "denied";
export interface CapacityAdmissionDecisionResponse {
accepted: boolean;
state: CapacityAdmissionState;
reason: string;
retryAfterSeconds?: number;
serverInstanceId?: string;
runEndpointId?: string;
capability: string;
targetKey?: string;
maxJobs: number;
runningJobs: number;
queuedJobs: number;
pressureCodes?: string[];
checkedAt: string;
alertId?: string;
auditEventId?: string;
}
export interface EndpointCapacityProjectionResponse {
runEndpointId: string;
displayName: string;
status: RunEndpointStatus;
capabilities: string[];
maxJobs: number;
runningJobs: number;
queuedJobs: number;
logBacklogBatches?: number;
artifactBacklogChunks?: number;
pressureCodes?: string[];
summary?: string;
lastHeartbeatAt: string;
lastAdmissionDecision?: CapacityAdmissionState;
lastAdmissionReason?: string;
lastAdmissionCheckedAt?: string;
}
export interface ProductionCapacitySummaryResponse {
endpoints: EndpointCapacityProjectionResponse[];
totalMaxJobs: number;
totalRunningJobs: number;
totalQueuedJobs: number;
activeAlerts: number;
generatedAt: string;
}
export type AlertState = "active" | "acknowledged" | "resolved";
export interface AlertResponse {
id: string;
sourceKind: string;
sourceId: string;
ruleKey: string;
severity: "info" | "warning" | "critical";
state: AlertState;
title: string;
message: string;
occurrenceCount: number;
retryable: boolean;
retryAfterSeconds?: number;
lastJobId?: string;
lastAuditEventId?: string;
lastSeenAt: string;
acknowledgedBy?: string;
acknowledgedAt?: string;
resolvedBy?: string;
resolvedAt?: string;
resolutionNote?: string;
createdAt: string;
updatedAt: string;
}
export interface AlertListResponse { items: AlertResponse[]; count: number; }
export interface AlertRetryResponse { status: string; alert: AlertResponse; decision: CapacityAdmissionDecisionResponse; }
export type PluginLifecycleOperation = "install" | "enable" | "disable" | "upgrade" | "rollback" | "retire" | "dependency-check";
export interface PluginLifecycleInstallationResponse {
id: string;
pluginId: string;
serverInstanceId: string;
currentVersion?: string;
targetVersion?: string;
previousVersion?: string;
desiredState: string;
currentState: string;
lastOperation?: PluginLifecycleOperation;
compatibility?: string;
dependencyState?: string;
jobId?: string;
alertId?: string;
auditEventId?: string;
failureReason?: string;
createdAt: string;
updatedAt: string;
}
export interface PluginLifecycleListResponse { items: PluginLifecycleInstallationResponse[]; count: number; }
export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; confirmed: boolean; }
export interface PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; decision: CapacityAdmissionDecisionResponse; alert?: AlertResponse; }
export interface AIConfigDiffPreviewResponse {
id: string;
requestId: string;
createdBy: string;
serverInstanceId: string;
pluginId?: string;
providerId?: string;
model?: string;
key: string;
configVersion: number;
currentConfigChecksum?: string;
proposedConfig?: string;
diffSummary: string;
state: "pending" | "approved" | "cancelled" | "expired";
expiresAt: string;
approvedBy?: string;
approvedAt?: string;
jobId?: string;
createdAt: string;
updatedAt: string;
}
export interface AIConfigDiffListResponse { items: AIConfigDiffPreviewResponse[]; count: number; }
export interface AIConfigDiffApprovalResponse { preview: AIConfigDiffPreviewResponse; dispatch: ServerConfigWriteDispatchResponse; }
export interface AIInvocationSafeErrorResponse {
code: string;
message: string;
+1
View File
@@ -76,6 +76,7 @@ export function App() {
routes={navRoutes}
currentPage={navigation.pageId}
session={user}
operations={operations.operations}
onNavigate={handleNavigate}
>
{allowed ? (
@@ -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>
);
}
+6 -2
View File
@@ -9,6 +9,7 @@ export interface AiProviderFormState {
name: string;
kind: AiProviderKind;
baseUrl: string;
baseUrlConfigured: boolean;
apiKeyRef: string;
apiKeyConfigured: boolean;
modelsText: string;
@@ -155,7 +156,8 @@ export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormS
id: provider.id,
name: provider.name,
kind: provider.kind,
baseUrl: provider.baseUrl,
baseUrl: "",
baseUrlConfigured: provider.baseUrlConfigured,
apiKeyRef: "",
apiKeyConfigured: provider.apiKeyConfigured,
modelsText: provider.models.join(", "),
@@ -173,6 +175,7 @@ export function aiProviderFormFromDefaults(kind: AiProviderKind): AiProviderForm
name: defaults.name,
kind: defaults.kind,
baseUrl: defaults.baseUrl,
baseUrlConfigured: false,
apiKeyRef: defaults.apiKeyRef,
apiKeyConfigured: false,
modelsText: defaults.modelsText,
@@ -191,6 +194,7 @@ export function applyAiProviderKindDefaults(current: AiProviderFormState, kind:
name: defaults.name,
kind: defaults.kind,
baseUrl: defaults.baseUrl,
baseUrlConfigured: false,
apiKeyRef: defaults.apiKeyRef,
apiKeyConfigured: false,
modelsText: defaults.modelsText,
@@ -212,7 +216,7 @@ export function completeAiProviderForm(form: AiProviderFormState): AiProviderFor
...form,
id: generatedAiProviderId(form),
name: form.name.trim() || defaults.name,
baseUrl: form.baseUrl.trim() || defaults.baseUrl,
baseUrl: form.baseUrl.trim() || (form.baseUrlConfigured ? "" : defaults.baseUrl),
apiKeyRef: form.apiKeyConfigured && !form.apiKeyRef.trim() ? "" : form.apiKeyRef.trim() || defaults.apiKeyRef,
modelsText,
defaultModel: form.defaultModel.trim() || models[0] || defaults.defaultModel,
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
import type { OperationRecord } from "./workspace";
import { jobBuckets, projectOperationForTray, summarizeEndpointOperations, summarizeServerOperations } from "./operationsConsole";
function job(id: string, state: JobResponse["state"], updatedAt: string, serverInstanceId = "server-1"): JobResponse {
return {
id,
serverInstanceId,
runEndpointId: "endpoint-1",
capability: "server.lifecycle.start",
idempotencyKey: `idem-${id}`,
state,
progress: { percent: state === "succeeded" ? 100 : 40 },
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 1, maxBackoffSeconds: 10 },
attempt: 1,
reconcileCount: 0,
createdAt: updatedAt,
updatedAt
};
}
function server(id: string, state: ServerInstanceResponse["state"], updatedAt: string): ServerInstanceResponse {
return {
id,
pluginId: "scum-server-plugin",
pluginVersion: "1.0.0",
runEndpointId: "endpoint-1",
name: id,
adminUserIds: [],
state,
configVersion: 1,
createdAt: updatedAt,
updatedAt
};
}
describe("operations console contracts", () => {
it("classifies active, retrying, failed, and terminal jobs using Platform state", () => {
const buckets = jobBuckets([
job("queued", "queued", "2026-07-18T10:00:00Z"),
job("retrying", "retrying", "2026-07-18T10:02:00Z"),
job("failed", "failed", "2026-07-18T10:03:00Z"),
job("succeeded", "succeeded", "2026-07-18T10:01:00Z")
]);
expect(buckets.active.map((item) => item.id)).toEqual(["retrying", "queued"]);
expect(buckets.retrying.map((item) => item.id)).toEqual(["retrying"]);
expect(buckets.failed.map((item) => item.id)).toEqual(["failed"]);
expect(buckets.succeeded.map((item) => item.id)).toEqual(["succeeded"]);
});
it("orders failed servers and failed jobs before active and healthy servers", () => {
const summaries = summarizeServerOperations(
[
server("healthy", "running", "2026-07-18T10:04:00Z"),
server("active", "ready", "2026-07-18T10:03:00Z"),
server("job-failed", "stopped", "2026-07-18T10:02:00Z"),
server("server-failed", "failed", "2026-07-18T10:01:00Z")
],
new Map(),
[job("active-job", "running", "2026-07-18T10:05:00Z", "active"), job("failed-job", "failed", "2026-07-18T10:06:00Z", "job-failed")]
);
expect(summaries.map((item) => item.instance.id)).toEqual(["server-failed", "job-failed", "active", "healthy"]);
expect(summaries.find((item) => item.instance.id === "job-failed")?.failedJobs).toBe(1);
});
it("summarizes only endpoint-safe capacity and status values", () => {
const endpoints: RunEndpointResponse[] = [
{
id: "endpoint-1",
displayName: "Primary",
version: "1.0.0",
status: "online",
capabilities: ["server.lifecycle.start"],
capacity: { maxJobs: 4, runningJobs: 2, queuedJobs: 1 },
lastHeartbeatAt: "2026-07-18T10:00:00Z"
},
{
id: "endpoint-2",
displayName: "Secondary",
version: "1.0.0",
status: "degraded",
capabilities: [],
capacity: { maxJobs: 2, runningJobs: 1, queuedJobs: 3 },
lastHeartbeatAt: "2026-07-18T10:00:00Z"
}
];
expect(summarizeEndpointOperations(endpoints)).toEqual({ total: 2, online: 1, degraded: 1, offline: 0, disabled: 0, activeJobs: 3, queuedJobs: 4 });
});
it("projects an allowlisted operation tray shape and protects unsafe target strings", () => {
const operation = {
id: "op-1",
intent: "更新配置",
targetKind: "config",
targetId: "/Users/operator/private.cfg",
requester: "Operator",
status: "failed",
errorReason: "平台拒绝请求:Bearer raw-token /Users/operator/private.cfg PID=4201 unix:///var/run/run.sock",
diagnosticId: "/private/tmp/diagnostic.json",
createdAt: "2026-07-18T10:00:00Z",
updatedAt: "2026-07-18T10:01:00Z",
rawToken: "run-token-should-not-project"
} as OperationRecord & { rawToken: string };
const projected = projectOperationForTray(operation);
expect(projected.targetLabel).toBe("配置 / 受保护目标");
expect(JSON.stringify(projected)).not.toContain("/Users/");
expect(JSON.stringify(projected)).not.toContain("run-token");
expect(JSON.stringify(projected)).not.toContain("raw-token");
expect(JSON.stringify(projected)).not.toContain("/var/run/");
expect(JSON.stringify(projected)).not.toContain("4201");
expect(projected.diagnosticId).toBe("受保护诊断");
expect(projected).not.toHaveProperty("requester");
});
});
+178
View File
@@ -0,0 +1,178 @@
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
import type { OperationRecord } from "./workspace";
export type OperationsModuleState<T> =
| { status: "loading"; refreshedAt?: string }
| { status: "error"; reason: string; diagnosticId: string; refreshedAt?: string }
| { status: "ready"; data: T; refreshedAt: string };
export interface JobBuckets {
active: JobResponse[];
retrying: JobResponse[];
failed: JobResponse[];
succeeded: JobResponse[];
cancelled: JobResponse[];
}
export interface ServerOperationsSummary {
instance: ServerInstanceResponse;
metrics?: ServerMetricsResponse;
activeJobs: number;
failedJobs: number;
latestJob?: JobResponse;
}
export interface EndpointOperationsSummary {
total: number;
online: number;
degraded: number;
offline: number;
disabled: number;
activeJobs: number;
queuedJobs: number;
}
export interface OperationTrayItem {
id: string;
intent: string;
targetLabel: string;
status: OperationRecord["status"];
message?: string;
errorReason?: string;
diagnosticId?: string;
updatedAt: string;
}
const activeJobStates = new Set<JobResponse["state"]>(["queued", "accepted", "running", "retrying"]);
export function isActiveJob(job: JobResponse): boolean {
return activeJobStates.has(job.state);
}
export function jobBuckets(jobs: JobResponse[]): JobBuckets {
const sorted = [...jobs].sort((left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt));
return {
active: sorted.filter(isActiveJob),
retrying: sorted.filter((job) => job.state === "retrying"),
failed: sorted.filter((job) => job.state === "failed"),
succeeded: sorted.filter((job) => job.state === "succeeded"),
cancelled: sorted.filter((job) => job.state === "cancelled")
};
}
export function summarizeServerOperations(
instances: ServerInstanceResponse[],
metrics: Map<string, ServerMetricsResponse>,
jobs: JobResponse[]
): ServerOperationsSummary[] {
const jobsByServer = new Map<string, JobResponse[]>();
for (const job of jobs) {
if (!job.serverInstanceId) {
continue;
}
const serverJobs = jobsByServer.get(job.serverInstanceId) ?? [];
serverJobs.push(job);
jobsByServer.set(job.serverInstanceId, serverJobs);
}
return instances
.map((instance) => {
const serverJobs = [...(jobsByServer.get(instance.id) ?? [])].sort(
(left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt)
);
return {
instance,
metrics: metrics.get(instance.id),
activeJobs: serverJobs.filter(isActiveJob).length,
failedJobs: serverJobs.filter((job) => job.state === "failed").length,
latestJob: serverJobs[0]
};
})
.sort(compareServerOperations);
}
export function summarizeEndpointOperations(endpoints: RunEndpointResponse[]): EndpointOperationsSummary {
return endpoints.reduce<EndpointOperationsSummary>(
(summary, endpoint) => ({
total: summary.total + 1,
online: summary.online + Number(endpoint.status === "online"),
degraded: summary.degraded + Number(endpoint.status === "degraded"),
offline: summary.offline + Number(endpoint.status === "offline"),
disabled: summary.disabled + Number(endpoint.status === "disabled"),
activeJobs: summary.activeJobs + endpoint.capacity.runningJobs,
queuedJobs: summary.queuedJobs + endpoint.capacity.queuedJobs
}),
{ total: 0, online: 0, degraded: 0, offline: 0, disabled: 0, activeJobs: 0, queuedJobs: 0 }
);
}
export function projectOperationForTray(operation: OperationRecord): OperationTrayItem {
return {
id: operation.id,
intent: operation.intent,
targetLabel: safeOperationTarget(operation.targetKind, operation.targetId),
status: operation.status,
message: safeDiagnosticText(operation.message),
errorReason: safeDiagnosticText(operation.errorReason),
diagnosticId: safeDiagnosticId(operation.diagnosticId),
updatedAt: operation.updatedAt
};
}
export function moduleFreshnessLabel(refreshedAt?: string): string {
if (!refreshedAt) {
return "尚未刷新";
}
const time = new Date(refreshedAt);
return Number.isNaN(time.getTime()) ? "刷新时间未知" : `刷新于 ${time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
function compareServerOperations(left: ServerOperationsSummary, right: ServerOperationsSummary): number {
const attentionDifference = serverAttentionRank(right) - serverAttentionRank(left);
if (attentionDifference !== 0) {
return attentionDifference;
}
const updatedDifference = timestamp(right.instance.updatedAt) - timestamp(left.instance.updatedAt);
return updatedDifference !== 0 ? updatedDifference : left.instance.name.localeCompare(right.instance.name, "zh-CN");
}
function serverAttentionRank(summary: ServerOperationsSummary): number {
if (summary.instance.state === "failed") {
return 50;
}
if (summary.failedJobs > 0) {
return 40;
}
if (summary.instance.state === "installing") {
return 30;
}
if (summary.activeJobs > 0) {
return 20;
}
if (summary.instance.state === "running") {
return 10;
}
return 0;
}
function safeOperationTarget(kind: OperationRecord["targetKind"], targetId: string): string {
const kindLabel: Record<OperationRecord["targetKind"], string> = {
server: "服务器",
plugin: "插件",
config: "配置",
llm: "AI 提供商",
platform: "平台"
};
const safeId = /^[a-zA-Z0-9._:-]{1,96}$/.test(targetId) ? targetId : "受保护目标";
return `${kindLabel[kind]} / ${safeId}`;
}
function safeDiagnosticId(value: string | undefined): string | undefined {
return value && /^[a-zA-Z0-9._:-]{1,96}$/.test(value) ? value : value ? "受保护诊断" : undefined;
}
function timestamp(value: string): number {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
+3 -1
View File
@@ -4,10 +4,12 @@ import type { UserProfileUpdateRequest, UserThemePreferenceRequest, UserThemePre
import type { CurrentUserView, WorkspaceCapability } from "./workspace";
import type { OperationTracker } from "../stores/operations";
export type PageId = "home" | "servers" | "serverDetail" | "plugins" | "users" | "aiProviders" | "maintenance" | "profileSettings";
export type PageId = "home" | "servers" | "serverDetail" | "pluginPage" | "plugins" | "users" | "aiProviders" | "maintenance" | "profileSettings";
export interface PageParams {
serverId?: string;
pluginId?: string;
routeKey?: string;
}
export interface PageRoute {
@@ -17,8 +17,16 @@ Plugin page runs with safe platform context.
- `logs.query`: historical log query and analysis windows.
- `artifacts.open`: artifact upload/download references.
- `files.request`: scoped file operation requests.
- `remote.access.request`: declared logical remote-access or read-only query-template request.
- `run.distribution.request`: platform-mediated Run distribution request.
- `dependencies.request`: declared dependency check or install request.
- `logs.backfill.request`: bounded historical log backfill request.
- `client-manager.request`: Client Manager lifecycle request through Platform.
- `plugin-lifecycle.request`: declared plugin lifecycle request through Platform.
- `ai.invoke`: platform-mediated AI invocation.
The host intersects manifest-level and page-level permissions/actions before exposing context. The SCUM operations page additionally intersects its command, snapshot, and query-template keys with `gameClientBridge.pages.operations`; it does not synthesize undeclared SCUM semantics.
## Forbidden
The host must not expose raw auth storage, AI keys, run credentials, host paths, or storage backend credentials.
+75
View File
@@ -1,3 +1,5 @@
import type { GamePluginResponse } from "../api/types";
export type PluginPermission =
| "server.create"
| "server.read"
@@ -7,6 +9,13 @@ export type PluginPermission =
| "server.logs.read"
| "server.artifacts.read"
| "server.artifacts.write"
| "server.remote.access"
| "server.run.distribution"
| "server.dependencies.manage"
| "server.client-manager.manage"
| "server.game-client.read"
| "server.game-client.command"
| "server.game-client.maintenance"
| "ai.invoke";
export type PluginBridgeAction =
@@ -15,8 +24,56 @@ export type PluginBridgeAction =
| "logs.query"
| "artifacts.open"
| "files.request"
| "remote.access.request"
| "run.distribution.request"
| "dependencies.request"
| "logs.backfill.request"
| "client-manager.request"
| "plugin-lifecycle.request"
| "ai.invoke";
const pluginPermissions: readonly PluginPermission[] = [
"server.create",
"server.read",
"server.lifecycle",
"server.files.read",
"server.files.write",
"server.logs.read",
"server.artifacts.read",
"server.artifacts.write",
"server.remote.access",
"server.run.distribution",
"server.dependencies.manage",
"server.client-manager.manage",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance",
"ai.invoke"
];
const pluginBridgeActions: readonly PluginBridgeAction[] = [
"server.instances.read",
"jobs.dispatch",
"logs.query",
"artifacts.open",
"files.request",
"remote.access.request",
"run.distribution.request",
"dependencies.request",
"logs.backfill.request",
"client-manager.request",
"plugin-lifecycle.request",
"ai.invoke"
];
export function isPluginPermission(value: string): value is PluginPermission {
return pluginPermissions.includes(value as PluginPermission);
}
export function isPluginBridgeAction(value: string): value is PluginBridgeAction {
return pluginBridgeActions.includes(value as PluginBridgeAction);
}
export interface PluginPageContract {
key: string;
title: string;
@@ -33,6 +90,24 @@ export interface PluginBridgeManifestContract {
aiPurposes?: string[];
}
export function pluginBridgeManifestContractFromResponse(
plugin: Pick<GamePluginResponse, "id" | "declaredPermissions" | "bridgeActions" | "pages" | "aiPurposes">
): PluginBridgeManifestContract {
return {
id: plugin.id,
declaredPermissions: plugin.declaredPermissions.filter(isPluginPermission),
bridgeActions: plugin.bridgeActions.filter(isPluginBridgeAction),
pages: plugin.pages.map((page) => ({
key: page.key,
title: page.title,
path: page.path,
permissions: page.permissions.filter(isPluginPermission),
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction)
})),
aiPurposes: [...plugin.aiPurposes]
};
}
export interface PluginBridgeThemeTokens {
colorScheme: "light" | "dark";
accentColor: string;
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types";
import { resolveScumOperationsPageContract } from "./scumOperations";
const plugin = {
id: "game.scum",
pages: [{
key: "operations",
title: "SCUM 运维",
path: "/operations",
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access", "unknown.permission"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request", "unknown.action"]
}],
gameClientBridge: {
commands: [
{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 },
{ type: "maintenance.prepare", title: "Prepare maintenance", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", payloadSchemaRef: "schemas/bridge/maintenance.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }
],
snapshots: [
{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 },
{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.json", keepForSeconds: 3600, maxRecords: 24 }
],
queryTemplates: [
{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }
],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
},
runtimeProfiles: {
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" }]
},
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }
} satisfies Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle">;
describe("SCUM operations page contract", () => {
it("projects only declarations owned by the plugin operations page", () => {
const resolution = resolveScumOperationsPageContract(plugin, "server-1");
expect(resolution).toMatchObject({
available: true,
contract: {
pluginId: "game.scum",
routeKey: "operations",
serverInstanceId: "server-1",
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
commands: [{ type: "announcement.send" }],
snapshots: [{ type: "companion.health" }],
queryTemplates: [{ key: "scum.player.search", engine: "sqlite" }],
logEvents: [{ eventType: "scum.chat" }],
productionLifecycle: { dependencyPolicy: "required" }
}
});
expect(JSON.stringify(resolution)).not.toMatch(/sqlText|hostPath|sessionToken|componentKey|credential|socket/i);
});
it("reports declaration and server-context availability without inventing fallback semantics", () => {
expect(resolveScumOperationsPageContract({ ...plugin, id: "game.other" }, "server-1")).toMatchObject({ available: false });
expect(resolveScumOperationsPageContract(plugin, "")).toMatchObject({ available: false, reason: "缺少服务器实例上下文。" });
expect(resolveScumOperationsPageContract({ ...plugin, gameClientBridge: undefined }, "server-1")).toMatchObject({ available: false });
});
});
+144
View File
@@ -0,0 +1,144 @@
import type {
GameClientBridgeCommandDeclarationResponse,
GameClientBridgeQueryTemplateDeclarationResponse,
GameClientBridgeSnapshotDeclarationResponse,
GamePluginResponse,
PluginProductionLifecycleDeclaration,
RuntimeLogEventResponse,
RuntimeLogSourceResponse
} from "../api/types";
import {
isPluginBridgeAction,
isPluginPermission,
type PluginBridgeAction,
type PluginPermission
} from "./pluginBridge";
export const scumOperationsPluginId = "game.scum";
export const scumOperationsRouteKey = "operations";
export interface ScumOperationsPageContract {
pluginId: typeof scumOperationsPluginId;
routeKey: typeof scumOperationsRouteKey;
serverInstanceId: string;
title: string;
permissions: PluginPermission[];
bridgeActions: PluginBridgeAction[];
commands: GameClientBridgeCommandDeclarationResponse[];
snapshots: GameClientBridgeSnapshotDeclarationResponse[];
queryTemplates: GameClientBridgeQueryTemplateDeclarationResponse[];
logSources: RuntimeLogSourceResponse[];
logEvents: RuntimeLogEventResponse[];
productionLifecycle: PluginProductionLifecycleDeclaration;
}
export interface ScumCompanionHealthView {
status: "online" | "degraded" | "offline" | "unknown";
version?: string;
observedAt?: string;
latencyMs?: number;
capabilities: string[];
}
export interface ScumSessionView {
sessionId: string;
playerName: string;
startedAt?: string;
}
export interface ScumPlayerView {
playerId: string;
playerName: string;
status: string;
squadId?: string;
pingMs?: number;
lastSeenAt?: string;
}
export interface ScumSquadView {
squadId: string;
name: string;
memberCount: number;
leaderPlayerId?: string;
lastActiveAt?: string;
}
export interface ScumVehicleView {
vehicleId: string;
vehicleType: string;
status: string;
ownerPlayerId?: string;
squadId?: string;
fuelPercent?: number;
healthPercent?: number;
lastSeenAt?: string;
}
export interface ScumFlagView {
flagId: string;
status: string;
ownerPlayerId?: string;
squadId?: string;
radiusMeters?: number;
lastUpdatedAt?: string;
}
export interface ScumSnapshotCollection<T> {
observedAt?: string;
total: number;
items: T[];
}
export interface ScumOperationsSnapshotView {
health?: ScumCompanionHealthView;
sessions: ScumSnapshotCollection<ScumSessionView>;
players: ScumSnapshotCollection<ScumPlayerView>;
squads: ScumSnapshotCollection<ScumSquadView>;
vehicles: ScumSnapshotCollection<ScumVehicleView>;
flags: ScumSnapshotCollection<ScumFlagView>;
}
export type ScumOperationsPageResolution =
| { available: true; contract: ScumOperationsPageContract }
| { available: false; reason: string };
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle">;
export function resolveScumOperationsPageContract(plugin: ScumPluginProjection, serverInstanceId: string): ScumOperationsPageResolution {
if (plugin.id !== scumOperationsPluginId) {
return { available: false, reason: "该路由仅承载 game.scum 插件声明的运维页。" };
}
if (!serverInstanceId.trim()) {
return { available: false, reason: "缺少服务器实例上下文。" };
}
const page = plugin.pages.find((candidate) => candidate.key === scumOperationsRouteKey);
if (!page) {
return { available: false, reason: "SCUM 插件未声明 operations 页面。" };
}
const manifest = plugin.gameClientBridge;
const bridgePage = manifest?.pages?.find((candidate) => candidate.pageKey === scumOperationsRouteKey);
if (!manifest || !bridgePage) {
return { available: false, reason: "SCUM 插件未声明 operations 的 Game Client Bridge 契约。" };
}
const commandTypes = new Set(bridgePage.commandTypes ?? []);
const snapshotTypes = new Set(bridgePage.snapshotTypes ?? []);
const queryTemplateKeys = new Set(bridgePage.queryTemplateKeys ?? []);
return {
available: true,
contract: {
pluginId: scumOperationsPluginId,
routeKey: scumOperationsRouteKey,
serverInstanceId,
title: page.title,
permissions: page.permissions.filter(isPluginPermission),
bridgeActions: (page.bridgeActions ?? []).filter(isPluginBridgeAction),
commands: manifest.commands.filter((command) => commandTypes.has(command.type)),
snapshots: manifest.snapshots.filter((snapshot) => snapshotTypes.has(snapshot.type)),
queryTemplates: (manifest.queryTemplates ?? []).filter((template) => queryTemplateKeys.has(template.key)),
logSources: [...(plugin.runtimeProfiles?.logSources ?? [])],
logEvents: [...(plugin.runtimeProfiles?.logEvents ?? [])],
productionLifecycle: plugin.productionLifecycle
}
};
}
+6 -1
View File
@@ -92,6 +92,9 @@ export interface ServerCardView {
instance: ServerInstanceResponse;
metrics?: ServerMetricsResponse;
pendingJobs: number;
activeJobs?: number;
failedJobs?: number;
latestJob?: JobResponse;
}
export type ServerStatusFilter = "all" | "online" | "offline" | "attention";
@@ -187,7 +190,9 @@ export interface ConfigDiffView {
export interface LlmSuggestionView {
serverInstanceId: string;
source: "api" | "local";
source: "api";
recommendation: string;
diffId?: string;
expiresAt?: string;
diff?: ConfigDiffView;
}
+23 -1
View File
@@ -2,13 +2,14 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AiProvidersPage } from "./AiProvidersPage";
import aiProvidersPageSource from "./AiProvidersPage.tsx?raw";
import type { AiProviderResponse } from "../api/types";
const provider: AiProviderResponse = {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
baseUrlConfigured: true,
apiKeyConfigured: true,
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
@@ -38,6 +39,16 @@ describe("AiProvidersPage", () => {
expect(html).not.toContain("OpenAI Relay");
});
it("renders provider API failure with scoped retry instead of a browser reload", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ listState: "error", listError: "backend unavailable", source: "error" }} />);
expect(html).toContain("AI 提供商加载失败");
expect(html).toContain("backend unavailable");
expect(html).toContain("重试");
expect(aiProvidersPageSource).toContain("refreshProviders");
expect(aiProvidersPageSource).not.toContain("window.location.reload");
});
it("renders local-development fixtures with an explicit status label", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], listState: "ready", source: "local-development" }} />);
@@ -65,6 +76,7 @@ describe("AiProvidersPage", () => {
expect(html).toContain("secret://providers/...");
expect(html).not.toContain('name="id"');
expect(html).not.toContain("api.example.test");
expect(html).not.toContain("relay.example.test");
});
it("does not render raw key field names", () => {
@@ -77,4 +89,14 @@ describe("AiProvidersPage", () => {
expect(html).not.toContain("api_key=");
expect(html).not.toContain("Bearer ");
});
it("keeps provider state actions confirmed and provider-scoped busy", () => {
expect(aiProvidersPageSource).toContain("confirmStatus");
expect(aiProvidersPageSource).toContain("busyProviderIds.has(provider.id)");
expect(aiProvidersPageSource).toContain("确认提供商状态变更");
expect(aiProvidersPageSource).toContain("只读模式");
expect(aiProvidersPageSource).toContain("operations?.begin");
expect(aiProvidersPageSource).toContain("operations?.succeed");
expect(aiProvidersPageSource).toContain("operations?.fail");
});
});
+140 -67
View File
@@ -1,11 +1,13 @@
import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, UserRoundMinus } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { AIConfigDiffReviewPanel } from "../components/AIConfigDiffReviewPanel";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { isPlatformAdmin } from "../contracts/workspace";
import {
aiProviderKindDefaults,
applyAiProviderKindDefaults,
@@ -44,7 +46,7 @@ const providerPresets: ProviderPreset[] = [
{ kind: "custom", label: "自定义" }
];
export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
export function AiProvidersPage({ initialState, session, operations }: AiProvidersPageProps = {}) {
const initialSelectedProvider = initialState?.selectedId ? initialState.providers?.find((provider) => provider.id === initialState.selectedId) : undefined;
const [providers, setProviders] = useState<AiProviderResponse[]>(initialState?.providers ?? []);
const [listState, setListState] = useState<AiProviderListState>(initialState?.listState ?? "loading");
@@ -56,59 +58,59 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
const [viewState, setViewState] = useState<AiProviderViewState>(initialState?.source ?? "local-development");
const [action, setAction] = useState<AiProviderActionState | null>(initialState?.action ?? null);
const [confirmRetire, setConfirmRetire] = useState<AiProviderResponse | null>(null);
const [confirmStatus, setConfirmStatus] = useState<AiProviderResponse | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [busyProviderIds, setBusyProviderIds] = useState<Set<string>>(() => new Set());
const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
const [formCheck, setFormCheck] = useState<FormCheckState | null>(null);
useEffect(() => {
let cancelled = false;
const refreshProviders = useCallback(async () => {
if (initialState?.providers) {
setListState(initialState.listState ?? "ready");
setViewState(initialState.source ?? "local-development");
return () => {
cancelled = true;
};
return;
}
platformApiClient
.listAiProviders()
.then((response) => {
if (cancelled) {
return;
}
const nextSelectedId =
initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId)
? initialState.selectedId
: "";
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
setProviders(response.items);
setSelectedId(nextSelectedId);
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
setFormMode(nextSelectedProvider ? "edit" : null);
setListState("ready");
setListError("");
setViewState("api");
setFormCheck(null);
})
.catch((error: unknown) => {
if (cancelled) {
return;
}
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
});
setListState("loading");
setListError("");
try {
const response = await platformApiClient.listAiProviders();
const nextSelectedId = initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId) ? initialState.selectedId : "";
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
setProviders(response.items);
setSelectedId(nextSelectedId);
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
setFormMode(nextSelectedProvider ? "edit" : null);
setListState("ready");
setListError("");
setViewState("api");
setFormCheck(null);
} catch (error: unknown) {
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
}
}, [initialState]);
return () => {
cancelled = true;
};
}, [initialState?.listState, initialState?.providers, initialState?.selectedId, initialState?.source]);
useEffect(() => {
void refreshProviders();
}, [refreshProviders]);
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
const filteredProviders = useMemo(() => providers.filter((provider) => filter === "all" || provider.status === filter), [filter, providers]);
const canManage = Boolean(session && isPlatformAdmin(session));
function setProviderBusy(providerId: string, busy: boolean) {
setBusyProviderIds((current) => {
const next = new Set(current);
if (busy) next.add(providerId);
else next.delete(providerId);
return next;
});
}
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
setForm((current) => ({ ...current, [key]: value }));
@@ -120,6 +122,8 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
} else if (key === "apiKeyRef") {
setForm((current) => ({ ...current, apiKeyRef: event.target.value, apiKeyConfigured: current.apiKeyConfigured || Boolean(event.target.value.trim()) }));
} else if (key === "baseUrl") {
setForm((current) => ({ ...current, baseUrl: event.target.value, baseUrlConfigured: current.baseUrlConfigured || Boolean(event.target.value.trim()) }));
} else {
updateForm(key, event.target.value);
}
@@ -127,6 +131,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function selectProvider(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setSelectedId(provider.id);
setForm(aiProviderToForm(provider));
setFormMode("edit");
@@ -136,6 +143,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function startCreate() {
if (!canManage) {
return;
}
setSelectedId("");
setForm(emptyAiProviderForm());
setFormMode("create");
@@ -165,7 +175,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
if (!completed.name.trim()) {
missing.push("名称");
}
if (!completed.baseUrl.trim()) {
if (!completed.baseUrl.trim() && !completed.baseUrlConfigured) {
missing.push("Base URL");
}
if (completed.relayMode !== "local" && !completed.apiKeyConfigured && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
@@ -186,10 +196,15 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canManage) {
return;
}
setViewState("saving");
const completed = completeAiProviderForm(form);
const providerId = generatedAiProviderId(completed);
const existing = providers.some((provider) => provider.id === providerId);
const intent = existing ? "更新 AI 提供商" : "创建 AI 提供商";
const operationId = operations?.begin({ intent, targetKind: "llm", targetId: providerId, requester: session?.displayName });
try {
const saved = existing
@@ -202,6 +217,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
setListState("ready");
setListError("");
setAction({ providerId: saved.id, label: "save", success: true, message: "已保存" });
if (operationId) operations?.succeed(operationId, `${saved.name} 已保存`);
setFormCheck(null);
setFormMode(null);
} catch (error) {
@@ -209,55 +225,98 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
const message = errorMessage(error, "保存失败");
setAction({ providerId: generatedAiProviderId(form) || selectedId, label: "save", success: false, message });
setFormCheck({ status: "failed", message });
if (operationId) operations?.fail(operationId, message, operationId);
}
}
async function handleStatus(provider: AiProviderResponse) {
async function handleStatus(provider: AiProviderResponse): Promise<boolean> {
if (!canManage) {
return false;
}
setExpandedProviderId(null);
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
const intent = nextStatus === "active" ? "启用 AI 提供商" : "停用 AI 提供商";
const operationId = operations?.begin({ intent, targetKind: "llm", targetId: provider.id, requester: session?.displayName });
setProviderBusy(provider.id, true);
try {
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status === "disabled" ? "已停用" : "已启用" });
if (operationId) operations?.succeed(operationId, `${updated.name}${updated.status === "disabled" ? "停用" : "启用"}`);
return true;
} catch (error) {
setAction({ providerId: provider.id, label: "status", success: false, message: errorMessage(error, "状态更新失败") });
const message = errorMessage(error, "状态更新失败");
setAction({ providerId: provider.id, label: "status", success: false, message });
if (operationId) operations?.fail(operationId, message, operationId);
return false;
} finally {
setProviderBusy(provider.id, false);
}
}
async function handleRetire(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setConfirmBusy(true);
const operationId = operations?.begin({ intent: "退役 AI 提供商", targetKind: "llm", targetId: provider.id, requester: session?.displayName });
setProviderBusy(provider.id, true);
try {
const retired = await platformApiClient.setAiProviderStatus(provider.id, aiProviderRetireRequest());
upsertProvider(retired);
setAction({ providerId: provider.id, label: "retire", success: true, message: "已退役" });
if (operationId) operations?.succeed(operationId, `${retired.name} 已退役`);
setConfirmRetire(null);
} catch (error) {
setAction({ providerId: provider.id, label: "retire", success: false, message: errorMessage(error, "退役失败,可能仍被引用") });
const message = errorMessage(error, "退役失败,可能仍被引用");
setAction({ providerId: provider.id, label: "retire", success: false, message });
if (operationId) operations?.fail(operationId, message, operationId);
} finally {
setConfirmBusy(false);
setProviderBusy(provider.id, false);
}
}
async function handleTest(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setExpandedProviderId(null);
setProviderBusy(provider.id, true);
try {
const result = await platformApiClient.testAiProvider(provider.id);
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
} catch (error) {
setAction({ providerId: provider.id, label: "test", success: false, message: errorMessage(error, "测试失败") });
} finally {
setProviderBusy(provider.id, false);
}
}
async function handleModels(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setExpandedProviderId(null);
setProviderBusy(provider.id, true);
try {
const result = await platformApiClient.listAiProviderModels(provider.id);
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length} 个模型` });
} catch (error) {
setAction({ providerId: provider.id, label: "models", success: false, message: errorMessage(error, "模型刷新失败") });
} finally {
setProviderBusy(provider.id, false);
}
}
function requestStatusChange(provider: AiProviderResponse) {
if (!canManage || busyProviderIds.has(provider.id)) {
return;
}
setExpandedProviderId(null);
setConfirmStatus(provider);
}
async function handleSavedFormTest() {
const providerId = form.id.trim() || selectedId;
if (formMode !== "edit" || !providerId) {
@@ -319,21 +378,21 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
AI
</h1>
</div>
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewStateLabel(viewState)}</span>
<span className={cx("page-status", viewState === "api" && canManage && "page-status-ready")}>{!canManage ? (viewState === "local-development" ? "本地开发 / 只读" : "只读模式") : viewStateLabel(viewState)}</span>
</header>
<dl className="page-summary-strip ai-provider-summary" aria-label="AI 提供商快速状态">
<div className="page-summary-chip summary-tone-neutral">
<dt></dt>
<dd>{metrics.total}</dd>
<dd>{listState === "ready" ? metrics.total : "--"}</dd>
</div>
<div className="page-summary-chip summary-tone-success">
<dt></dt>
<dd>{metrics.active}</dd>
<dd>{listState === "ready" ? metrics.active : "--"}</dd>
</div>
<div className="page-summary-chip summary-tone-warning">
<dt></dt>
<dd>{metrics.models}</dd>
<dd>{listState === "ready" ? metrics.models : "--"}</dd>
</div>
</dl>
@@ -343,15 +402,16 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
{filterLabel(item)}
</button>
))}
<button type="button" className="icon-command" title="新增提供商" onClick={startCreate}>
<button type="button" className="icon-command" disabled={!canManage} title={canManage ? "新增提供商" : "当前账号没有管理权限"} onClick={startCreate}>
<Candy size={16} />
<span></span>
</button>
</div>
{!canManage && <ResultBadge status="pending" label="当前账号为只读模式,保存、状态和测试动作需要平台管理员权限" />}
{action && <ResultBadge status={action.success ? "succeeded" : "failed"} label={action.message} />}
{listState === "loading" && <LoadingState label="正在加载 AI 提供商…" />}
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => window.location.reload()} />}
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => void refreshProviders()} />}
{listState === "ready" && providers.length === 0 && (
<EmptyState icon={<FlaskConical size={26} />} title="暂无 AI 提供商" description="平台还没有返回任何提供商。创建第一个平台托管的 AI 提供商后,这里会显示 API 数据。" actionLabel="新增提供商" onAction={startCreate} />
@@ -375,7 +435,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
{filteredProviders.map((provider) => (
<tr key={provider.id} className={cx(provider.id === selectedId && "provider-row-active")}>
<td>
<button type="button" className="table-link-button" onClick={() => selectProvider(provider)}>
<button type="button" className="table-link-button" disabled={!canManage} onClick={() => selectProvider(provider)}>
{provider.name}
</button>
<span className="provider-id">{provider.id}</span>
@@ -391,15 +451,15 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</td>
<td className="provider-actions-cell">
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
<button type="button" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<FlaskConical size={15} />
<span></span>
<span>{busyProviderIds.has(provider.id) ? "处理中" : "测试"}</span>
</button>
<button type="button" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<Sparkles size={15} />
<span></span>
<span>{busyProviderIds.has(provider.id) ? "处理中" : "模型"}</span>
</button>
<button type="button" aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
<WandSparkles size={15} />
<span></span>
</button>
@@ -408,6 +468,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
className={cx(expandedProviderId === provider.id && "row-action-button-active")}
aria-expanded={expandedProviderId === provider.id}
aria-label={`${provider.name} 更多操作`}
disabled={!canManage || busyProviderIds.has(provider.id)}
onClick={() => setExpandedProviderId((current) => (current === provider.id ? null : provider.id))}
>
<MoreHorizontal size={15} />
@@ -416,7 +477,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
{expandedProviderId === provider.id && (
<div className="inline-action-menu" role="menu" aria-label={`${provider.name} 更多操作`}>
<button type="button" role="menuitem" onClick={() => void handleStatus(provider)}>
<button type="button" role="menuitem" onClick={() => requestStatusChange(provider)}>
<Power size={14} />
<span>{provider.status === "active" ? "停用提供商" : "启用提供商"}</span>
</button>
@@ -426,7 +487,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
className="danger-command"
onClick={() => {
setExpandedProviderId(null);
setConfirmRetire(provider);
if (canManage) setConfirmRetire(provider);
}}
>
<UserRoundMinus size={14} />
@@ -443,7 +504,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
)}
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={closeForm}>
<AIConfigDiffReviewPanel />
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={() => { if (viewState !== "saving") closeForm(); }}>
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
<ProviderSetupGuide />
<div className="provider-preset-grid" aria-label="提供商预设">
@@ -496,7 +559,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
<label>
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} placeholder={form.baseUrlConfigured ? "已配置;留空保持不变" : formDefaults.baseUrl} />
<small className="field-help">{formDefaults.advancedNote}</small>
</label>
<label>
@@ -533,12 +596,12 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
{formCheck && <ResultBadge status={formCheck.status} label={formCheck.message} />}
<div className="confirm-actions">
<button type="button" onClick={closeForm}>
<button type="button" disabled={viewState === "saving"} onClick={closeForm}>
</button>
<button type="submit" className="confirm-primary">
<button type="submit" className="confirm-primary" disabled={!canManage || viewState === "saving"}>
<WandSparkles size={16} />
<span></span>
<span>{viewState === "saving" ? "保存中…" : "保存配置"}</span>
</button>
</div>
</form>
@@ -551,9 +614,19 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
confirmLabel="确认退役"
danger
busy={confirmBusy}
onCancel={() => setConfirmRetire(null)}
onCancel={() => { if (!confirmBusy) setConfirmRetire(null); }}
onConfirm={() => void (confirmRetire ? handleRetire(confirmRetire) : undefined)}
/>
<ConfirmDialog
open={confirmStatus !== null}
title="确认提供商状态变更"
description={`确认将 ${confirmStatus?.name ?? "该提供商"} ${confirmStatus?.status === "active" ? "停用" : "启用"}?平台会返回持久状态,失败时保持当前状态。`}
confirmLabel={confirmStatus?.status === "active" ? "确认停用" : "确认启用"}
danger={confirmStatus?.status === "active"}
busy={confirmStatus ? busyProviderIds.has(confirmStatus.id) : false}
onCancel={() => { if (!confirmStatus || !busyProviderIds.has(confirmStatus.id)) setConfirmStatus(null); }}
onConfirm={() => { if (confirmStatus) { void handleStatus(confirmStatus).then((succeeded) => { if (succeeded) setConfirmStatus(null); }); } }}
/>
</section>
);
}
+82 -1
View File
@@ -69,6 +69,75 @@ describe("first-party console pages", () => {
expect(html).not.toContain("sk-");
});
it("keeps unavailable overview modules visible instead of claiming an empty healthy state", () => {
const html = renderToStaticMarkup(
<HomePage
{...pageProps()}
initialState={{
core: {
status: "ready",
refreshedAt: "2026-07-18T10:00:00Z",
data: {
instances: [],
endpoints: [],
jobs: [
{
id: "job-failed-1",
serverInstanceId: "server-1",
runEndpointId: "endpoint-1",
capability: "server.lifecycle.start",
idempotencyKey: "idem-1",
state: "failed",
progress: { percent: 42 },
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 1, maxBackoffSeconds: 10 },
attempt: 3,
reconcileCount: 0,
createdAt: "2026-07-18T09:59:00Z",
updatedAt: "2026-07-18T10:00:00Z"
}
]
}
},
metrics: { status: "error", reason: "metrics unavailable", diagnosticId: "metrics-1" },
usage: { status: "error", reason: "usage unavailable", diagnosticId: "usage-1" },
providers: { status: "error", reason: "providers unavailable", diagnosticId: "providers-1" },
signals: { status: "error", reason: "audit unavailable", diagnosticId: "audit-1" }
}}
/>
);
expect(html).toContain("4 个模块不可用");
expect(html).toContain("资源指标不可用");
expect(html).toContain("审计信号不可用");
expect(html).toContain("AI 提供商信号不可用");
expect(html).toContain("server.lifecycle.start");
expect(html).not.toContain("暂无异常信号");
});
it("explains read-only overview access when the session cannot create servers", () => {
const readOnlySession: CurrentUserView = {
...adminUser,
roles: ["serverAdmin"],
capabilities: ["platform.overview.read", "servers.read"]
};
const html = renderToStaticMarkup(
<HomePage
{...pageProps()}
session={readOnlySession}
initialState={{
core: { status: "ready", data: { instances: [], endpoints: [], jobs: [] }, refreshedAt: "2026-07-18T10:00:00Z" },
metrics: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
usage: { status: "error", reason: "unavailable", diagnosticId: "usage" },
providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
signals: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }
}}
/>
);
expect(html).toContain("没有创建服务器的权限");
expect(html).toContain("前往服务器管理");
});
it("renders the server list workspace with search and status filters", () => {
const html = renderToStaticMarkup(<ServersPage {...pageProps()} />);
@@ -80,7 +149,19 @@ describe("first-party console pages", () => {
expect(html).not.toContain("/Users/");
});
it("keeps server metrics unavailable and runtime commands permission-gated", () => {
expect(serversPageSource).toContain("metricsUnavailable");
expect(serversPageSource).toContain("指标不可用");
expect(serversPageSource).toContain("canManageServers");
expect(serversPageSource).toContain("当前账号没有运行操作权限");
expect(serversPageSource).toContain("failedJobs");
expect(serversPageSource).toContain("refreshMetrics");
expect(serversPageSource).toContain("onClick={() => void refreshMetrics()}");
});
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
expect(serversPageSource).toContain("<ManagementDialog");
expect(serversPageSource).toContain('className="provider-form dialog-form"');
expect(serversPageSource).toContain('name="profileKey"');
expect(serversPageSource).toContain("runtimeBindingFields");
expect(serversPageSource).toContain("updateBinding(field.key");
@@ -136,7 +217,7 @@ describe("first-party console pages", () => {
expect(html).toContain("用户管理");
expect(html).toContain("Plugin Reviewer");
expect(html).toContain("needs approval");
expect(html).toContain("本地开发样例 / 禁止假成功");
expect(html).toContain("本地开发样例 / 仅查看");
});
it("renders maintenance triage entry points", () => {
+240 -144
View File
@@ -1,4 +1,17 @@
import { Activity, AlertTriangle, CakeSlice, Candy, Info, MoonStar, Sparkles } from "lucide-react";
import {
Activity,
AlertTriangle,
Bot,
CakeSlice,
Candy,
CircleGauge,
Info,
MoonStar,
RotateCw,
ServerCog,
Sparkles,
Workflow
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
@@ -12,26 +25,38 @@ import type {
ServerMetricsResponse
} from "../api/types";
import { UsageMeter } from "../components/OperationControls";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
import type { PageComponentProps } from "../contracts/page";
import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace";
import { serverIsOnline } from "../contracts/workspace";
import { cx } from "../utils/classes";
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
interface OverviewData {
instances: ServerInstanceResponse[];
endpoints: RunEndpointResponse[];
jobs: JobResponse[];
}
export function HomePage({ onNavigate }: PageComponentProps) {
const [core, setCore] = useState<ModuleState<OverviewData>>({ status: "loading" });
const [metrics, setMetrics] = useState<ModuleState<ServerMetricsResponse[]>>({ status: "loading" });
const [usage, setUsage] = useState<ModuleState<PlatformResourceUsageResponse>>({ status: "loading" });
const [providers, setProviders] = useState<ModuleState<AiProviderResponse[]>>({ status: "loading" });
const [signals, setSignals] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
export interface HomePageInitialState {
core?: OperationsModuleState<OverviewData>;
metrics?: OperationsModuleState<ServerMetricsResponse[]>;
usage?: OperationsModuleState<PlatformResourceUsageResponse>;
providers?: OperationsModuleState<AiProviderResponse[]>;
signals?: OperationsModuleState<AuditEventResponse[]>;
}
interface HomePageProps extends PageComponentProps {
initialState?: HomePageInitialState;
}
export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
const [core, setCore] = useState<OperationsModuleState<OverviewData>>(initialState?.core ?? { status: "loading" });
const [metrics, setMetrics] = useState<OperationsModuleState<ServerMetricsResponse[]>>(initialState?.metrics ?? { status: "loading" });
const [usage, setUsage] = useState<OperationsModuleState<PlatformResourceUsageResponse>>(initialState?.usage ?? { status: "loading" });
const [providers, setProviders] = useState<OperationsModuleState<AiProviderResponse[]>>(initialState?.providers ?? { status: "loading" });
const [signals, setSignals] = useState<OperationsModuleState<AuditEventResponse[]>>(initialState?.signals ?? { status: "loading" });
const refreshCore = useCallback(async () => {
setCore({ status: "loading" });
@@ -41,9 +66,9 @@ export function HomePage({ onNavigate }: PageComponentProps) {
platformApiClient.listRunEndpoints(),
platformApiClient.listJobs()
]);
setCore({ status: "ready", data: { instances: instances.items, endpoints: endpoints.items, jobs: jobs.items } });
setCore({ status: "ready", data: { instances: instances.items, endpoints: endpoints.items, jobs: jobs.items }, refreshedAt: refreshedNow() });
} catch (error) {
setCore({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setCore({ status: "error", reason: errorMessage(error, "服务器、节点或任务加载失败"), diagnosticId: "overview-core" });
}
}, []);
@@ -51,9 +76,9 @@ export function HomePage({ onNavigate }: PageComponentProps) {
setMetrics({ status: "loading" });
try {
const response = await platformApiClient.listServerMetrics();
setMetrics({ status: "ready", data: response.items });
} catch {
setMetrics({ status: "ready", data: [] });
setMetrics({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
} catch (error) {
setMetrics({ status: "error", reason: errorMessage(error, "服务器指标加载失败"), diagnosticId: "overview-server-metrics" });
}
}, []);
@@ -61,9 +86,9 @@ export function HomePage({ onNavigate }: PageComponentProps) {
setUsage({ status: "loading" });
try {
const response = await platformApiClient.getPlatformResourceUsage();
setUsage({ status: "ready", data: response });
} catch {
setUsage({ status: "error", reason: "平台资源指标接口尚未提供" });
setUsage({ status: "ready", data: response, refreshedAt: refreshedNow() });
} catch (error) {
setUsage({ status: "error", reason: errorMessage(error, "平台资源指标加载失败"), diagnosticId: "overview-platform-usage" });
}
}, []);
@@ -71,9 +96,9 @@ export function HomePage({ onNavigate }: PageComponentProps) {
setProviders({ status: "loading" });
try {
const response = await platformApiClient.listAiProviders();
setProviders({ status: "ready", data: response.items });
setProviders({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
} catch (error) {
setProviders({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setProviders({ status: "error", reason: errorMessage(error, "AI 提供商状态加载失败"), diagnosticId: "overview-ai-providers" });
}
}, []);
@@ -81,19 +106,26 @@ export function HomePage({ onNavigate }: PageComponentProps) {
setSignals({ status: "loading" });
try {
const response = await platformApiClient.listAuditEvents();
setSignals({ status: "ready", data: response.items });
} catch {
setSignals({ status: "ready", data: [] });
setSignals({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
} catch (error) {
setSignals({ status: "error", reason: errorMessage(error, "审计事件加载失败"), diagnosticId: "overview-audit-events" });
}
}, []);
useEffect(() => {
const refreshAll = useCallback(() => {
void refreshCore();
void refreshMetrics();
void refreshUsage();
void refreshProviders();
void refreshSignals();
}, [refreshCore, refreshMetrics, refreshUsage, refreshProviders, refreshSignals]);
}, [refreshCore, refreshMetrics, refreshProviders, refreshSignals, refreshUsage]);
useEffect(() => {
if (initialState) {
return;
}
refreshAll();
}, [initialState, refreshAll]);
const distribution = useMemo<GameTypeDistributionEntry[]>(() => {
if (core.status !== "ready") {
@@ -106,73 +138,19 @@ export function HomePage({ onNavigate }: PageComponentProps) {
return [...counts.entries()].map(([serverType, count]) => ({ serverType, label: serverType, count }));
}, [core]);
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => {
const collected: PlatformOverviewSignal[] = [];
if (core.status === "ready") {
for (const instance of core.data.instances.filter((item) => item.state === "failed")) {
collected.push({
id: `fault-${instance.id}`,
kind: "fault",
summary: `服务器 ${instance.name} 处于异常状态`,
detail: `实例 ${instance.id} 状态为 failed,需要排查`,
targetPage: "servers",
targetId: instance.id,
tone: "error",
at: instance.updatedAt
});
}
for (const job of core.data.jobs.filter((item) => item.state === "failed").slice(0, 5)) {
collected.push({
id: `job-${job.id}`,
kind: "job",
summary: `任务 ${job.capability} 失败`,
detail: `任务 ${job.id}${job.serverInstanceId ? `(服务器 ${job.serverInstanceId}` : ""} 执行失败`,
targetPage: "servers",
targetId: job.serverInstanceId,
tone: "error",
at: job.updatedAt
});
}
}
if (providers.status === "ready") {
for (const provider of providers.data.filter((item) => item.status === "error")) {
collected.push({
id: `ai-${provider.id}`,
kind: "aiProvider",
summary: `AI 提供商 ${provider.name} 连接异常`,
detail: `提供商 ${provider.id} 状态为 error,LLM 辅助可能不可用`,
targetPage: "aiProviders",
targetId: provider.id,
tone: "warning",
at: ""
});
}
}
if (signals.status === "ready") {
for (const event of signals.data.slice(0, 5)) {
collected.push({
id: `audit-${event.id}`,
kind: "log",
summary: event.summary || `${event.action} ${event.resourceKind}`,
detail: `${event.actorId}${event.resourceKind}/${event.resourceId} 执行 ${event.action}${event.result}`,
targetPage: event.resourceKind === "server-instance" ? "servers" : "maintenance",
targetId: event.resourceId,
tone: event.result === "failure" ? "warning" : "info",
at: event.createdAt
});
}
}
return collected.slice(0, 8);
}, [core, providers, signals]);
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers, signals), [core, providers, signals]);
const jobs = core.status === "ready" ? jobBuckets(core.data.jobs) : null;
const endpointSummary = core.status === "ready" ? summarizeEndpointOperations(core.data.endpoints) : null;
const onlineCount = core.status === "ready" ? core.data.instances.filter((item) => serverIsOnline(item.state)).length : 0;
const offlineCount = core.status === "ready" ? core.data.instances.length - onlineCount : 0;
const activeProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "active").length : 0;
const errorProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "error").length : 0;
const moduleFailureCount = [core, metrics, usage, providers, signals].filter((module) => module.status === "error").length;
const canManageServers = session.capabilities.includes("servers.manage");
const metricAverages = useMemo(() => {
if (usage.status === "ready") {
return { cpu: usage.data.cpuPercent, memory: usage.data.memoryPercent, disk: usage.data.diskPercent, source: "平台指标" };
return { cpu: usage.data.cpuPercent, memory: usage.data.memoryPercent, disk: usage.data.diskPercent, source: "平台指标", refreshedAt: usage.refreshedAt };
}
if (metrics.status === "ready" && metrics.data.length > 0) {
const average = (values: Array<number | undefined>) => {
@@ -183,38 +161,45 @@ export function HomePage({ onNavigate }: PageComponentProps) {
cpu: average(metrics.data.map((item) => item.cpuPercent)),
memory: average(metrics.data.map((item) => item.memoryPercent)),
disk: average(metrics.data.map((item) => item.diskPercent)),
source: "服务器均值"
source: "服务器指标均值",
refreshedAt: metrics.refreshedAt
};
}
return { cpu: undefined, memory: undefined, disk: undefined, source: "暂无数据" };
return { cpu: undefined, memory: undefined, disk: undefined, source: metrics.status === "error" || usage.status === "error" ? "指标不可用" : "等待指标", refreshedAt: undefined };
}, [usage, metrics]);
return (
<div className="console-page">
<div className="console-page operations-overview-page">
<header className="page-header">
<div>
<p className="page-kicker"></p>
<p className="page-kicker"></p>
<h1 className="page-title"></h1>
</div>
<span className={cx("page-status", core.status === "ready" && "page-status-ready")}>
{core.status === "ready" ? "数据已加载" : core.status === "loading" ? "加载中" : "部分模块加载失败"}
</span>
<div className="page-header-actions">
<span className={cx("page-status", moduleFailureCount === 0 && core.status === "ready" && "page-status-ready")}>
{moduleFailureCount > 0 ? `${moduleFailureCount} 个模块不可用` : core.status === "ready" ? "运营数据已同步" : "正在同步"}
</span>
<button type="button" className="icon-command" onClick={refreshAll}>
<RotateCw size={14} />
</button>
</div>
</header>
{core.status === "loading" && <LoadingState label="正在加载服务器与任务概况…" />}
{core.status === "error" && <ErrorState title="平台概况加载失败" reason={core.reason} diagnosticId="overview-core" onRetry={() => void refreshCore()} />}
{core.status === "loading" && <LoadingState label="正在加载服务器、节点与任务概况…" />}
{core.status === "error" && <ErrorState title="平台核心概况不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} />}
{core.status === "ready" && core.data.instances.length === 0 && (
<EmptyState
icon={<CakeSlice size={26} />}
title="还没有服务器实例"
description="平台尚未创建任何服务器。前往服务器管理创建第一个实例,或检查运行节点是否在线。"
description={canManageServers ? "平台尚未创建服务器。先确认运行节点在线,再创建第一个实例。" : "当前账号可查看概览,但没有创建服务器的权限。"}
actionLabel="前往服务器管理"
onAction={() => onNavigate("servers")}
/>
)}
{core.status === "ready" && core.data.instances.length > 0 && (
<section className="console-grid" aria-label="platform health">
{core.status === "ready" && (
<section className="console-grid operations-summary-grid" aria-label="平台运营摘要">
<article className="overview-card metric-tone-success">
<span className="metric-label">线</span>
<strong className="metric-value">{onlineCount}</strong>
@@ -225,53 +210,128 @@ export function HomePage({ onNavigate }: PageComponentProps) {
<strong className="metric-value">{offlineCount}</strong>
<p>{core.data.instances.filter((item) => item.state === "failed").length} </p>
</article>
<article className="overview-card metric-tone-neutral">
<article className={cx("overview-card", endpointSummary && endpointSummary.degraded + endpointSummary.offline > 0 ? "metric-tone-warning" : "metric-tone-neutral")}>
<span className="metric-label"></span>
<strong className="metric-value">{core.data.endpoints.filter((item) => item.status === "online").length}</strong>
<p> {core.data.endpoints.length} </p>
<strong className="metric-value">{endpointSummary?.online ?? 0} 线</strong>
<p>{endpointSummary ? `${endpointSummary.degraded} 降级,${endpointSummary.offline} 离线` : "节点状态不可用"}</p>
</article>
<article className={cx("overview-card", errorProviders > 0 ? "metric-tone-warning" : "metric-tone-success")}>
<span className="metric-label">LLM </span>
<strong className="metric-value">
{providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "…" : "未知"}
</strong>
<p>{errorProviders > 0 ? `${errorProviders} 个提供商异常` : "提供商状态正常"}</p>
<article className={cx("overview-card", providers.status === "error" || errorProviders > 0 ? "metric-tone-warning" : "metric-tone-success")}>
<span className="metric-label">AI </span>
<strong className="metric-value">{providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "加载中" : "不可用"}</strong>
<p>{providers.status === "error" ? "提供商状态接口失败" : errorProviders > 0 ? `${errorProviders} 个连接异常` : "连接状态正常"}</p>
</article>
</section>
)}
<section className="operations-command-grid" aria-label="平台运营模块">
<article className="console-panel operations-module" aria-label="任务脉冲">
<div className="panel-header">
<h2><Workflow size={16} /> </h2>
<span className="page-status">{moduleFreshnessLabel(core.status === "ready" ? core.refreshedAt : core.refreshedAt)}</span>
</div>
{core.status === "loading" && <LoadingState label="正在汇总任务…" compact />}
{core.status === "error" && <ErrorState title="任务状态不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />}
{core.status === "ready" && jobs && (
<>
<dl className="operations-pulse-strip">
<div><dt></dt><dd>{jobs.active.length}</dd></div>
<div><dt></dt><dd>{jobs.retrying.length}</dd></div>
<div><dt></dt><dd>{jobs.failed.length}</dd></div>
</dl>
{jobs.active.length === 0 && jobs.failed.length === 0 ? (
<p className="operations-module-empty"></p>
) : (
<div className="operations-job-list">
{[...jobs.active.slice(0, 3), ...jobs.failed.slice(0, 2)].map((job) => (
<button
key={job.id}
type="button"
className={cx("operations-job-row", job.state === "failed" && "operations-job-row-failed")}
onClick={() => onNavigate(job.serverInstanceId ? "serverDetail" : "maintenance", job.serverInstanceId ? { serverId: job.serverInstanceId } : undefined)}
>
<span><strong>{job.capability}</strong><small>{job.serverInstanceId ? `服务器 ${job.serverInstanceId}` : "平台任务"}</small></span>
<span>{job.state === "retrying" ? `重试 ${job.attempt}/${job.retryPolicy.maxAttempts}` : jobStateLabel(job.state)}</span>
<span>{Math.round(job.progress.percent)}%</span>
</button>
))}
</div>
)}
</>
)}
</article>
<article className="console-panel operations-module" aria-label="运行节点状态">
<div className="panel-header">
<h2><ServerCog size={16} /> </h2>
<button type="button" className="icon-command" onClick={() => onNavigate("maintenance")}><CircleGauge size={14} /> </button>
</div>
{core.status === "loading" && <LoadingState label="正在汇总节点…" compact />}
{core.status === "error" && <ErrorState title="节点状态不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />}
{core.status === "ready" && endpointSummary && (
<>
<dl className="operations-pulse-strip">
<div><dt>线</dt><dd>{endpointSummary.online}</dd></div>
<div><dt></dt><dd>{endpointSummary.activeJobs}</dd></div>
<div><dt></dt><dd>{endpointSummary.queuedJobs}</dd></div>
</dl>
{core.data.endpoints.length === 0 ? (
<p className="operations-module-empty">Platform </p>
) : (
<div className="operations-endpoint-list">
{core.data.endpoints.slice(0, 4).map((endpoint) => (
<div key={endpoint.id} className="operations-endpoint-row">
<span><strong>{endpoint.displayName}</strong><small>{endpoint.version}</small></span>
<span className={cx("status-pill", `status-${endpoint.status}`)}>{endpointStatusLabel(endpoint.status)}</span>
<span>{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} </span>
</div>
))}
</div>
)}
</>
)}
</article>
</section>
<ProductionGovernancePanel compact title="生产容量与告警" />
<section className="overview-two-col">
<article className="console-panel" aria-label="resource usage">
<div className="panel-header">
<h2>
<Activity size={16} style={{ verticalAlign: "-2px" }} />
</h2>
<h2><Activity size={16} /> </h2>
<span className="page-status">{metricAverages.source}</span>
</div>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metricAverages.cpu} />
<UsageMeter label="内存" percent={metricAverages.memory} />
<UsageMeter label="磁盘" percent={metricAverages.disk} />
</div>
{usage.status === "loading" && metrics.status === "loading" ? (
<LoadingState label="正在读取资源指标…" compact />
) : usage.status === "error" && metrics.status === "error" ? (
<ErrorState title="资源指标不可用" reason={`${usage.reason}${metrics.reason}`} diagnosticId="overview-resource-usage" onRetry={() => { void refreshUsage(); void refreshMetrics(); }} compact />
) : (
<>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metricAverages.cpu} />
<UsageMeter label="内存" percent={metricAverages.memory} />
<UsageMeter label="磁盘" percent={metricAverages.disk} />
</div>
<span className="operations-module-freshness">{moduleFreshnessLabel(metricAverages.refreshedAt)}</span>
{(usage.status === "error" || metrics.status === "error") && (
<button type="button" className="operations-inline-warning" onClick={() => { void refreshUsage(); void refreshMetrics(); }}>
<AlertTriangle size={14} />
</button>
)}
</>
)}
</article>
<article className="console-panel" aria-label="game type distribution">
<div className="panel-header">
<h2>
<Candy size={16} style={{ verticalAlign: "-2px" }} />
</h2>
</div>
<div className="panel-header"><h2><Candy size={16} /> </h2></div>
{core.status === "loading" ? (
<LoadingState label="统计中…" compact />
) : core.status === "error" ? (
<ErrorState title="分布数据不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />
) : distribution.length === 0 ? (
<EmptyState title="暂无分布数据" description="创建服务器后这里会显示各游戏类型的实例数量。" />
) : (
<div className="action-list">
{distribution.map((entry) => (
<span key={entry.serverType}>
<strong>{entry.label}</strong>{entry.count}
</span>
))}
{distribution.map((entry) => <span key={entry.serverType}><strong>{entry.label}</strong>{entry.count} </span>)}
</div>
)}
</article>
@@ -279,17 +339,16 @@ export function HomePage({ onNavigate }: PageComponentProps) {
<section className="console-panel" aria-label="recent signals">
<div className="panel-header">
<h2>
<MoonStar size={16} style={{ verticalAlign: "-2px" }} />
</h2>
<button type="button" className="icon-command" onClick={() => void refreshSignals()}>
<Sparkles size={14} />
</button>
<h2><MoonStar size={16} /> </h2>
<div className="panel-header-actions">
<span className="page-status">{moduleFreshnessLabel(signals.status === "ready" ? signals.refreshedAt : signals.refreshedAt)}</span>
<button type="button" className="icon-command" onClick={() => void refreshSignals()}><Sparkles size={14} /> </button>
</div>
</div>
{signals.status === "loading" && core.status === "loading" ? (
<LoadingState label="正在收集信号…" compact />
) : overviewSignals.length === 0 ? (
{signals.status === "loading" && core.status === "loading" && <LoadingState label="正在收集信号…" compact />}
{signals.status === "error" && <ErrorState title="审计信号不可用" reason={signals.reason} diagnosticId={signals.diagnosticId} onRetry={() => void refreshSignals()} compact />}
{providers.status === "error" && <ErrorState title="AI 提供商信号不可用" reason={providers.reason} diagnosticId={providers.diagnosticId} onRetry={() => void refreshProviders()} compact />}
{overviewSignals.length === 0 && signals.status === "ready" && core.status === "ready" && providers.status === "ready" ? (
<EmptyState title="暂无异常信号" description="最近没有故障、失败任务或需要关注的审计事件。" />
) : (
<div className="signal-list">
@@ -298,18 +357,10 @@ export function HomePage({ onNavigate }: PageComponentProps) {
key={signal.id}
type="button"
className={cx("signal-item", `signal-tone-${signal.tone}`)}
onClick={() =>
onNavigate(
signal.targetPage === "servers" && signal.targetId ? "serverDetail" : signal.targetPage,
signal.targetPage === "servers" && signal.targetId ? { serverId: signal.targetId } : undefined
)
}
onClick={() => onNavigate(signal.targetPage === "servers" && signal.targetId ? "serverDetail" : signal.targetPage, signal.targetPage === "servers" && signal.targetId ? { serverId: signal.targetId } : undefined)}
>
{signal.tone === "error" ? <AlertTriangle size={18} /> : signal.tone === "warning" ? <AlertTriangle size={18} /> : <Info size={18} />}
<span>
<strong>{signal.summary}</strong>
<p>{signal.detail}</p>
</span>
{signal.tone === "error" || signal.tone === "warning" ? <AlertTriangle size={18} /> : signal.kind === "aiProvider" ? <Bot size={18} /> : <Info size={18} />}
<span><strong>{signal.summary}</strong><p>{signal.detail}</p></span>
<span className="provider-id">{signal.at ? new Date(signal.at).toLocaleString() : ""}</span>
</button>
))}
@@ -319,3 +370,48 @@ export function HomePage({ onNavigate }: PageComponentProps) {
</div>
);
}
function buildOverviewSignals(
core: OperationsModuleState<OverviewData>,
providers: OperationsModuleState<AiProviderResponse[]>,
signals: OperationsModuleState<AuditEventResponse[]>
): PlatformOverviewSignal[] {
const collected: PlatformOverviewSignal[] = [];
if (core.status === "ready") {
for (const instance of core.data.instances.filter((item) => item.state === "failed")) {
collected.push({ id: `fault-${instance.id}`, kind: "fault", summary: `服务器 ${instance.name} 处于异常状态`, detail: `实例 ${instance.id} 需要排查`, targetPage: "servers", targetId: instance.id, tone: "error", at: instance.updatedAt });
}
for (const job of jobBuckets(core.data.jobs).failed.slice(0, 5)) {
collected.push({ id: `job-${job.id}`, kind: "job", summary: `任务 ${job.capability} 失败`, detail: job.serverInstanceId ? `服务器 ${job.serverInstanceId} 的任务执行失败` : "平台任务执行失败", targetPage: job.serverInstanceId ? "servers" : "maintenance", targetId: job.serverInstanceId, tone: "error", at: job.updatedAt });
}
}
if (providers.status === "ready") {
for (const provider of providers.data.filter((item) => item.status === "error")) {
collected.push({ id: `ai-${provider.id}`, kind: "aiProvider", summary: `AI 提供商 ${provider.name} 连接异常`, detail: "LLM 辅助暂不可用,请在提供商管理中检查已保存配置。", targetPage: "aiProviders", targetId: provider.id, tone: "warning", at: "" });
}
}
if (signals.status === "ready") {
for (const event of signals.data.slice(0, 5)) {
collected.push({ id: `audit-${event.id}`, kind: "log", summary: event.summary || `${event.action} ${event.resourceKind}`, detail: `${event.resourceKind}/${event.resourceId}${event.result}`, targetPage: event.resourceKind === "server-instance" ? "servers" : "maintenance", targetId: event.resourceId, tone: event.result === "failure" ? "warning" : "info", at: event.createdAt });
}
}
return collected.sort((left, right) => Date.parse(right.at || "") - Date.parse(left.at || "")).slice(0, 8);
}
function refreshedNow(): string {
return new Date().toISOString();
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function jobStateLabel(state: JobResponse["state"]): string {
const labels: Record<JobResponse["state"], string> = { queued: "排队", accepted: "已领取", running: "执行中", retrying: "等待重试", succeeded: "完成", failed: "失败", cancelled: "已取消" };
return labels[state];
}
function endpointStatusLabel(status: RunEndpointResponse["status"]): string {
const labels: Record<RunEndpointResponse["status"], string> = { online: "在线", offline: "离线", degraded: "降级", disabled: "停用" };
return labels[status];
}
+3
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import type { PageComponentProps } from "../contracts/page";
import { cx } from "../utils/classes";
@@ -145,6 +146,8 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
<ProductionGovernancePanel title="容量治理与告警闭环" />
<section className="console-panel" aria-label="run endpoints">
<div className="panel-header">
<h2></h2>
@@ -0,0 +1,91 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
import { PluginPageHostPage } from "./PluginPageHostPage";
const operations: OperationTracker = {
operations: [],
begin: () => "operation-test",
update: () => undefined,
succeed: () => undefined,
fail: () => undefined,
isPending: () => false
};
const plugin: GamePluginResponse = {
id: "game.scum",
name: "SCUM Server",
version: "1.0.0",
serverType: "scum",
serverDisplayName: "SCUM Server",
manifestRef: "artifact://manifests/game.scum/1.0.0",
createFormSchemaRef: "schemas/create-form.schema.json",
requiredRunCapabilities: [],
declaredPermissions: ["server.read", "server.logs.read", "server.remote.access", "server.game-client.read", "server.game-client.command"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: true },
lifecycleActions: {},
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
pages: [{
key: "operations",
title: "SCUM 运维",
path: "/operations",
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"]
}],
tags: ["scum"],
aiPurposes: [],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] },
gameClientBridge: {
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
queryTemplates: [{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
},
status: "installed"
};
function props(serverId = "server-1"): PageComponentProps {
const session = {
id: "operator-1",
displayName: "Operator",
status: "active" as const,
roles: ["platformAdmin" as const],
capabilities: capabilitiesForRoles(["platformAdmin"]),
profile: {},
source: "local" as const
};
return {
session,
params: { pluginId: "game.scum", routeKey: "operations", serverId },
operations,
onNavigate: () => undefined,
onLogout: async () => undefined,
onProfileSave: async () => session,
onThemePreferenceSave: async () => ({ userId: session.id, paletteId: "mecha-black", backgroundPresetId: "mecha-grid", persistence: "api", updatedAt: "2026-07-20T00:00:00Z" })
};
}
describe("PluginPageHostPage", () => {
it("renders SCUM operations from manifest-owned declarations", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
expect(html).toContain("SCUM 运维");
expect(html).toContain("平台托管上下文");
expect(html).toContain("命令目录");
expect(html).toContain("快照目录");
expect(html).toContain("查询模板");
expect(html).toContain("返回服务器");
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
});
it("shows a declared availability reason when server context is missing", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
expect(html).toContain("SCUM 运维声明不可用");
expect(html).toContain("缺少服务器实例上下文");
});
});
+111
View File
@@ -0,0 +1,111 @@
import { ArrowLeft, PlugZap } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame";
import { ScumOperationsPanel } from "../components/ScumOperationsPanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
type PluginPageState =
| { status: "loading" }
| { status: "error"; reason: string }
| { status: "ready"; plugin: GamePluginResponse };
interface PluginPageHostPageProps extends PageComponentProps {
initialPlugin?: GamePluginResponse;
}
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
const pluginId = params.pluginId ?? "";
const routeKey = params.routeKey ?? "";
const serverId = params.serverId ?? "";
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
const load = useCallback(async () => {
if (!pluginId || !routeKey) {
setState({ status: "error", reason: "插件页面路由缺少 pluginId 或 routeKey。" });
return;
}
setState({ status: "loading" });
try {
const response = await platformApiClient.listGamePlugins();
const plugin = response.items.find((candidate) => candidate.id === pluginId);
setState(plugin ? { status: "ready", plugin } : { status: "error", reason: "未找到已注册的插件声明。" });
} catch (error) {
setState({ status: "error", reason: error instanceof Error ? error.message : "插件页面声明加载失败。" });
}
}, [pluginId, routeKey]);
useEffect(() => {
if (!initialPlugin) {
void load();
}
}, [initialPlugin, load]);
if (state.status === "loading") {
return <LoadingState label="正在加载插件页面声明…" />;
}
if (state.status === "error") {
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
}
const page = state.plugin.pages.find((candidate) => candidate.key === routeKey);
if (!page) {
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
}
const manifestContract = pluginBridgeManifestContractFromResponse(state.plugin);
const hostContext = createPluginBridgeHostContext({
plugin: manifestContract,
routeKey,
serverInstanceId: serverId || undefined,
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
});
const isScumOperations = state.plugin.id === scumOperationsPluginId && routeKey === scumOperationsRouteKey;
const scumResolution = isScumOperations ? resolveScumOperationsPageContract(state.plugin, serverId) : null;
return (
<div className="console-page">
<PageFrame
kicker={`${state.plugin.name} / PLUGIN PAGE`}
title={page.title}
status={serverId ? `服务器 ${serverId}` : "未绑定服务器"}
metrics={[
{ label: "有效权限", value: String(hostContext.permissions.length), tone: hostContext.permissions.length > 0 ? "success" : "warning" },
{ label: "桥接动作", value: String(hostContext.bridgeActions.length), tone: hostContext.bridgeActions.length > 0 ? "success" : "warning" },
{ label: "页面路由", value: routeKey, tone: "neutral" }
]}
/>
<section className="console-panel" aria-label="plugin page host context">
<div className="panel-header">
<h2><PlugZap size={16} aria-hidden="true" /> </h2>
<button type="button" className="icon-command" onClick={() => onNavigate(serverId ? "serverDetail" : "plugins", serverId ? { serverId } : {})}>
<ArrowLeft size={14} aria-hidden="true" />
<span>{serverId ? "返回服务器" : "返回插件市场"}</span>
</button>
</div>
{scumResolution && !scumResolution.available && <ErrorState title="SCUM 运维声明不可用" reason={scumResolution.reason} compact />}
{scumResolution?.available && (
<div className="action-list" aria-label="SCUM operations declarations">
<span><strong></strong> {scumResolution.contract.commands.length} operations </span>
<span><strong></strong> {scumResolution.contract.snapshots.length} schemaVersion </span>
<span><strong></strong> {scumResolution.contract.queryTemplates.length} SQLite </span>
</div>
)}
{!scumResolution && (
<EmptyState
title="插件页面已接入 Host Bridge"
description="当前页面只接收 manifest 声明与平台安全上下文;具体操作由对应插件页面实现。"
/>
)}
</section>
{scumResolution?.available && <ScumOperationsPanel contract={scumResolution.contract} />}
</div>
);
}
+13
View File
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PluginsPage } from "./PluginsPage";
import pluginsPageSource from "./PluginsPage.tsx?raw";
import type { MarketplacePluginResponse } from "../api/types";
const marketplacePlugin: MarketplacePluginResponse = {
@@ -22,6 +23,7 @@ const marketplacePlugin: MarketplacePluginResponse = {
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
status: "installed",
source: "platform-registry"
};
@@ -82,4 +84,15 @@ describe("PluginsPage", () => {
expect(html).not.toContain('role="dialog"');
expect(html).not.toContain("manifest validated");
});
it("keeps state actions confirmed, permission-gated, and detail retryable", () => {
expect(pluginsPageSource).toContain("ConfirmDialog");
expect(pluginsPageSource).toContain("retryDetail");
expect(pluginsPageSource).toContain("当前账号为只读模式");
expect(pluginsPageSource).toContain("平台会返回持久状态");
expect(pluginsPageSource).toContain("actionPending !== null");
expect(pluginsPageSource).toContain("operations?.begin");
expect(pluginsPageSource).toContain("operations?.succeed");
expect(pluginsPageSource).toContain("operations?.fail");
});
});
+60 -10
View File
@@ -3,10 +3,12 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from "rea
import { platformApiClient } from "../api/client";
import type { GamePluginStatus, MarketplacePluginFilterRequest, MarketplacePluginResponse, MarketplacePluginStateAction } from "../api/types";
import { ManagementDialog } from "../components/OperationControls";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { isPlatformAdmin } from "../contracts/workspace";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
@@ -34,7 +36,7 @@ const statusFilters: Array<{ id: StatusFilter; label: string }> = [
{ id: "updating", label: "更新中" }
];
export function PluginsPage({ initialState }: PluginsPageProps = {}) {
export function PluginsPage({ initialState, session, operations }: PluginsPageProps = {}) {
const [listState, setListState] = useState<ListState>(initialState?.listState ?? "loading");
const [listError, setListError] = useState(initialState?.listError ?? "");
const [plugins, setPlugins] = useState<MarketplacePluginResponse[]>(initialState?.plugins ?? []);
@@ -47,6 +49,7 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
const [serverType, setServerType] = useState("");
const [capability, setCapability] = useState("");
const [actionPending, setActionPending] = useState<MarketplacePluginStateAction | null>(null);
const [confirmAction, setConfirmAction] = useState<MarketplacePluginStateAction | null>(null);
const [actionResult, setActionResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(initialState?.actionResult ?? null);
const [usingFallback, setUsingFallback] = useState(initialState?.usingFallback ?? false);
@@ -110,6 +113,22 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
.finally(() => setDetailPending(false));
}, [plugins, selectedId, usingFallback]);
const retryDetail = useCallback(async () => {
if (!selectedId || usingFallback) {
return;
}
setDetailPending(true);
setDetailError("");
try {
const plugin = await platformApiClient.getMarketplacePlugin(selectedId);
setDetail(plugin);
} catch (error) {
setDetailError(error instanceof Error ? error.message : "插件详情加载失败");
} finally {
setDetailPending(false);
}
}, [selectedId, usingFallback]);
const serverTypes = useMemo(() => unique(plugins.map((plugin) => plugin.serverType)), [plugins]);
const capabilities = useMemo(() => unique(plugins.flatMap((plugin) => [...plugin.capabilities, ...plugin.bridgeActions])), [plugins]);
const installedCount = plugins.filter((plugin) => plugin.status === "installed").length;
@@ -129,6 +148,8 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
if (!detail || usingFallback) {
return;
}
const intent = `${stateActionLabel(action)}插件`;
const operationId = operations?.begin({ intent, targetKind: "plugin", targetId: detail.id, requester: session?.displayName });
setActionPending(action);
setActionResult(null);
try {
@@ -136,23 +157,37 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
setDetail(updated);
setPlugins((current) => current.map((plugin) => (plugin.id === updated.id ? updated : plugin)));
setActionResult({ status: "succeeded", label: `${updated.name}${stateActionLabel(action)}` });
if (operationId) operations?.succeed(operationId, `${updated.name}${stateActionLabel(action)}`);
setConfirmAction(null);
} catch (error) {
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "状态更新失败" });
const reason = error instanceof Error ? error.message : "状态更新失败";
setActionResult({ status: "failed", label: reason });
if (operationId) operations?.fail(operationId, reason, operationId);
} finally {
setActionPending(null);
}
}
function requestStateChange(action: MarketplacePluginStateAction) {
if (!canManage || usingFallback || actionPending !== null) {
return;
}
setActionResult(null);
setConfirmAction(action);
}
const canManage = Boolean(session && isPlatformAdmin(session));
return (
<div className="console-page">
<PageFrame
kicker="扩展"
title="插件市场"
status={usingFallback ? "本地演示数据" : "平台 API"}
status={usingFallback ? "本地演示数据" : listState === "loading" ? "正在连接平台 API" : listState === "error" ? "平台 API 不可用" : "平台 API"}
metrics={[
{ label: "已安装", value: `${installedCount}`, tone: "success" },
{ label: "桥接动作", value: `${bridgeActionCount}`, tone: "success" },
{ label: "校验失败", value: `${invalidCount}`, tone: invalidCount > 0 ? "warning" : "success" }
{ label: "已安装", value: listState === "ready" ? `${installedCount}` : "--", tone: "success" },
{ label: "桥接动作", value: listState === "ready" ? `${bridgeActionCount}` : "--", tone: "success" },
{ label: "校验失败", value: listState === "ready" ? `${invalidCount}` : "--", tone: invalidCount > 0 ? "warning" : "success" }
]}
/>
@@ -196,6 +231,7 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
</div>
{usingFallback && <ResultBadge status="pending" label="本地演示数据仅用于前端开发,连接平台 API 后会自动替换" />}
{!canManage && !usingFallback && <ResultBadge status="pending" label="当前账号为只读模式,插件状态动作需要平台管理员权限" />}
{actionResult && <ResultBadge status={actionResult.status} label={actionResult.label} />}
{listState === "loading" && <LoadingState label="正在加载插件市场…" />}
@@ -248,13 +284,23 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
</section>
)}
<ManagementDialog open={selectedId !== ""} title={detail?.name ?? "插件详情"} wide onClose={() => { setSelectedId(""); setDetail(null); setDetailError(""); }}>
<ManagementDialog open={selectedId !== ""} title={detail?.name ?? "插件详情"} wide onClose={() => { if (actionPending === null) { setSelectedId(""); setDetail(null); setDetailError(""); setConfirmAction(null); } }}>
<div className="plugin-detail-panel" aria-label="plugin marketplace detail">
{detailPending && <LoadingState label="正在加载插件详情…" compact />}
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} compact />}
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback} onAction={(action) => void changeState(action)} />}
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} onRetry={() => void retryDetail()} compact />}
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback || !canManage} onAction={requestStateChange} />}
</div>
</ManagementDialog>
<ConfirmDialog
open={confirmAction !== null}
title="确认插件状态变更"
description={detail ? `将对 ${detail.name} 执行“${stateActionLabel(confirmAction ?? "disable")}”。平台会返回持久状态,失败时保留当前状态并允许重试。` : "请确认插件状态变更。"}
confirmLabel={stateActionLabel(confirmAction ?? "disable")}
danger={confirmAction === "disable"}
busy={actionPending !== null}
onCancel={() => { if (actionPending === null) setConfirmAction(null); }}
onConfirm={() => { if (confirmAction) void changeState(confirmAction); }}
/>
</div>
);
}
@@ -282,6 +328,7 @@ function PluginDetail({ plugin, actionPending, actionsDisabled, onAction }: Plug
<DetailStat label="权限" value={`${plugin.declaredPermissions.length}`} />
<DetailStat label="AI 用途" value={plugin.aiPurposes.length ? plugin.aiPurposes.join(", ") : "--"} />
</div>
<PluginLifecycleWorkbench pluginId={plugin.id} pluginName={plugin.name} operations={plugin.productionLifecycle?.operations} disabled={actionsDisabled} />
<dl className="detail-list">
<div>
<dt></dt>
@@ -345,5 +392,8 @@ function statusLabel(status: string): string {
}
function stateActionLabel(action: MarketplacePluginStateAction): string {
if (action === "install") {
return "安装";
}
return action === "disable" ? "停用" : "启用";
}
+3
View File
@@ -24,3 +24,6 @@ Pages must use the shared black-mecha / magical-girl visual system from `../them
- Do not introduce opaque white cards, heavy dark dashboards, stock marketing layouts, or single-page custom gradients that bypass the theme tokens.
- Preserve text/icons for status and operation results; do not rely on color-only cues.
- Read `../theme/README.md` before adding a new page surface pattern.
# Server Detail lifecycle behavior
The Server Detail Client Manager section is a real operations surface: it consumes safe Platform projections, polls only while a lifecycle job is active, shows current job attempts/progress, and provides recovery actions for retryable failure, stale key generation, failed update rollback, and offline health. Action buttons remain compact and disabled with the Platform-provided reason when declaration, permission, endpoint, artifact, target, or lifecycle state is not ready.
+20 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { configDiffViewFromPreview } from "./ServerDetailPage";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import clientManagerLifecyclePanelSource from "../components/ClientManagerLifecyclePanel.tsx?raw";
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types";
@@ -81,7 +82,18 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).toContain("installDependencies");
expect(serverDetailPageSource).toContain("listServerLiveLogs");
expect(serverDetailPageSource).toContain("requestLogBackfill");
expect(serverDetailPageSource).toContain("safeRuntimeRef");
expect(serverDetailPageSource).toContain("ClientManagerLifecyclePanel");
expect(clientManagerLifecyclePanelSource).toContain("listClientManagerLifecycles");
expect(clientManagerLifecyclePanelSource).toContain("deployClientManager");
expect(clientManagerLifecyclePanelSource).toContain("controlClientManager");
expect(clientManagerLifecyclePanelSource).toContain("updateClientManager");
expect(clientManagerLifecyclePanelSource).toContain("retryClientManagerLifecycle");
expect(clientManagerLifecyclePanelSource).toContain("revokeClientManagerSession");
expect(clientManagerLifecyclePanelSource).toContain("uninstallClientManager");
expect(clientManagerLifecyclePanelSource).toContain("expectedDeploymentGeneration");
expect(clientManagerLifecyclePanelSource).not.toContain("secretRef");
expect(clientManagerLifecyclePanelSource).not.toContain("hostPath");
expect(clientManagerLifecyclePanelSource).not.toContain("process.pid");
expect(serverDetailPageSource).not.toContain("authKey");
expect(serverDetailPageSource).not.toContain("password=");
expect(serverDetailPageSource).not.toContain("unix://");
@@ -90,6 +102,13 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("sqlite://");
});
it("loads the dependency catalog only after runtime actions expose dependency operations", () => {
const runtimeDistributionSectionSource = serverDetailPageSource.split("function RuntimeDistributionSection")[1]?.split("function RuntimeBindingFields")[0] ?? "";
expect(runtimeDistributionSectionSource).toContain('action.key === "dependencies-check" || action.key === "dependencies-install"');
expect(runtimeDistributionSectionSource).toContain("dependencyActions.some((action) => action.available)");
expect(runtimeDistributionSectionSource).toContain("getDependencyCatalog(instance.id)");
});
it("reviews and updates only redacted runtime binding metadata", () => {
const runtimeBindingSectionSource = serverDetailPageSource.split("function RuntimeBindingSection")[1]?.split("function RuntimeDistributionSection")[0] ?? "";
expect(serverDetailPageSource).toContain("getServerRuntimeBinding");
+81 -103
View File
@@ -26,6 +26,9 @@ import type {
RemoteAdapterDeclarationResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
import {
RuntimeTaskProgressDialog,
runtimeBuildStages,
@@ -61,7 +64,7 @@ import {
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
@@ -300,6 +303,9 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()}
/>
)}
{section === "overview" && (
<ClientManagerLifecyclePanel serverId={instance.data.id} serverName={instance.data.name} session={session} operations={operations} />
)}
{section === "overview" && (
<ServerMetadataSection
instance={instance.data}
@@ -312,7 +318,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} />}
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} onNavigate={onNavigate} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
</>
@@ -778,11 +784,25 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const refreshRuntimeProjections = useCallback(async () => {
const dependencyActions = runtimeActions.status === "ready"
? runtimeActions.data.actions.filter((action) => action.key === "dependencies-check" || action.key === "dependencies-install")
: [];
const dependencyActionReason = dependencyActions.find((action) => action.reason)?.reason ?? "依赖操作未开放";
const catalogRequest: Promise<LoadState<DependencyCatalogResponse>> =
runtimeActions.status === "ready" && dependencyActions.some((action) => action.available)
? platformApiClient
.getDependencyCatalog(instance.id)
.then((data): LoadState<DependencyCatalogResponse> => ({ status: "ready", data }))
.catch((error): LoadState<DependencyCatalogResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" }))
: Promise.resolve(
runtimeActions.status === "error"
? { status: "error", reason: runtimeActions.reason }
: runtimeActions.status === "ready"
? { status: "error", reason: dependencyActionReason }
: { status: "loading" }
);
const [catalog, updates] = await Promise.all([
platformApiClient
.getDependencyCatalog(instance.id)
.then((data): LoadState<DependencyCatalogResponse> => ({ status: "ready", data }))
.catch((error): LoadState<DependencyCatalogResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" })),
catalogRequest,
platformApiClient
.listRunUpdates(instance.id)
.then((data): LoadState<RunUpdateJobResponse[]> => ({ status: "ready", data: data.items }))
@@ -790,7 +810,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
]);
setDependencyCatalog(catalog);
setRunUpdates(updates);
}, [instance.id]);
}, [instance.id, runtimeActions]);
useEffect(() => {
void refreshRuntimeProjections();
@@ -1134,7 +1154,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
setLastClient(distribution);
return distribution;
},
(distribution) => `客户端管理器二进制已构建,artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`,
(distribution) => `客户端管理器二进制已构建,artifact ${distribution.artifactId}组件密钥仅由 Platform/Run 受控使用`,
{
description: `${profileKey} profile 拉取客户端代码、安装环境、编译并生成可下载 artifact。`,
stages: runtimeBuildStages,
@@ -1341,13 +1361,6 @@ function runUpdatePhaseLabel(phase: RunUpdateJobResponse["phase"]): string {
}
}
function safeRuntimeRef(ref: string): string {
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
return ref;
}
return "[redacted-ref]";
}
interface LogsSectionProps {
serverId: string;
}
@@ -1680,6 +1693,7 @@ interface PluginControlsSectionProps {
artifacts: ArtifactResponse[];
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onNavigate: PageComponentProps["onNavigate"];
}
function controlsForPlugin(plugin: GamePluginResponse): PluginControlDescriptor[] {
@@ -1746,7 +1760,7 @@ function lifecycleControlLabel(action: string): string {
}
}
function PluginControlsSection({ serverId, instance, plugins, artifacts, session, operations }: PluginControlsSectionProps) {
function PluginControlsSection({ serverId, instance, plugins, artifacts, session, operations, onNavigate }: PluginControlsSectionProps) {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [confirmControl, setConfirmControl] = useState<null | { plugin: PluginControlGroupView; control: PluginControlDescriptor }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
@@ -1829,12 +1843,16 @@ function PluginControlsSection({ serverId, instance, plugins, artifacts, session
{!isCollapsed && (
<div className="plugin-group-body">
{plugins.find((plugin) => plugin.id === group.pluginId) && (
<PluginBridgeExecutionPanel
plugin={plugins.find((plugin) => plugin.id === group.pluginId)!}
serverId={serverId}
serverInstance={instance}
artifacts={artifacts}
/>
<>
<PluginLifecycleWorkbench pluginId={group.pluginId} pluginName={group.pluginName} operations={plugins.find((plugin) => plugin.id === group.pluginId)?.productionLifecycle?.operations} serverId={serverId} />
<PluginBridgeExecutionPanel
plugin={plugins.find((plugin) => plugin.id === group.pluginId)!}
serverId={serverId}
serverInstance={instance}
artifacts={artifacts}
onNavigate={onNavigate}
/>
</>
)}
{group.controls.length === 0 && <span className="provider-id"></span>}
{group.controls.map((control) => {
@@ -1904,12 +1922,14 @@ interface PluginBridgeExecutionPanelProps {
serverId: string;
serverInstance: ServerInstanceResponse;
artifacts: ArtifactResponse[];
onNavigate: PageComponentProps["onNavigate"];
}
function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifacts }: PluginBridgeExecutionPanelProps) {
function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifacts, onNavigate }: PluginBridgeExecutionPanelProps) {
const [pendingAction, setPendingAction] = useState<PluginBridgeAction | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const page = plugin.pages[0];
const declaredPageKey = plugin.gameClientBridge?.pages?.[0]?.pageKey;
const page = plugin.pages.find((candidate) => candidate.key === declaredPageKey) ?? plugin.pages[0];
if (!page || plugin.bridgeActions.length === 0) {
return null;
}
@@ -1962,6 +1982,15 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
{result && <ResultBadge status={result.status} label={result.label} />}
</span>
<div className="action-strip">
<button
type="button"
className="icon-command"
onClick={() => onNavigate("pluginPage", { pluginId: plugin.id, routeKey: page.key, serverId })}
title={`打开 ${page.title}`}
>
<PackageOpen size={14} />
<span></span>
</button>
{executableActions.slice(0, 3).map((action) => (
<button
key={action}
@@ -2027,27 +2056,11 @@ interface LlmSectionProps {
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
const [prompt, setPrompt] = useState("");
const [currentConfig, setCurrentConfig] = useState<string>("");
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
void platformApiClient
.getServerConfig(serverId)
.then((response) => {
if (!cancelled) {
setCurrentConfig(response.content);
}
})
.catch(() => {
setCurrentConfig("");
});
return () => {
cancelled = true;
};
}, [serverId]);
const [approvalBusy, setApprovalBusy] = useState(false);
const [suggestionError, setSuggestionError] = useState("");
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -2056,50 +2069,53 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
}
setBusy(true);
setSuggestion(null);
setSuggestionError("");
try {
const response = await platformApiClient.suggestServerConfig({ serverInstanceId: serverId, prompt: prompt.trim(), currentConfig });
const preview = response.suggestedConfig
const response = await platformApiClient.invokeAI({ requestId: `web:ai.config:${serverId}:${Date.now()}`, serverInstanceId: serverId, purpose: "config.suggest", prompt: prompt.trim() });
if (response.status !== "ok") {
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
}
const recommendation = response.configRecommendation;
const preview = recommendation?.suggestedConfig
? await platformApiClient.previewServerConfigDiff(serverId, {
expectedConfigVersion: instance.configVersion,
expectedChecksum: instance.configChecksum,
key: defaultConfigKey,
proposedContent: response.suggestedConfig
key: recommendation.key,
proposedContent: recommendation.suggestedConfig
})
: undefined;
setSuggestion({
serverInstanceId: serverId,
source: "api",
recommendation: response.recommendation,
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
diffId: recommendation?.diffId,
expiresAt: recommendation?.expiresAt,
diff: preview ? configDiffViewFromPreview(preview) : undefined
});
} catch {
setSuggestion(buildLocalSuggestion(serverId, prompt.trim(), currentConfig));
} catch (caught) {
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
} finally {
setBusy(false);
}
}
async function applySuggestion() {
if (!suggestion?.diff) {
if (!suggestion?.diff || !suggestion.diffId || approvalBusy) {
return;
}
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
setApprovalBusy(true);
try {
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
expectedConfigVersion: suggestion.diff.configVersion ?? instance.configVersion,
expectedChecksum: suggestion.diff.checksum ?? instance.configChecksum,
key: suggestion.diff.key ?? defaultConfigKey,
proposedContent: suggestion.diff.nextContent,
proposedContentInputRef: suggestion.diff.proposedContentInputRef,
idempotencyKey: `web:config.write.llm:${serverId}:${Date.now()}`
});
const job = dispatch.job;
const approved = await platformApiClient.approveAIConfigDiff(suggestion.diffId, `web:ai.config.approve:${suggestion.diffId}`);
const job = approved.dispatch.job;
operations.succeed(operationId, `AI 建议已确认,写入任务 ${job.id} 已派发`, job);
setSuggestion(null);
setConfirming(false);
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "写入任务派发失败", operationId);
setConfirming(false);
} finally {
setApprovalBusy(false);
}
}
@@ -2130,6 +2146,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
/>
</div>
)}
{suggestionError && <ErrorState title="AI 建议不可用" reason={suggestionError} diagnosticId={`ai-config:${serverId}`} onRetry={() => setSuggestionError("")} compact />}
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void requestSuggestion(event)}>
<label>
AI
@@ -2149,7 +2166,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
<div className="panel-header" style={{ marginBottom: 0 }}>
<h3>AI </h3>
<span className="page-status">{suggestion.source === "api" ? "平台 LLM" : "本地建议(LLM 接口未提供)"}</span>
<span className="page-status"> AI Provider</span>
</div>
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
{suggestion.diff ? (
@@ -2175,7 +2192,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
title="确认应用 AI 配置建议"
description={`即将向服务器 ${instance.name}${serverId})派发配置写入任务。写入内容以上方差异为准。`}
confirmLabel="确认写入"
busy={llmOperation?.status === "pending"}
busy={approvalBusy || llmOperation?.status === "pending"}
onCancel={() => setConfirming(false)}
onConfirm={() => void applySuggestion()}
/>
@@ -2183,48 +2200,6 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
);
}
function buildLocalSuggestion(serverId: string, prompt: string, currentConfig: string): LlmSuggestionView {
const lines = currentConfig.split("\n");
const next = [...lines];
const changed: string[] = [];
const playerMatch = prompt.match(/(\d+)\s*(?:人|名玩家|players?)/i) ?? prompt.match(/玩家[^\d]*(\d+)/);
if (playerMatch) {
const index = next.findIndex((line) => line.startsWith("max-players="));
if (index >= 0) {
next[index] = `max-players=${playerMatch[1]}`;
changed.push(`max-players 调整为 ${playerMatch[1]}`);
}
}
if (/关闭\s*pvp|禁用\s*pvp|pvp.*(off|false|关)/i.test(prompt)) {
const index = next.findIndex((line) => line.startsWith("pvp="));
if (index >= 0) {
next[index] = "pvp=false";
changed.push("pvp 关闭");
}
}
if (/开启\s*pvp|pvp.*(on|true|开)/i.test(prompt)) {
const index = next.findIndex((line) => line.startsWith("pvp="));
if (index >= 0) {
next[index] = "pvp=true";
changed.push("pvp 开启");
}
}
const nextContent = next.join("\n");
if (changed.length === 0) {
return {
serverInstanceId: serverId,
source: "local",
recommendation: `暂时无法为“${prompt}”生成配置差异。平台 LLM 建议接口尚未提供;本地建议引擎只支持常见字段(如 max-players、pvp)。`
};
}
return {
serverInstanceId: serverId,
source: "local",
recommendation: `根据请求“${prompt}”,建议:${changed.join("")}。请确认差异后再写入。`,
diff: buildConfigDiff(serverId, currentConfig, nextContent)
};
}
export function configDiffViewFromPreview(preview: ServerConfigDiffPreviewResponse): ConfigDiffView {
const lines = preview.diff.map(configDiffLineFromPreviewLine);
const added = lines.filter((line) => line.kind === "added").length;
@@ -2261,6 +2236,8 @@ interface HistorySectionProps {
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
return (
<>
<ProductionGovernancePanel compact title={`服务器 ${serverId} 的容量与告警`} />
<div className="overview-two-col" aria-label="operation history">
<article className="console-panel">
<div className="panel-header">
@@ -2370,6 +2347,7 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHis
</div>
</article>
</div>
</>
);
}
+61 -25
View File
@@ -1,4 +1,4 @@
import { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
@@ -14,17 +14,17 @@ import {
runtimeUpdateStages,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { UsageMeter } from "../components/OperationControls";
import { ManagementDialog, UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
defaultServerCreateForm,
endpointLabel,
pendingJobsForServer,
pluginLabel,
runtimeBindingFields,
type ServerCreateFormState
} from "../contracts/serverManagement";
import { summarizeServerOperations } from "../contracts/operationsConsole";
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
import {
clientManagerBuildRequest,
@@ -56,6 +56,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [metrics, setMetrics] = useState<Map<string, ServerMetricsResponse>>(new Map());
const [metricsPending, setMetricsPending] = useState(true);
const [metricsError, setMetricsError] = useState("");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
@@ -63,7 +64,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const refresh = useCallback(async () => {
const refreshList = useCallback(async () => {
setListState("loading");
try {
const [pluginResponse, endpointResponse, instanceResponse, jobResponse] = await Promise.all([
@@ -97,33 +98,46 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
setListState("error");
setListError(error instanceof Error ? error.message : "加载失败");
}
}, []);
const refreshMetrics = useCallback(async () => {
setMetricsPending(true);
try {
const metricsResponse = await platformApiClient.listServerMetrics();
setMetrics(new Map(metricsResponse.items.map((item) => [item.serverInstanceId, item])));
} catch {
setMetricsError("");
} catch (error) {
setMetrics(new Map());
setMetricsError(error instanceof Error ? error.message : "服务器指标加载失败");
} finally {
setMetricsPending(false);
}
}, []);
const refresh = useCallback(async () => {
await Promise.all([refreshList(), refreshMetrics()]);
}, [refreshList, refreshMetrics]);
useEffect(() => {
void refresh();
}, [refresh]);
const cards = useMemo<ServerCardView[]>(
() =>
instances.map((instance) => ({
instance,
metrics: metrics.get(instance.id),
pendingJobs: pendingJobsForServer(jobs, instance.id).length
summarizeServerOperations(instances, metrics, jobs).map((summary) => ({
instance: summary.instance,
metrics: summary.metrics,
pendingJobs: summary.activeJobs,
activeJobs: summary.activeJobs,
failedJobs: summary.failedJobs,
latestJob: summary.latestJob
})),
[instances, jobs, metrics]
);
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
const createPending = operations.isPending("platform", "创建服务器");
const canManageServers = session.capabilities.includes("servers.manage");
const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId);
const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey);
@@ -356,13 +370,23 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
<Sparkles size={16} />
<span></span>
</button>
<button type="button" className="icon-command" title="创建服务器" onClick={() => setShowCreate((current) => !current)}>
<button type="button" className="icon-command" title={canManageServers ? "创建服务器" : "当前账号没有创建权限"} disabled={!canManageServers} onClick={() => setShowCreate((current) => !current)}>
<Candy size={16} />
<span></span>
</button>
</div>
</header>
{metricsError && (
<div className="operations-inline-warning" role="status">
<AlertTriangle size={14} />
<span>{metricsError}</span>
<button type="button" className="inline-link-command" onClick={() => void refreshMetrics()}>
</button>
</div>
)}
{latestCreate && (
<div className="inline-result-strip" aria-live="polite">
<ResultBadge
@@ -378,11 +402,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</div>
)}
{showCreate && (
<form className="provider-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
<div className="form-header">
<h2></h2>
</div>
<ManagementDialog
open={showCreate && canManageServers}
title="创建服务器"
description="选择插件声明的运行配置和安全逻辑绑定。提交后以 Platform 返回的实例与安装任务为准。"
wide
onClose={() => { if (!createPending) setShowCreate(false); }}
>
<form className="provider-form dialog-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
<div className="form-grid">
<label>
ID
@@ -436,12 +463,15 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</label>
))}
</div>
<button type="submit" className="primary-command" disabled={createPending || !form.profileKey} title="创建服务器">
<Sparkles size={16} />
<span>{createPending ? "创建中…" : "创建并安装"}</span>
</button>
<div className="confirm-actions">
<button type="button" disabled={createPending} onClick={() => setShowCreate(false)}></button>
<button type="submit" className="confirm-primary" disabled={createPending || !form.profileKey} title="创建服务器">
<Sparkles size={16} />
<span>{createPending ? "创建中…" : "创建并安装"}</span>
</button>
</div>
</form>
)}
</ManagementDialog>
<div className="server-toolbar" role="search">
<Search size={16} aria-hidden="true" />
@@ -465,7 +495,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</div>
{listState === "loading" && <LoadingState label="正在加载服务器列表…" />}
{listState === "error" && <ErrorState title="服务器列表加载失败" reason={listError} diagnosticId="server-list" onRetry={() => void refresh()} />}
{listState === "error" && <ErrorState title="服务器列表加载失败" reason={listError} diagnosticId="server-list" onRetry={() => void refreshList()} />}
{listState === "ready" && cards.length === 0 && (
<EmptyState
icon={<CakeSlice size={26} />}
@@ -493,6 +523,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
key={card.instance.id}
card={card}
metricsPending={metricsPending}
metricsUnavailable={Boolean(metricsError)}
canManage={canManageServers}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
/>
@@ -517,12 +549,14 @@ type ServerQuickRuntimeAction =
interface ServerCardProps {
card: ServerCardView;
metricsPending: boolean;
metricsUnavailable: boolean;
canManage: boolean;
onOpen: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void;
}
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card;
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
@@ -622,7 +656,7 @@ function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardP
</span>
<span className="server-card-stat">
<span></span>
<strong>{pendingJobs > 0 ? `${pendingJobs} 进行中` : online ? "空闲" : "--"}</strong>
<strong>{failedJobs > 0 ? `${failedJobs} 失败` : pendingJobs > 0 ? `${pendingJobs} 进行中` : online ? "空闲" : "--"}</strong>
</span>
</div>
<div className="server-card-meters">
@@ -630,12 +664,14 @@ function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardP
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
{metricsUnavailable && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{failedJobs > 0 && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
<div className="action-strip" style={{ justifyContent: "space-between" }}>
<button type="button" className="icon-command" onClick={onOpen}>
<Sparkles size={14} />
<span></span>
</button>
<button ref={menuButtonRef} type="button" className="icon-command" aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canManage} title={canManage ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span>
</button>
</div>
+30 -1
View File
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UsersPage } from "./UsersPage";
import usersPageSource from "./UsersPage.tsx?raw";
import type { UserResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
@@ -66,11 +67,22 @@ describe("UsersPage", () => {
expect(html).not.toContain("Plugin Reviewer");
});
it("renders API failure without substituting actionable sample users", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [], loading: false, source: "api", loadError: "backend unavailable" }} />);
expect(html).toContain("用户 API 暂不可用");
expect(html).toContain("backend unavailable");
expect(html).toContain("重试");
expect(html).not.toContain("local.example.test");
expect(usersPageSource).toContain("setUsers([])");
expect(usersPageSource).not.toContain("fallbackUsers");
});
it("renders explicit local-development fixture state with a single status flow", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "local-development" }} />);
expect(html).toContain("Plugin Reviewer");
expect(html).toContain("本地开发样例 / 禁止假成功");
expect(html).toContain("本地开发样例 / 仅查看");
expect(html).toContain("编辑");
expect(html).toContain("状态");
expect(html).toContain("应用状态");
@@ -91,6 +103,23 @@ describe("UsersPage", () => {
expect(html).not.toContain('aria-label="编辑用户"');
});
it("renders full-width user search and role/status filters", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
expect(html).toContain("搜索名称、邮箱或用户 ID");
expect(html).toContain("按角色筛选");
expect(html).toContain("按状态筛选");
expect(html).toContain("平台管理员");
expect(html).toContain("服务器管理员");
});
it("prevents duplicate create and edit submissions while session operations are pending", () => {
expect(usersPageSource).toContain('operations.isPending("users", "邀请用户")');
expect(usersPageSource).toContain('operations.isPending(editingUserId, "编辑用户")');
expect(usersPageSource).toContain("createPending ? \"发送中…\"");
expect(usersPageSource).toContain("editPending ? \"保存中…\"");
});
it("does not render account maintenance controls for non-admins", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps(serverUser)} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
+75 -67
View File
@@ -1,5 +1,5 @@
import { HeartHandshake, Sparkles, UserPen, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { UserCreateRequest, UserResponse, UserStatus } from "../api/types";
@@ -7,7 +7,6 @@ import { ConfirmDialog, ManagementDialog } from "../components/OperationControls
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { userAccess } from "../contracts/shell";
import { type UserEditFormState, type UserListSource, type UserRemovalConfirmationState, userEditFormFromResponse } from "../contracts/users";
import { isPlatformAdmin } from "../contracts/workspace";
import { userCreateRequestFromDraft, userDeactivateRequest, userUpdateRequestFromEditForm } from "../schemas/users";
@@ -25,21 +24,6 @@ const statusOptions: Array<{ value: UserStatus; label: string }> = [
{ value: "disabled", label: "停用" }
];
const fallbackUsers: UserResponse[] = userAccess.map((user, index) => ({
id: user.id,
displayName: user.displayName,
email: `${user.id}@local.example.test`,
status: user.status,
roles: user.roles,
profile: {
phone: index === 0 ? "13800000000" : "",
qq: index === 0 ? "10001" : "",
contactNote: user.review
},
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
}));
interface UsersPageInitialState {
users?: UserResponse[];
loading?: boolean;
@@ -63,6 +47,9 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
const [confirmRemoval, setConfirmRemoval] = useState<UserRemovalConfirmationState | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [statusDrafts, setStatusDrafts] = useState<Record<string, UserStatus>>({});
const [keyword, setKeyword] = useState("");
const [roleFilter, setRoleFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<UserStatus | "all">("all");
const [draft, setDraft] = useState<UserCreateRequest>({
displayName: "",
email: "",
@@ -71,43 +58,33 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
profile: { phone: "", qq: "", contactNote: "" }
});
useEffect(() => {
let cancelled = false;
const loadUsers = useCallback(async () => {
if (initialState?.users) {
setUsers(initialState.users);
setLoading(initialState.loading ?? false);
setSource(initialState.source ?? "api");
setLoadError(initialState.loadError ?? "");
return () => {
cancelled = true;
};
return;
}
setLoading(true);
setLoadError("");
try {
const response = await platformApiClient.listUsers();
setUsers(response.items);
setSource("api");
} catch (error) {
setUsers([]);
setSource("api");
setLoadError(error instanceof Error ? error.message : "账号 API 加载失败,未显示本地样例数据。");
} finally {
setLoading(false);
}
void platformApiClient
.listUsers()
.then((response) => {
if (!cancelled) {
setUsers(response.items);
setSource("api");
setLoadError("");
}
})
.catch(() => {
if (!cancelled) {
setUsers(import.meta.env.DEV ? fallbackUsers : []);
setSource(import.meta.env.DEV ? "local-development" : "api");
setLoadError(import.meta.env.DEV ? "账号 API 加载失败,当前显示本地开发样例;状态变更会被平台 API 拒绝或写入。" : "账号 API 加载失败,未显示本地样例数据。");
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [initialState]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
useEffect(() => {
setStatusDrafts(Object.fromEntries(users.map((user) => [user.id, user.status])));
}, [users]);
@@ -121,6 +98,23 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
[users]
);
const persistenceDisabled = source === "local-development";
const filteredUsers = useMemo(() => {
const query = keyword.trim().toLowerCase();
return users.filter((user) => {
if (query && !`${user.displayName} ${user.email ?? ""} ${user.id}`.toLowerCase().includes(query)) {
return false;
}
if (roleFilter && !user.roles.some((role) => role === roleFilter || role.replace("-", "") === roleFilter.replace("-", ""))) {
return false;
}
return statusFilter === "all" || user.status === statusFilter;
});
}, [keyword, roleFilter, statusFilter, users]);
const hasActiveFilters = Boolean(keyword.trim() || roleFilter || statusFilter !== "all");
const mutationDisabled = persistenceDisabled || loading || Boolean(loadError);
const createPending = operations.isPending("users", "邀请用户");
const editPending = editingUserId ? operations.isPending(editingUserId, "编辑用户") : false;
const summaryAvailable = !loading && !loadError;
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -267,34 +261,48 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
<PageFrame
kicker="身份"
title="用户管理"
status={source === "api" ? "账号 API 已连接" : "本地开发数据"}
status={loadError ? "账号 API 不可用" : source === "api" ? "账号 API 已连接" : "本地开发数据"}
metrics={[
{ label: "用户", value: `${users.length}`, tone: "success" },
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
{ label: "待审核", value: `${counts.pending}`, tone: "warning" }
{ label: "用户", value: summaryAvailable ? `${users.length}` : "--", tone: "success" },
{ label: "角色", value: summaryAvailable ? `${counts.roleCount}` : "--", tone: "neutral" },
{ label: "待审核", value: summaryAvailable ? `${counts.pending}` : "--", tone: "warning" }
]}
/>
{loading && <LoadingState label="正在加载用户列表…" />}
{loadError && <ErrorState title="用户 API 暂不可用" reason={loadError} diagnosticId="user-management:fallback" compact />}
{loadError && <ErrorState title="用户 API 暂不可用" reason={loadError} diagnosticId="user-management:list" onRetry={() => void loadUsers()} compact />}
<section className="console-panel">
<div className="panel-header">
<h2>访</h2>
{loading ? <ResultBadge status="pending" label="加载用户…" /> : result && <ResultBadge status={result.status} label={result.label} />}
<span className="page-status">{source === "api" ? "平台数据" : "本地开发样例 / 禁止假成功"}</span>
<button type="button" className="primary-command" disabled={persistenceDisabled} onClick={() => setCreateOpen(true)}>
<span className="page-status">{source === "local-development" ? "本地开发样例 / 仅查看" : loadError ? "API 不可用" : "平台数据"}</span>
<button type="button" className="primary-command" disabled={mutationDisabled} onClick={() => setCreateOpen(true)}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "邀请用户"}</span>
<span>{mutationDisabled ? "等待 API" : "邀请用户"}</span>
</button>
</div>
<AccessFlowGuide />
<RoleImpactGuide />
{users.length === 0 ? (
<div className="resource-filter-bar" role="search" aria-label="用户筛选">
<input type="search" value={keyword} placeholder="搜索名称、邮箱或用户 ID" aria-label="搜索用户" onChange={(event) => setKeyword(event.target.value)} />
<select value={roleFilter} aria-label="按角色筛选" onChange={(event) => setRoleFilter(event.target.value)}>
<option value=""></option>
{roleOptions.map((role) => <option key={role.value} value={role.value}>{role.label}</option>)}
</select>
<select value={statusFilter} aria-label="按状态筛选" onChange={(event) => setStatusFilter(event.target.value as UserStatus | "all")}>
<option value="all"></option>
{statusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
{hasActiveFilters && <button type="button" className="inline-link-command" onClick={() => { setKeyword(""); setRoleFilter(""); setStatusFilter("all"); }}></button>}
</div>
{!loading && !loadError && users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。邀请用户后会在这里显示 API 连接结果。" actionLabel="邀请用户" onAction={() => setCreateOpen(true)} />
) : !loading && !loadError && filteredUsers.length === 0 ? (
<EmptyState title="没有匹配用户" description="调整名称、角色或状态筛选后再试。" actionLabel="清除筛选" onAction={() => { setKeyword(""); setRoleFilter(""); setStatusFilter("all"); }} />
) : (
<div className="resource-list user-management-list">
{users.map((user) => (
{filteredUsers.map((user) => (
<article key={user.id} className="resource-list-item user-management-item">
<div>
<strong>{user.displayName}</strong>
@@ -307,7 +315,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
<span>{user.roles.map(roleLabel).join(" / ")}</span>
<span>{profileSummary(user)}</span>
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
<button type="button" className="theme-upload" disabled={persistenceDisabled} aria-label={`编辑 ${user.displayName}`} onClick={() => startEdit(user)}>
<button type="button" className="theme-upload" disabled={mutationDisabled || operations.isPending(user.id, "编辑用户")} aria-label={`编辑 ${user.displayName}`} onClick={() => startEdit(user)}>
<UserPen size={13} />
</button>
@@ -315,7 +323,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
<span></span>
<select
value={statusDrafts[user.id] ?? user.status}
disabled={persistenceDisabled}
disabled={mutationDisabled || operations.isPending(user.id, "更新用户状态")}
aria-label={`选择 ${user.displayName} 状态`}
onChange={(event) => setStatusDrafts((current) => ({ ...current, [user.id]: event.target.value as UserStatus }))}
>
@@ -329,7 +337,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
<button
type="button"
className="theme-upload"
disabled={persistenceDisabled || (statusDrafts[user.id] ?? user.status) === user.status}
disabled={mutationDisabled || operations.isPending(user.id, "更新用户状态") || (statusDrafts[user.id] ?? user.status) === user.status}
aria-label={`应用 ${user.displayName} 状态变更`}
onClick={() => applyUserStatus(user)}
>
@@ -343,7 +351,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
)}
</section>
<ManagementDialog open={createOpen} title="邀请用户" wide onClose={() => setCreateOpen(false)}>
<ManagementDialog open={createOpen} title="邀请用户" wide onClose={() => { if (!createPending) setCreateOpen(false); }}>
<form className="management-form dialog-form" onSubmit={createUser}>
<div className="form-guidance management-form-wide">
<strong></strong>
@@ -400,18 +408,18 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
</div>
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={() => setCreateOpen(false)}>
<button type="button" disabled={createPending} onClick={() => setCreateOpen(false)}>
</button>
<button type="submit" className="confirm-primary" disabled={persistenceDisabled}>
<button type="submit" className="confirm-primary" disabled={mutationDisabled || createPending}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "发送邀请"}</span>
<span>{mutationDisabled ? "等待 API" : createPending ? "发送中…" : "发送邀请"}</span>
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog open={editDraft !== null} title="编辑用户" wide onClose={closeEdit}>
<ManagementDialog open={editDraft !== null} title="编辑用户" wide onClose={() => { if (!editPending) closeEdit(); }}>
{editDraft && (
<form className="management-form dialog-form" onSubmit={(event) => void saveUserEdit(event)}>
<label>
@@ -454,12 +462,12 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
</div>
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={closeEdit}>
<button type="button" disabled={editPending} onClick={closeEdit}>
</button>
<button type="submit" className="confirm-primary">
<button type="submit" className="confirm-primary" disabled={editPending}>
<UserPen size={14} />
<span></span>
<span>{editPending ? "保存中…" : "保存用户"}</span>
</button>
</div>
</form>
+2
View File
@@ -5,6 +5,7 @@ import { AiProvidersPage } from "./AiProvidersPage";
import { HomePage } from "./HomePage";
import { MaintenancePage } from "./MaintenancePage";
import { PluginsPage } from "./PluginsPage";
import { PluginPageHostPage } from "./PluginPageHostPage";
import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage";
import { ServersPage } from "./ServersPage";
@@ -14,6 +15,7 @@ export const pageRegistry: Record<PageId, ComponentType<PageComponentProps>> = {
home: HomePage,
servers: ServersPage,
serverDetail: ServerDetailPage,
pluginPage: PluginPageHostPage,
plugins: PluginsPage,
profileSettings: ProfileSettingsPage,
users: UsersPage,
+4 -1
View File
@@ -11,7 +11,7 @@ First-party routes must be declared here before page implementation.
- `/users`: 用户管理.
- `/ai-providers`: AI 提供商管理.
- `/maintenance`: 系统维护(审计事件与运行节点).
- `/plugin-pages/:pluginId/:routeKey`: platform-hosted plugin page route.
- `/plugin-pages/:pluginId/:routeKey?serverInstanceId=:serverId`: platform-hosted plugin page route with optional server context; IDs are URL encoded and the route is not shown in primary navigation.
## Role Scoping
@@ -23,3 +23,6 @@ Navigation entries are generated from the current user's capability set (`contra
- Server creation opens inline from 服务器管理 (no separate `/servers/new` page).
Server and plugin detail flows must use routes, modals, or drawers. Do not build a fixed left-list/right-detail page.
# Server Detail lifecycle route
The `serverDetail` route owns the Client Manager workspace as an overview operations surface. It is a detail workflow, not a permanent split pane: list pages remain full-width and destructive lifecycle commands use shared confirmation dialogs. Route state does not contain component secrets or Run session material.
+8
View File
@@ -41,6 +41,14 @@ describe("console shell routes", () => {
expect(hashForPage("serverDetail", { serverId: "server-example-1" })).toBe("#/servers/server-example-1");
});
it("round-trips hosted plugin page hashes with server context", () => {
const hash = hashForPage("pluginPage", { pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" });
expect(hash).toBe("#/plugin-pages/game.scum/operations?serverInstanceId=server%2Fscum-1");
const resolved = resolveRouteHash(hash, platformAdmin);
expect(resolved.route).toMatchObject({ id: "pluginPage", showInNav: false, requiredCapability: "servers.read" });
expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" });
});
it("keeps route metadata available for shell navigation", () => {
expect(firstPartyRoutes.map((route) => route.id)).toContain("maintenance");
expect(firstPartyRoutes.map((route) => route.id)).toContain("profileSettings");
+30 -1
View File
@@ -39,6 +39,15 @@ export const firstPartyRoutes: PageRoute[] = [
requiredCapability: "servers.read",
showInNav: false
},
{
id: "pluginPage",
label: "插件页面",
path: "/plugin-pages/:pluginId/:routeKey",
hash: "#/plugin-pages/:pluginId/:routeKey",
description: "平台托管的插件运维页面",
requiredCapability: "servers.read",
showInNav: false
},
{
id: "plugins",
label: "插件市场",
@@ -93,6 +102,14 @@ export function hashForPage(pageId: PageId, params: PageParams = {}): string {
if (pageId === "serverDetail" && params.serverId) {
return `#/servers/${encodeURIComponent(params.serverId)}`;
}
if (pageId === "pluginPage" && params.pluginId && params.routeKey) {
const query = new URLSearchParams();
if (params.serverId) {
query.set("serverInstanceId", params.serverId);
}
const suffix = query.toString();
return `#/plugin-pages/${encodeURIComponent(params.pluginId)}/${encodeURIComponent(params.routeKey)}${suffix ? `?${suffix}` : ""}`;
}
return route.hash;
}
@@ -121,13 +138,25 @@ export function resolveRouteHash(hash: string, user?: CurrentUserView): Resolved
if (!normalized || normalized === "/") {
return fallback;
}
const segments = normalized.replace(/^\//, "").split("/").filter(Boolean);
const [normalizedPath, rawQuery = ""] = normalized.split("?", 2);
const segments = normalizedPath.replace(/^\//, "").split("/").filter(Boolean);
if (segments.length === 0) {
return fallback;
}
if (segments[0] === "servers" && segments.length > 1) {
return { route: routeForPage("serverDetail"), params: { serverId: decodeURIComponent(segments[1]) } };
}
if (segments[0] === "plugin-pages" && segments.length > 2) {
const serverId = new URLSearchParams(rawQuery).get("serverInstanceId") ?? undefined;
return {
route: routeForPage("pluginPage"),
params: {
pluginId: decodeURIComponent(segments[1]),
routeKey: decodeURIComponent(segments[2]),
serverId
}
};
}
const key = segments[0];
const byId = routesById.get(key as PageId);
if (byId) {
+4 -1
View File
@@ -38,10 +38,13 @@ describe("ai provider form schemas", () => {
...emptyAiProviderForm(),
id: "ai.openai",
apiKeyRef: "",
apiKeyConfigured: true
apiKeyConfigured: true,
baseUrl: "",
baseUrlConfigured: true
});
expect(request.apiKeyRef).toBe("");
expect(request.baseUrl).toBe("");
expect(JSON.stringify(request)).not.toContain("secret://providers/openai");
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "./clientManagerLifecycle";
export const safeClientManagerLifecycleFixture = {
id: "client-manager-installation-1",
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client-manager",
targetOs: "windows",
targetArch: "amd64",
status: "online",
phase: "component heartbeat healthy",
desiredVersion: "2.0.0",
activeVersion: "2.0.0",
previousVersion: "1.0.0",
desiredRevision: "rev-2",
activeRevision: "rev-2",
previousRevision: "rev-1",
desiredArtifactId: "artifact-2",
activeArtifactId: "artifact-2",
previousArtifactId: "artifact-1",
keyGeneration: 3,
deploymentGeneration: 4,
currentJobId: "job-update-1",
lastSuccessfulJobId: "job-deploy-1",
lastOperation: "update",
health: "healthy",
healthReason: "component heartbeat healthy",
lastSeenAt: "2026-07-18T08:00:00Z",
retryable: false,
requiresRedeploy: false,
updatedAt: "2026-07-18T08:00:00Z",
distribution: { id: "distribution-2", artifactId: "artifact-2", sourceRevision: "rev-2", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 3, status: "available" },
job: { id: "job-update-1", state: "running", progress: { percent: 65, message: "health confirmation" }, attempt: 1, createdAt: "2026-07-18T07:59:00Z", updatedAt: "2026-07-18T08:00:00Z" },
actions: [
{ operation: "start", available: false, reason: "already online" },
{ operation: "stop", available: true },
{ operation: "rollback", available: true }
]
} as const;
describe("Client Manager lifecycle schema", () => {
it("preserves safe lifecycle, job progress, versions and action availability", () => {
const parsed = parseSafeClientManagerLifecycle(safeClientManagerLifecycleFixture);
expect(parsed).toMatchObject({ status: "online", health: "healthy", activeVersion: "2.0.0", previousVersion: "1.0.0", job: { state: "running", progress: { percent: 65 } } });
expect(parseSafeClientManagerLifecycleList({ items: [safeClientManagerLifecycleFixture], count: 1 })).toMatchObject({ count: 1, items: [{ profileKey: "scum-client-manager" }] });
});
it.each([
{ runEndpointId: "run-private" },
{ pid: 4124 },
{ secretRef: "redacted" },
{ healthReason: "/Users/operator/client-manager" },
{ healthReason: "unix://private.sock" }
])("rejects machine and credential projection %#", (unsafe) => {
expect(() => parseSafeClientManagerLifecycle({ ...safeClientManagerLifecycleFixture, ...unsafe })).toThrow(/forbidden|sensitive/i);
});
});
@@ -0,0 +1,117 @@
import type {
ClientManagerInstallationListResponse,
ClientManagerInstallationResponse,
ClientManagerLifecycleActionResponse,
ClientManagerLifecycleOperation,
ClientManagerLifecycleStatus,
JobState
} from "../api/types";
const lifecycleStatuses = new Set<ClientManagerLifecycleStatus>([
"requested", "building", "available", "deploying", "installed", "registering", "online", "degraded", "offline", "updating", "rolling_back", "stopping", "uninstalled", "failed"
]);
const lifecycleOperations = new Set<ClientManagerLifecycleOperation>(["deploy", "start", "stop", "restart", "status", "update", "rollback", "uninstall"]);
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
const forbiddenKeys = new Set(["key", "token", "secretref", "secretvalue", "hostpath", "pid", "socket", "credential", "dsn", "password", "runendpointid"]);
const forbiddenFragments = ["secret://", "/users/", "/var/run/", "bearer ", "password=", "unix://", "tcp://", "mysql://", "sqlite://", "rcon://"];
export function parseSafeClientManagerLifecycleList(value: unknown): ClientManagerInstallationListResponse {
const record = object(value, "Client Manager lifecycle list");
rejectSensitiveProjection(record);
const items = array(record.items, "items").map(parseSafeClientManagerLifecycle);
const count = number(record.count, "count");
return { items, count };
}
export function parseSafeClientManagerLifecycle(value: unknown): ClientManagerInstallationResponse {
const record = object(value, "Client Manager lifecycle");
rejectSensitiveProjection(record);
const status = string(record.status, "status") as ClientManagerLifecycleStatus;
if (!lifecycleStatuses.has(status)) throw new Error("Client Manager lifecycle status is invalid");
const actions = array(record.actions, "actions").map(parseAction);
const result: ClientManagerInstallationResponse = {
id: string(record.id, "id"),
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
pluginId: string(record.pluginId, "pluginId"),
profileKey: string(record.profileKey, "profileKey"),
targetOs: string(record.targetOs, "targetOs"),
targetArch: string(record.targetArch, "targetArch"),
status,
phase: string(record.phase, "phase"),
keyGeneration: number(record.keyGeneration, "keyGeneration"),
deploymentGeneration: number(record.deploymentGeneration, "deploymentGeneration"),
health: health(record.health),
retryable: boolean(record.retryable, "retryable"),
requiresRedeploy: boolean(record.requiresRedeploy, "requiresRedeploy"),
updatedAt: string(record.updatedAt, "updatedAt"),
actions
};
copyOptionalStrings(record, result, ["desiredVersion", "activeVersion", "previousVersion", "desiredRevision", "activeRevision", "previousRevision", "desiredArtifactId", "activeArtifactId", "previousArtifactId", "currentJobId", "lastSuccessfulJobId", "healthReason", "lastSeenAt", "installedAt", "uninstalledAt"]);
if (record.lastOperation !== undefined) {
const operation = string(record.lastOperation, "lastOperation") as ClientManagerLifecycleOperation;
if (!lifecycleOperations.has(operation)) throw new Error("Client Manager lifecycle operation is invalid");
result.lastOperation = operation;
}
if (record.distribution !== undefined) {
const distribution = object(record.distribution, "distribution");
result.distribution = {
id: string(distribution.id, "distribution.id"), artifactId: string(distribution.artifactId, "distribution.artifactId"), sourceRevision: string(distribution.sourceRevision, "distribution.sourceRevision"),
targetOs: string(distribution.targetOs, "distribution.targetOs"), targetArch: string(distribution.targetArch, "distribution.targetArch"), checksum: string(distribution.checksum, "distribution.checksum"),
keyGeneration: number(distribution.keyGeneration, "distribution.keyGeneration"), status: string(distribution.status, "distribution.status")
};
}
if (record.job !== undefined) {
const job = object(record.job, "job");
const state = string(job.state, "job.state") as JobState;
if (!jobStates.has(state)) throw new Error("Client Manager job state is invalid");
const progress = object(job.progress, "job.progress");
result.job = { id: string(job.id, "job.id"), state, progress: { percent: number(progress.percent, "job.progress.percent"), message: optionalString(progress.message) }, attempt: number(job.attempt, "job.attempt"), createdAt: string(job.createdAt, "job.createdAt"), updatedAt: string(job.updatedAt, "job.updatedAt") };
}
return result;
}
function parseAction(value: unknown): ClientManagerLifecycleActionResponse {
const action = object(value, "action");
const operation = string(action.operation, "action.operation") as ClientManagerLifecycleOperation;
if (!lifecycleOperations.has(operation)) throw new Error("Client Manager action is invalid");
return { operation, available: boolean(action.available, "action.available"), reason: optionalString(action.reason) };
}
function rejectSensitiveProjection(value: unknown, key = ""): void {
if (typeof value === "string") {
const normalized = value.toLowerCase();
if (forbiddenFragments.some((fragment) => normalized.includes(fragment))) throw new Error("Client Manager response contains sensitive machine data");
return;
}
if (Array.isArray(value)) {
value.forEach((item) => rejectSensitiveProjection(item, key));
return;
}
if (value && typeof value === "object") {
for (const [childKey, child] of Object.entries(value)) {
if (forbiddenKeys.has(childKey.toLowerCase())) throw new Error("Client Manager response contains a forbidden field");
rejectSensitiveProjection(child, childKey);
}
}
}
function copyOptionalStrings(source: Record<string, unknown>, target: ClientManagerInstallationResponse, keys: Array<keyof ClientManagerInstallationResponse>) {
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value !== "") (target as unknown as Record<string, unknown>)[key] = value;
}
}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] { if (!Array.isArray(value)) throw new Error(`${label} must be an array`); return value; }
function string(value: unknown, label: string): string { if (typeof value !== "string" || value === "") throw new Error(`${label} must be a string`); return value; }
function optionalString(value: unknown): string | undefined { return typeof value === "string" && value !== "" ? value : undefined; }
function number(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a number`); return value; }
function boolean(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); return value; }
function health(value: unknown): ClientManagerInstallationResponse["health"] {
if (value === "unknown" || value === "healthy" || value === "degraded" || value === "unhealthy" || value === "offline") return value;
throw new Error("Client Manager health is invalid");
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
parseSafeGameClientBridgeCommand,
parseSafeGameClientBridgeSnapshotList,
parseSafeGameClientBridgeStatus
} from "./gameClientBridge";
const now = "2026-07-20T08:00:00Z";
const safeCommand = {
id: "command-1",
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client",
commandType: "scum.player.lookup",
priority: 10,
state: "succeeded",
approvalState: "not_required",
requesterId: "user-1",
result: { status: "succeeded", summary: "player found", payload: { found: true }, completedAt: now },
auditReferences: ["audit-1"],
expiresAt: now,
createdAt: now,
updatedAt: now,
completedAt: now
};
const safeSnapshotList = {
items: [{
id: "snapshot-1",
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client",
type: "scum.players",
schemaVersion: "1",
streamKey: "current",
sequence: 1,
observedAt: now,
payload: { players: [{ playerId: "player-1" }] },
retention: { keepForSeconds: 3600, maxRecords: 24 },
createdAt: now,
expiresAt: now
}],
count: 1
};
describe("Game Client Bridge safe projection schema", () => {
it("preserves declarations, approval, result, retention and typed snapshot payloads", () => {
expect(parseSafeGameClientBridgeStatus({
serverInstanceId: "server-1",
pluginId: "game.scum",
available: true,
profiles: [{ pluginId: "game.scum", profileKey: "scum-client", available: true, commandTypes: ["scum.player.lookup"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }]
})).toMatchObject({ available: true, profiles: [{ queryTemplateKeys: ["scum.player.search"] }] });
expect(parseSafeGameClientBridgeCommand(safeCommand)).toMatchObject({ approvalState: "not_required", result: { payload: { found: true } } });
expect(parseSafeGameClientBridgeSnapshotList(safeSnapshotList)).toMatchObject({ count: 1, items: [{ retention: { maxRecords: 24 } }] });
expect(parseSafeGameClientBridgeSnapshotList({
...safeSnapshotList,
items: [{ ...safeSnapshotList.items[0], payload: { sessions: [{ sessionId: "game-session-1", playerId: "player-1" }] } }]
})).toMatchObject({ items: [{ payload: { sessions: [{ sessionId: "game-session-1" }] } }] });
});
it.each([
{ sessionToken: "component-session-material" },
{ componentSession: "component-session-material" },
{ componentKey: "raw-component-key" },
{ sourceSessionId: "component-session-1" },
{ result: { ...safeCommand.result, payload: { dsn: "sqlite:///srv/scum/SCUM.db" } } },
{ result: { ...safeCommand.result, payload: { endpoint: "tcp://127.0.0.1:9999" } } },
{ result: { ...safeCommand.result, payload: { output: "/Users/operator/scum/config.yaml" } } }
])("rejects forbidden command projection %#", (unsafe) => {
expect(() => parseSafeGameClientBridgeCommand({ ...safeCommand, ...unsafe })).toThrow(/forbidden|sensitive/i);
});
it("rejects credentials nested inside snapshot payloads", () => {
const unsafe = structuredClone(safeSnapshotList);
unsafe.items[0].payload = { players: [{ playerId: "player-1", credential: "raw-password" }] } as unknown as typeof unsafe.items[0]["payload"];
expect(() => parseSafeGameClientBridgeSnapshotList(unsafe)).toThrow(/forbidden/i);
});
});
+300
View File
@@ -0,0 +1,300 @@
import type {
GameClientBridgeApprovalState,
GameClientBridgeCancelResponse,
GameClientBridgeCommandCancellationResponse,
GameClientBridgeCommandListResponse,
GameClientBridgeCommandResponse,
GameClientBridgeCommandResultResponse,
GameClientBridgeCommandState,
GameClientBridgeJsonObject,
GameClientBridgeJsonValue,
GameClientBridgeProfileDeclarationResponse,
GameClientBridgeResultStatus,
GameClientBridgeSnapshotListResponse,
GameClientBridgeSnapshotResponse,
GameClientBridgeStatusResponse
} from "../api/types";
const commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired"]);
const approvalStates = new Set<GameClientBridgeApprovalState>(["not_required", "pending", "approved", "rejected"]);
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled"]);
const forbiddenKeys = new Set([
"apikey",
"accesskey",
"accesskeyid",
"claimlease",
"componentkey",
"componentsession",
"componentsessionid",
"credential",
"credentials",
"deploymentgeneration",
"dsn",
"fencingtoken",
"hostpath",
"installationid",
"keygeneration",
"leaseexpiresat",
"password",
"privatekey",
"rawcredential",
"runendpoint",
"runendpointurl",
"secret",
"secretref",
"secretvalue",
"sessiontoken",
"socket",
"sourcesessionid",
"storagecredential",
"token"
]);
const forbiddenFragments = [
"bearer ",
"password=",
"secret://",
"unix://",
"tcp://",
"mysql://",
"postgres://",
"sqlite://",
"rcon://"
];
const forbiddenHostPath = /(?:^|[\s"'])(?:\/[Uu]sers\/|\/home\/|\/root\/|\/var\/|\/etc\/|\/opt\/|[a-z]:[\\/]|\\\\[^\\]+\\)/;
export function parseSafeGameClientBridgeStatus(value: unknown): GameClientBridgeStatusResponse {
const record = safeObject(value, "Game Client Bridge status");
return {
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
pluginId: string(record.pluginId, "pluginId"),
available: boolean(record.available, "available"),
reason: optionalString(record.reason, "reason"),
profiles: array(record.profiles, "profiles").map(parseProfile)
};
}
export function parseSafeGameClientBridgeCommand(value: unknown): GameClientBridgeCommandResponse {
const record = safeObject(value, "Game Client Bridge command");
const result: GameClientBridgeCommandResponse = {
id: string(record.id, "id"),
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
pluginId: string(record.pluginId, "pluginId"),
profileKey: string(record.profileKey, "profileKey"),
commandType: string(record.commandType, "commandType"),
priority: number(record.priority, "priority"),
state: commandState(record.state),
approvalState: approvalState(record.approvalState),
expiresAt: string(record.expiresAt, "expiresAt"),
createdAt: string(record.createdAt, "createdAt"),
updatedAt: string(record.updatedAt, "updatedAt")
};
copyOptionalString(record, result, "requesterId");
copyOptionalString(record, result, "resultSummary");
copyOptionalString(record, result, "completedAt");
const auditReferences = optionalStringArray(record.auditReferences, "auditReferences");
if (auditReferences) result.auditReferences = auditReferences;
if (record.result !== undefined) result.result = parseResult(record.result);
if (record.cancellation !== undefined) result.cancellation = parseCancellation(record.cancellation);
return result;
}
export function parseSafeGameClientBridgeCommandList(value: unknown): GameClientBridgeCommandListResponse {
const record = safeObject(value, "Game Client Bridge command list");
return {
items: array(record.items, "items").map(parseSafeGameClientBridgeCommand),
count: nonNegativeInteger(record.count, "count")
};
}
export function parseSafeGameClientBridgeCancellation(value: unknown): GameClientBridgeCancelResponse {
const record = safeObject(value, "Game Client Bridge cancellation");
const result: GameClientBridgeCancelResponse = {
commandId: string(record.commandId, "commandId"),
state: commandState(record.state),
cancellation: parseCancellation(record.cancellation),
updatedAt: string(record.updatedAt, "updatedAt")
};
const auditReferences = optionalStringArray(record.auditReferences, "auditReferences");
if (auditReferences) result.auditReferences = auditReferences;
return result;
}
export function parseSafeGameClientBridgeSnapshotList(value: unknown): GameClientBridgeSnapshotListResponse {
const record = safeObject(value, "Game Client Bridge snapshot list");
return {
items: array(record.items, "items").map(parseSnapshot),
count: nonNegativeInteger(record.count, "count")
};
}
function parseProfile(value: unknown): GameClientBridgeProfileDeclarationResponse {
const record = safeObject(value, "Game Client Bridge profile");
return {
pluginId: string(record.pluginId, "profile.pluginId"),
profileKey: string(record.profileKey, "profile.profileKey"),
available: boolean(record.available, "profile.available"),
reason: optionalString(record.reason, "profile.reason"),
commandTypes: stringArray(record.commandTypes, "profile.commandTypes"),
snapshotTypes: stringArray(record.snapshotTypes, "profile.snapshotTypes"),
queryTemplateKeys: stringArray(record.queryTemplateKeys, "profile.queryTemplateKeys")
};
}
function parseResult(value: unknown): GameClientBridgeCommandResultResponse {
const record = safeObject(value, "Game Client Bridge command result");
const result: GameClientBridgeCommandResultResponse = {
status: resultStatus(record.status),
completedAt: string(record.completedAt, "result.completedAt")
};
const summary = optionalString(record.summary, "result.summary");
if (summary) result.summary = summary;
if (record.payload !== undefined) result.payload = jsonObject(record.payload, "result.payload");
return result;
}
function parseCancellation(value: unknown): GameClientBridgeCommandCancellationResponse {
const record = safeObject(value, "Game Client Bridge command cancellation");
const result: GameClientBridgeCommandCancellationResponse = {
cancelledAt: string(record.cancelledAt, "cancellation.cancelledAt")
};
const requestedBy = optionalString(record.requestedBy, "cancellation.requestedBy");
const reason = optionalString(record.reason, "cancellation.reason");
if (requestedBy) result.requestedBy = requestedBy;
if (reason) result.reason = reason;
return result;
}
function parseSnapshot(value: unknown): GameClientBridgeSnapshotResponse {
const record = safeObject(value, "Game Client Bridge snapshot");
const retention = safeObject(record.retention, "snapshot.retention");
const result: GameClientBridgeSnapshotResponse = {
id: string(record.id, "snapshot.id"),
serverInstanceId: string(record.serverInstanceId, "snapshot.serverInstanceId"),
pluginId: string(record.pluginId, "snapshot.pluginId"),
profileKey: string(record.profileKey, "snapshot.profileKey"),
type: string(record.type, "snapshot.type"),
schemaVersion: string(record.schemaVersion, "snapshot.schemaVersion"),
streamKey: string(record.streamKey, "snapshot.streamKey"),
sequence: nonNegativeInteger(record.sequence, "snapshot.sequence"),
observedAt: string(record.observedAt, "snapshot.observedAt"),
payload: jsonObject(record.payload, "snapshot.payload"),
retention: {
keepForSeconds: nonNegativeInteger(retention.keepForSeconds, "snapshot.retention.keepForSeconds")
},
createdAt: string(record.createdAt, "snapshot.createdAt"),
expiresAt: string(record.expiresAt, "snapshot.expiresAt")
};
if (retention.maxRecords !== undefined) result.retention.maxRecords = nonNegativeInteger(retention.maxRecords, "snapshot.retention.maxRecords");
const auditReferences = optionalStringArray(record.auditReferences, "snapshot.auditReferences");
if (auditReferences) result.auditReferences = auditReferences;
return result;
}
function safeObject(value: unknown, label: string): Record<string, unknown> {
const record = object(value, label);
rejectSensitiveProjection(record);
return record;
}
function rejectSensitiveProjection(value: unknown): void {
if (typeof value === "string") {
const normalized = value.toLowerCase();
if (forbiddenFragments.some((fragment) => normalized.includes(fragment)) || forbiddenHostPath.test(value)) {
throw new Error("Game Client Bridge response contains sensitive connection or host data");
}
return;
}
if (Array.isArray(value)) {
value.forEach(rejectSensitiveProjection);
return;
}
if (value && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
if (forbiddenKeys.has(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) {
throw new Error("Game Client Bridge response contains a forbidden field");
}
rejectSensitiveProjection(child);
}
}
}
function jsonObject(value: unknown, label: string): GameClientBridgeJsonObject {
const record = object(value, label);
return Object.fromEntries(Object.entries(record).map(([key, child]) => [key, jsonValue(child, `${label}.${key}`)]));
}
function jsonValue(value: unknown, label: string): GameClientBridgeJsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (Array.isArray(value)) return value.map((child, index) => jsonValue(child, `${label}[${index}]`));
if (value && typeof value === "object") return jsonObject(value, label);
throw new Error(`${label} must be JSON-compatible`);
}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || value === "") throw new Error(`${label} must be a string`);
return value;
}
function optionalString(value: unknown, label: string): string | undefined {
if (value === undefined || value === "") return undefined;
return string(value, label);
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a number`);
return value;
}
function nonNegativeInteger(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${label} must be a non-negative safe integer`);
return parsed;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
return value;
}
function stringArray(value: unknown, label: string): string[] {
return array(value, label).map((item, index) => string(item, `${label}[${index}]`));
}
function optionalStringArray(value: unknown, label: string): string[] | undefined {
if (value === undefined) return undefined;
return stringArray(value, label);
}
function commandState(value: unknown): GameClientBridgeCommandState {
const parsed = string(value, "state") as GameClientBridgeCommandState;
if (!commandStates.has(parsed)) throw new Error("Game Client Bridge command state is invalid");
return parsed;
}
function approvalState(value: unknown): GameClientBridgeApprovalState {
const parsed = string(value, "approvalState") as GameClientBridgeApprovalState;
if (!approvalStates.has(parsed)) throw new Error("Game Client Bridge approval state is invalid");
return parsed;
}
function resultStatus(value: unknown): GameClientBridgeResultStatus {
const parsed = string(value, "result.status") as GameClientBridgeResultStatus;
if (!resultStatuses.has(parsed)) throw new Error("Game Client Bridge result status is invalid");
return parsed;
}
function copyOptionalString<T extends object>(source: Record<string, unknown>, target: T, key: keyof T): void {
const parsed = optionalString(source[key as string], String(key));
if (parsed) (target as Record<keyof T, unknown>)[key] = parsed;
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { GameClientBridgeJsonObject, GameClientBridgeSnapshotResponse } from "../api/types";
import { projectScumOperationsSnapshots } from "./scumOperations";
const now = "2026-07-20T08:00:00Z";
function snapshot(type: string, payload: GameClientBridgeJsonObject, sequence = 1): GameClientBridgeSnapshotResponse {
return {
id: `${type}-${sequence}`,
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client",
type,
schemaVersion: "1",
streamKey: "current",
sequence,
observedAt: now,
payload,
retention: { keepForSeconds: 3600, maxRecords: 24 },
createdAt: now,
expiresAt: "2026-07-20T09:00:00Z"
};
}
describe("SCUM operations snapshot projection", () => {
it("projects companion, player, session, squad, vehicle and flag snapshots", () => {
const view = projectScumOperationsSnapshots([
snapshot("companion.health", { status: "online", version: "1.2.0", observedAt: now, latencyMs: 24, capabilities: ["game-client.bridge"] }),
snapshot("online.sessions", { observedAt: now, onlineCount: 1, sessions: [{ sessionId: "game-session-1", playerName: "Moonlight", startedAt: now }] }),
snapshot("players", { observedAt: now, players: [{ playerId: "player-1", playerName: "Moonlight", status: "online", squadId: "squad-1", pingMs: 33 }] }),
snapshot("squads", { observedAt: now, squads: [{ squadId: "squad-1", name: "Lunar", memberCount: 4, leaderPlayerId: "player-1" }] }),
snapshot("vehicles", { observedAt: now, vehicles: [{ vehicleId: "vehicle-1", vehicleType: "truck", status: "parked", ownerPlayerId: "player-1", fuelPercent: 70, healthPercent: 80 }] }),
snapshot("flags", { observedAt: now, flags: [{ flagId: "flag-1", status: "active", squadId: "squad-1", radiusMeters: 25 }] })
]);
expect(view).toMatchObject({
health: { status: "online", version: "1.2.0", latencyMs: 24 },
sessions: { total: 1, items: [{ sessionId: "game-session-1" }] },
players: { total: 1, items: [{ playerId: "player-1", squadId: "squad-1" }] },
squads: { total: 1, items: [{ memberCount: 4 }] },
vehicles: { total: 1, items: [{ fuelPercent: 70 }] },
flags: { total: 1, items: [{ radiusMeters: 25 }] }
});
});
it("uses the newest sequence and redacts sensitive-looking display strings", () => {
const view = projectScumOperationsSnapshots([
snapshot("players", { observedAt: now, players: [{ playerId: "player-old", playerName: "Old", status: "offline" }] }, 1),
{ ...snapshot("players", { observedAt: now, players: [{ playerId: "player-new", playerName: "token=raw-secret /Users/operator/file", status: "online" }] }, 2), observedAt: now }
]);
expect(view.players.items[0]).toMatchObject({ playerId: "player-new", status: "online" });
expect(view.players.items[0]?.playerName).not.toContain("raw-secret");
expect(view.players.items[0]?.playerName).not.toContain("/Users/");
});
});
+148
View File
@@ -0,0 +1,148 @@
import type { GameClientBridgeJsonObject, GameClientBridgeSnapshotResponse } from "../api/types";
import type {
ScumCompanionHealthView,
ScumFlagView,
ScumOperationsSnapshotView,
ScumPlayerView,
ScumSessionView,
ScumSnapshotCollection,
ScumSquadView,
ScumVehicleView
} from "../contracts/scumOperations";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
const maxVisibleItems = 50;
export function projectScumOperationsSnapshots(snapshots: GameClientBridgeSnapshotResponse[]): ScumOperationsSnapshotView {
return {
health: projectHealth(latestPayload(snapshots, "companion.health")),
sessions: projectCollection(latestSnapshot(snapshots, "online.sessions"), "sessions", projectSession),
players: projectCollection(latestSnapshot(snapshots, "players"), "players", projectPlayer),
squads: projectCollection(latestSnapshot(snapshots, "squads"), "squads", projectSquad),
vehicles: projectCollection(latestSnapshot(snapshots, "vehicles"), "vehicles", projectVehicle),
flags: projectCollection(latestSnapshot(snapshots, "flags"), "flags", projectFlag)
};
}
function latestSnapshot(snapshots: GameClientBridgeSnapshotResponse[], type: string): GameClientBridgeSnapshotResponse | undefined {
return snapshots
.filter((snapshot) => snapshot.type === type)
.sort((left, right) => right.observedAt.localeCompare(left.observedAt) || right.sequence - left.sequence)[0];
}
function latestPayload(snapshots: GameClientBridgeSnapshotResponse[], type: string): GameClientBridgeJsonObject | undefined {
return latestSnapshot(snapshots, type)?.payload;
}
function projectHealth(payload: GameClientBridgeJsonObject | undefined): ScumCompanionHealthView | undefined {
if (!payload) return undefined;
const status = readText(payload.status, 20);
return {
status: status === "online" || status === "degraded" || status === "offline" ? status : "unknown",
version: readOptionalText(payload.version, 40),
observedAt: readOptionalText(payload.observedAt, 64),
latencyMs: readOptionalNumber(payload.latencyMs, 0, 30000),
capabilities: readArray(payload.capabilities).map((value) => readText(value, 80)).filter(Boolean).slice(0, 16)
};
}
function projectCollection<T>(
snapshot: GameClientBridgeSnapshotResponse | undefined,
key: string,
project: (value: unknown) => T | null
): ScumSnapshotCollection<T> {
const values = snapshot ? readArray(snapshot.payload[key]) : [];
return {
observedAt: snapshot?.observedAt,
total: values.length,
items: values.slice(0, maxVisibleItems).map(project).filter((value): value is T => value !== null)
};
}
function projectSession(value: unknown): ScumSessionView | null {
const record = readObject(value);
const sessionId = readText(record?.sessionId, 120);
const playerName = readText(record?.playerName, 80);
return sessionId && playerName ? { sessionId, playerName, startedAt: readOptionalText(record?.startedAt, 64) } : null;
}
function projectPlayer(value: unknown): ScumPlayerView | null {
const record = readObject(value);
const playerId = readText(record?.playerId, 96);
const playerName = readText(record?.playerName, 80);
const status = readText(record?.status, 20);
return playerId && playerName && status ? {
playerId,
playerName,
status,
squadId: readOptionalText(record?.squadId, 96),
pingMs: readOptionalNumber(record?.pingMs, 0, 10000),
lastSeenAt: readOptionalText(record?.lastSeenAt, 64)
} : null;
}
function projectSquad(value: unknown): ScumSquadView | null {
const record = readObject(value);
const squadId = readText(record?.squadId, 96);
const name = readText(record?.name, 80);
const memberCount = readOptionalNumber(record?.memberCount, 0, 64);
return squadId && name && memberCount !== undefined ? {
squadId,
name,
memberCount,
leaderPlayerId: readOptionalText(record?.leaderPlayerId, 96),
lastActiveAt: readOptionalText(record?.lastActiveAt, 64)
} : null;
}
function projectVehicle(value: unknown): ScumVehicleView | null {
const record = readObject(value);
const vehicleId = readText(record?.vehicleId, 96);
const vehicleType = readText(record?.vehicleType, 80);
const status = readText(record?.status, 20);
return vehicleId && vehicleType && status ? {
vehicleId,
vehicleType,
status,
ownerPlayerId: readOptionalText(record?.ownerPlayerId, 96),
squadId: readOptionalText(record?.squadId, 96),
fuelPercent: readOptionalNumber(record?.fuelPercent, 0, 100),
healthPercent: readOptionalNumber(record?.healthPercent, 0, 100),
lastSeenAt: readOptionalText(record?.lastSeenAt, 64)
} : null;
}
function projectFlag(value: unknown): ScumFlagView | null {
const record = readObject(value);
const flagId = readText(record?.flagId, 96);
const status = readText(record?.status, 20);
return flagId && status ? {
flagId,
status,
ownerPlayerId: readOptionalText(record?.ownerPlayerId, 96),
squadId: readOptionalText(record?.squadId, 96),
radiusMeters: readOptionalNumber(record?.radiusMeters, 0, 5000),
lastUpdatedAt: readOptionalText(record?.lastUpdatedAt, 64)
} : null;
}
function readObject(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
function readArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function readText(value: unknown, maxLength: number): string {
if (typeof value !== "string") return "";
return (safeDiagnosticText(value.slice(0, maxLength), "") ?? "").trim();
}
function readOptionalText(value: unknown, maxLength: number): string | undefined {
return readText(value, maxLength) || undefined;
}
function readOptionalNumber(value: unknown, minimum: number, maximum: number): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= minimum && value <= maximum ? value : undefined;
}
@@ -19,6 +19,7 @@ const plugin: GamePluginResponse = {
pages: [],
tags: [],
aiPurposes: [],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
status: "installed",
runtimeProfiles: {
discovery: [{ key: "root-check", kind: "file.exists", targetKey: "server-root", required: true }],
+36
View File
@@ -2,6 +2,16 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("platform web shared theme CSS", () => {
it("keeps production lifecycle and alert controls bounded at narrow width", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const narrow = themeCss.slice(themeCss.indexOf("@media (max-width: 640px)", themeCss.indexOf(".production-governance-panel")));
expect(narrow).toContain(".plugin-lifecycle-controls");
expect(narrow).toContain("grid-template-columns: minmax(0, 1fr)");
expect(narrow).toContain(".production-alert-actions button");
expect(themeCss).toContain("overflow-wrap: anywhere");
});
it("suppresses nested state frames inside shared framed surfaces", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const nestedStateReset = themeCss.slice(themeCss.indexOf("A state view inside an already framed surface"));
@@ -76,4 +86,30 @@ describe("platform web shared theme CSS", () => {
expect(progressRule).toContain(".runtime-task-log");
expect(progressRule).toContain("prefers-reduced-motion");
});
it("styles operations surfaces with shared theme materials and narrow-screen collapse", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const operationsRule = themeCss.slice(themeCss.indexOf("/* ---- operations console enrichment ---- */"));
expect(operationsRule).toContain("var(--panel-material)");
expect(operationsRule).toContain(".operations-tray-panel");
expect(operationsRule).toContain("@media (max-width: 760px)");
expect(operationsRule).toContain("grid-template-columns: 1fr");
expect(operationsRule).toContain("prefers-reduced-motion");
expect(operationsRule).toContain(".operations-job-row-failed");
});
it("keeps mobile navigation off-canvas with readable vertical labels", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const mobileRule = themeCss.slice(themeCss.indexOf("/* ---- narrow screens ---- */"), themeCss.indexOf("/* A state view inside an already framed surface"));
expect(mobileRule).toContain(".mobile-sidebar-handle");
expect(mobileRule).toContain("position: fixed");
expect(mobileRule).toContain("padding: 14px 14px 14px 52px");
expect(mobileRule).toContain("transform: translateX(calc(-100% - 18px))");
expect(mobileRule).toContain(".app-sidebar-mobile-open");
expect(mobileRule).toContain("grid-template-columns: 40px minmax(0, 1fr)");
expect(mobileRule).toContain(".app-nav-copy strong");
expect(mobileRule).toContain("display: block");
});
});
File diff suppressed because it is too large Load Diff
+12
View File
@@ -181,6 +181,18 @@ function requiredPermissions(action: PluginBridgeAction): PluginPermission[] {
return ["server.artifacts.read"];
case "files.request":
return ["server.files.read"];
case "remote.access.request":
return ["server.remote.access"];
case "run.distribution.request":
return ["server.run.distribution"];
case "dependencies.request":
return ["server.dependencies.manage"];
case "logs.backfill.request":
return ["server.logs.read"];
case "client-manager.request":
return ["server.client-manager.manage"];
case "plugin-lifecycle.request":
return ["server.lifecycle"];
case "ai.invoke":
return ["ai.invoke"];
default:
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { safeDiagnosticText } from "./safeDiagnosticText";
describe("safeDiagnosticText", () => {
it("redacts raw credentials, host internals, sockets, process ids, DSNs, and private endpoints", () => {
const unsafe = [
"Bearer live-token-value",
"apiKey=sk-super-secret-value",
"secret://providers/production",
"/Users/operator/private/config.json",
"PID=48192",
"unix:///var/run/platform.sock",
"postgres://operator:password@db.internal/platform",
"RCON_PASSWORD=hunter2",
"http://127.0.0.1:18197/run/control"
].join(" | ");
const result = safeDiagnosticText(unsafe) ?? "";
for (const forbidden of ["live-token", "sk-super", "providers/production", "/Users/", "48192", "/var/run/", "operator:password", "hunter2", "127.0.0.1"]) {
expect(result).not.toContain(forbidden);
}
});
it("preserves safe operational wording instead of matching labels alone", () => {
const safe = "密钥状态已配置;Base URL 由平台托管;token 不会下发;RCON 数据不会投影。";
expect(safeDiagnosticText(safe)).toBe(safe);
});
});
+22
View File
@@ -0,0 +1,22 @@
const sensitiveAssignments = /\b(api[_-]?key|token|secret|password|passwd|credential|dsn|rcon(?:[_-]?(?:password|token))?)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi;
const privateEndpoint = /\b(?:https?|wss?):\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})(?::\d+)?[^\s"'<>]*/gi;
const hostPath = /(?:[A-Za-z]:\\|\/(?:Users|home|private|var|etc|opt|root|tmp)\/)[^\s"'<>]*/g;
export function safeDiagnosticText(value: string | undefined, fallback = "诊断信息已隐藏"): string | undefined {
if (!value) {
return value;
}
const sanitized = value
.replace(/Bearer\s+\S+/gi, "Bearer [redacted]")
.replace(/\b(?:sk|rk)-[A-Za-z0-9_-]{8,}\b/g, "[secret]")
.replace(/\bsecret:\/\/[^\s"'<>]+/gi, "secret://[redacted]")
.replace(/\b(?:postgres(?:ql)?|mysql|redis|mongodb(?:\+srv)?):\/\/[^\s"'<>]+/gi, "[dsn]")
.replace(/\b(?:unix|tcp):\/\/[^\s"'<>]+/gi, "[socket]")
.replace(privateEndpoint, "[private-endpoint]")
.replace(hostPath, "[host-path]")
.replace(/\bpid\s*[:=#]?\s*\d+\b/gi, "PID [redacted]")
.replace(sensitiveAssignments, (_match, label: string) => `${label}=[redacted]`)
.slice(0, 480)
.trim();
return sanitized || fallback;
}