功能修改
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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]]));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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 workspace,Platform 保留 build、artifact 与审计历史。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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: "公告内容必须为 1–500 个字符。" });
|
||||
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="输入 1–500 字公告" 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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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("缺少服务器实例上下文");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" ? "停用" : "启用";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }} />);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+896
-51
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user