Remove pre-1.0 production governance surfaces
This commit is contained in:
@@ -86,7 +86,7 @@ async function main() {
|
||||
throw new Error("AI provider response exposed Platform-owned endpoint or secret reference");
|
||||
}
|
||||
|
||||
const productionSeed = await prepareProductionOperations(authHeaders, server, plugin);
|
||||
const pluginSeed = await preparePluginOperations(authHeaders, server, plugin);
|
||||
|
||||
const chrome = await startChrome();
|
||||
const evidence = {
|
||||
@@ -114,7 +114,7 @@ async function main() {
|
||||
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"]),
|
||||
production: productionSeed.apiProof
|
||||
pluginOperations: pluginSeed.apiProof
|
||||
},
|
||||
routes: [],
|
||||
safety: {
|
||||
@@ -132,7 +132,7 @@ async function main() {
|
||||
{
|
||||
name: "首页",
|
||||
hash: "#/home",
|
||||
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU", "生产容量与告警", "运行槽位"]
|
||||
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU"]
|
||||
},
|
||||
{
|
||||
name: "服务器管理",
|
||||
@@ -165,12 +165,12 @@ async function main() {
|
||||
{
|
||||
name: "AI 提供商管理",
|
||||
hash: "#/aiProviders",
|
||||
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", productionSeed.diff.diffSummary, server.id]
|
||||
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", pluginSeed.diff.diffSummary, server.id]
|
||||
},
|
||||
{
|
||||
name: "系统维护",
|
||||
hash: "#/maintenance",
|
||||
markers: ["系统维护", "容量与告警闭环", "运行槽位", productionSeed.alert.title]
|
||||
markers: ["系统维护", "运行节点", "最近失败任务"]
|
||||
},
|
||||
{
|
||||
name: "服务器详情",
|
||||
@@ -202,10 +202,9 @@ async function main() {
|
||||
const pluginPage = await clickAndVerify(chrome, "概览", ["插件概览", "dev-game-plugin 页面 bundle", "server.instances.read"]);
|
||||
evidence.routes.push({ name: "服务器详情 / 插件声明页面", url: await chrome.url(), ...pluginPage });
|
||||
|
||||
evidence.productionInteractions = {
|
||||
alert: await verifyAlertInteraction(chrome, authHeaders, productionSeed.alert),
|
||||
evidence.pluginInteractions = {
|
||||
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
|
||||
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, productionSeed.diff)
|
||||
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, pluginSeed.diff)
|
||||
};
|
||||
|
||||
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
|
||||
@@ -558,7 +557,7 @@ async function ensureAiProvider(headers) {
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareProductionOperations(headers, server, plugin) {
|
||||
async function preparePluginOperations(headers, server, plugin) {
|
||||
const stamp = Date.now();
|
||||
const lifecycle = await postJson(
|
||||
`/plugin-lifecycles/${encodeURIComponent(plugin.id)}/actions`,
|
||||
@@ -589,99 +588,25 @@ async function prepareProductionOperations(headers, server, plugin) {
|
||||
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) {
|
||||
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),
|
||||
const [lifecycles, diffs] = await Promise.all([
|
||||
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}`);
|
||||
for (const [label, value] of Object.entries({ installation, diff, aiInvocation })) {
|
||||
assertNoForbiddenProjection(value, `plugin operations seed ${label}`);
|
||||
}
|
||||
return {
|
||||
alert,
|
||||
diff,
|
||||
apiProof: {
|
||||
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId"]),
|
||||
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"]),
|
||||
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId"]),
|
||||
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"]),
|
||||
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");
|
||||
@@ -719,7 +644,7 @@ async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server)
|
||||
}
|
||||
assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
|
||||
return {
|
||||
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "alertId"]),
|
||||
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId"]),
|
||||
forbiddenFragmentScan: "passed",
|
||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||
};
|
||||
|
||||
@@ -10,9 +10,6 @@ import type {
|
||||
AIInvocationResponse,
|
||||
AIConfigDiffApprovalResponse,
|
||||
AIConfigDiffListResponse,
|
||||
AlertListResponse,
|
||||
AlertResponse,
|
||||
AlertRetryResponse,
|
||||
ApiErrorResponse,
|
||||
ArtifactContentChunk,
|
||||
ArtifactDownloadReferenceResponse,
|
||||
@@ -63,8 +60,6 @@ import type {
|
||||
MarketplacePluginResponse,
|
||||
MarketplacePluginStateRequest,
|
||||
PlatformResourceUsageResponse,
|
||||
ProductionCapacitySummaryResponse,
|
||||
CapacityAdmissionDecisionResponse,
|
||||
PluginLifecycleActionRequest,
|
||||
PluginLifecycleActionResponse,
|
||||
PluginLifecycleListResponse,
|
||||
@@ -506,33 +501,6 @@ 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); });
|
||||
|
||||
+3
-13
@@ -2,33 +2,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PlatformApiClient } from "./client";
|
||||
|
||||
describe("PlatformApiClient production operations", () => {
|
||||
describe("PlatformApiClient plugin operations", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("uses Platform-only operations 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" } });
|
||||
return new Response(JSON.stringify({ items: [], count: 0, status: "queued", installation: {}, job: {} }), { 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",
|
||||
@@ -36,6 +26,6 @@ describe("PlatformApiClient production operations", () => {
|
||||
]);
|
||||
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 });
|
||||
expect(calls[1]?.body).toEqual({ serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false });
|
||||
});
|
||||
});
|
||||
@@ -1592,78 +1592,6 @@ export interface PluginProductionLifecycleDeclaration {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
@@ -1678,14 +1606,13 @@ export interface PluginLifecycleInstallationResponse {
|
||||
compatibility?: string;
|
||||
dependencyState?: string;
|
||||
jobId?: string;
|
||||
alertId?: 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 PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; }
|
||||
|
||||
export interface AIConfigDiffPreviewResponse {
|
||||
id: string;
|
||||
|
||||
@@ -38,7 +38,7 @@ export function OperationsTray({ operations }: OperationsTrayProps) {
|
||||
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
|
||||
<div className="operations-tray-heading">
|
||||
<strong>当前浏览器会话</strong>
|
||||
<span>持久任务与告警记录以 Platform 页面为准</span>
|
||||
<span>持久任务记录以 Platform 页面为准</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="operations-tray-empty">本会话尚未提交资源变更。</p>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
|
||||
idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`,
|
||||
confirmed: disruptiveOperations.includes(operation)
|
||||
});
|
||||
const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · ");
|
||||
const evidence = [response.job?.id && `任务 ${response.job.id}`].filter(Boolean).join(" · ");
|
||||
setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}:${response.status}${evidence ? ` · ${evidence}` : ""}` });
|
||||
setConfirming(false);
|
||||
await refresh();
|
||||
@@ -102,7 +102,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
|
||||
<div className="console-record-head"><strong>{installation.currentState} → {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
|
||||
<div className="console-record-meta">
|
||||
<span>当前 {installation.currentVersion || "--"}</span><span>目标 {installation.targetVersion || "--"}</span><span>依赖 {installation.dependencyState || "unknown"}</span>
|
||||
{installation.jobId && <span>任务 {installation.jobId}</span>}{installation.alertId && <span>告警 {installation.alertId}</span>}
|
||||
{installation.jobId && <span>任务 {installation.jobId}</span>}
|
||||
</div>
|
||||
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
|
||||
</div>
|
||||
|
||||
+4
-6
@@ -2,21 +2,19 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
|
||||
import { ProductionOperationsPanel } from "./ProductionOperationsPanel";
|
||||
import operationsSource from "./ProductionOperationsPanel.tsx?raw";
|
||||
import { PluginLifecycleWorkbench } from "./PluginLifecycleWorkbench";
|
||||
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
|
||||
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
|
||||
|
||||
describe("production operations components", () => {
|
||||
describe("plugin operations components", () => {
|
||||
it("renders persisted loading states without optimistic terminal success", () => {
|
||||
expect(renderToStaticMarkup(<ProductionOperationsPanel />)).toContain("正在同步容量与告警");
|
||||
expect(renderToStaticMarkup(<PluginLifecycleWorkbench pluginId="game.example" pluginName="Example" />)).toContain("正在同步插件生命周期");
|
||||
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
|
||||
for (const source of [operationsSource, lifecycleSource, diffSource]) {
|
||||
for (const source of [lifecycleSource, diffSource]) {
|
||||
expect(source).not.toContain("setTimeout");
|
||||
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
||||
expect(source).toContain("disabled=");
|
||||
}
|
||||
expect(operationsSource).toContain("if (!intent || busyKey) return");
|
||||
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
|
||||
expect(diffSource).toContain("if (!selected || busyId) return");
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
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 ProductionOperationsPanelProps {
|
||||
compact?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ProductionOperationsPanel({ compact = false, title = "容量与告警" }: ProductionOperationsPanelProps) {
|
||||
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 console-module production-operations-panel" aria-label="production capacity and alerts">
|
||||
<div className="panel-header">
|
||||
<h2><AlertTriangle size={16} /> {title}</h2>
|
||||
<button type="button" className="icon-command" disabled={loading || Boolean(busyKey)} onClick={() => void refresh()} title="刷新容量与告警">
|
||||
<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-operations" onRetry={() => void refresh()} compact />}
|
||||
{!loading && !error && capacity && (
|
||||
<>
|
||||
<dl className="console-stat-strip console-stat-strip-spaced">
|
||||
<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="console-row-list" aria-label="capacity endpoints">
|
||||
{visibleEndpoints.map((endpoint) => (
|
||||
<div key={endpoint.runEndpointId} className="console-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="console-record-list console-record-list-spaced production-alert-list" aria-label="durable alerts">
|
||||
{visibleAlerts.length === 0 && <p className="console-empty-note">当前没有持久告警。</p>}
|
||||
{visibleAlerts.map((alert) => {
|
||||
const pending = busyKey.startsWith(`${alert.id}:`);
|
||||
return (
|
||||
<div key={alert.id} className="console-record operation-item">
|
||||
<div className="console-record-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="console-record-meta">
|
||||
<span>{alert.sourceKind} · {alert.sourceId}</span>
|
||||
<span>发生 {alert.occurrenceCount} 次</span>
|
||||
{alert.lastJobId && <span>任务 {alert.lastJobId}</span>}
|
||||
</div>
|
||||
{alert.state !== "resolved" && (
|
||||
<div className="row-actions console-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 "重试来源";
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
ServerMetricsResponse
|
||||
} from "../api/types";
|
||||
import { UsageMeter } from "../components/OperationControls";
|
||||
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
@@ -278,9 +277,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<ProductionOperationsPanel compact title="生产容量与告警" />
|
||||
|
||||
<section className="overview-two-col">
|
||||
<article className="console-panel" aria-label="resource usage">
|
||||
<div className="panel-header">
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { cx } from "../utils/classes";
|
||||
@@ -126,9 +125,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
||||
</section>
|
||||
|
||||
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
||||
|
||||
<ProductionOperationsPanel title="容量与告警闭环" />
|
||||
|
||||
<section className="console-panel" aria-label="run endpoints">
|
||||
<div className="panel-header">
|
||||
<h2>运行节点</h2>
|
||||
|
||||
@@ -10,11 +10,10 @@ function compact(value) {
|
||||
}
|
||||
|
||||
describe("platform web shared theme CSS", () => {
|
||||
it("keeps production lifecycle and alert controls bounded at narrow width", () => {
|
||||
it("keeps plugin lifecycle controls bounded at narrow width", () => {
|
||||
const css = compact(readThemeCss());
|
||||
|
||||
expect(css).toContain("@media(max-width:640px){.plugin-lifecycle-controls{grid-template-columns:minmax(0,1fr)}");
|
||||
expect(css).toContain(".production-alert-actions,.production-alert-actionsbutton{width:100%}");
|
||||
expect(css).toContain("overflow-wrap:anywhere");
|
||||
});
|
||||
|
||||
|
||||
@@ -589,17 +589,15 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.console-row-actions .theme-upload,.maintenance-actions .theme-upload,.user-actions .theme-upload{min-height:30px}
|
||||
.console-record-list,.operation-list{display:grid;gap:10px}
|
||||
.console-record,.operation-item{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim);position:relative;overflow:hidden;min-width:0}
|
||||
.ai-diff-review-panel,.production-operations-panel{margin-block:14px}
|
||||
.console-stat-strip-spaced,.production-capacity-strip{margin-bottom:12px}
|
||||
.console-record-list-spaced,.production-alert-list{margin-top:12px}
|
||||
.plugin-lifecycle-controls,.production-alert-actions{flex-wrap:wrap}
|
||||
.ai-diff-review-panel{margin-block:14px}
|
||||
.console-stat-strip-spaced{margin-bottom:12px}
|
||||
.console-record-list-spaced{margin-top:12px}
|
||||
.plugin-lifecycle-controls{flex-wrap:wrap}
|
||||
.plugin-lifecycle-workbench{display:grid;gap:10px;padding-block:10px;border-block:1px solid color-mix(in srgb,var(--frame-accent) 34%,transparent)}
|
||||
.plugin-lifecycle-controls{display:grid;grid-template-columns:minmax(180px,1.4fr) minmax(132px,0.8fr) minmax(120px,0.8fr) auto;align-items:center}
|
||||
.ai-config-proposal,.plugin-lifecycle-state{min-width:0}
|
||||
.ai-config-proposal{max-height:280px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
@media (max-width:640px){.plugin-lifecycle-controls{grid-template-columns:minmax(0,1fr)}
|
||||
.production-alert-actions,.production-alert-actions button{width:100%}
|
||||
}
|
||||
@media (max-width:640px){.plugin-lifecycle-controls{grid-template-columns:minmax(0,1fr)}}
|
||||
.console-record-head,.operation-item-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
|
||||
.console-record-head>strong,.operation-item-head>strong{min-width:0;overflow-wrap:anywhere}
|
||||
.console-record-head strong,.operation-item-head strong{color:var(--ink)}
|
||||
|
||||
Reference in New Issue
Block a user