Remove pre-1.0 audit and protected request scaffolding
This commit is contained in:
@@ -78,7 +78,7 @@ async function main() {
|
||||
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(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback operation");
|
||||
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
|
||||
assertEqual(aiProvider.apiKeyConfigured, true, "AI provider key presence projection");
|
||||
assertEqual(aiProvider.baseUrlConfigured, true, "AI provider base URL presence projection");
|
||||
@@ -170,7 +170,7 @@ async function main() {
|
||||
{
|
||||
name: "系统维护",
|
||||
hash: "#/maintenance",
|
||||
markers: ["系统维护", "容量治理与告警闭环", "运行槽位", productionSeed.alert.title]
|
||||
markers: ["系统维护", "容量与告警闭环", "运行槽位", productionSeed.alert.title]
|
||||
},
|
||||
{
|
||||
name: "服务器详情",
|
||||
@@ -598,7 +598,7 @@ async function prepareProductionOperations(headers, server, plugin) {
|
||||
},
|
||||
headers
|
||||
);
|
||||
if (admission.accepted || admission.state !== "denied" || !admission.alertId || !admission.auditEventId) {
|
||||
if (admission.accepted || admission.state !== "denied" || !admission.alertId) {
|
||||
throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`);
|
||||
}
|
||||
|
||||
@@ -622,13 +622,13 @@ async function prepareProductionOperations(headers, server, plugin) {
|
||||
alert,
|
||||
diff,
|
||||
apiProof: {
|
||||
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId", "auditEventId"]),
|
||||
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", "lastAuditEventId"]),
|
||||
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId", "auditEventId"]),
|
||||
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"])
|
||||
}
|
||||
};
|
||||
@@ -676,7 +676,7 @@ async function verifyAlertInteraction(chrome, headers, seededAlert) {
|
||||
assertNoForbiddenProjection(acknowledged, "acknowledged alert response");
|
||||
return {
|
||||
cancelPreservedState: stillActive.state,
|
||||
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt", "lastAuditEventId"]),
|
||||
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt"]),
|
||||
forbiddenFragmentScan: "passed",
|
||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||
};
|
||||
@@ -714,12 +714,12 @@ async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server)
|
||||
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)}`);
|
||||
if (!installation.jobId) {
|
||||
throw new Error(`browser lifecycle dispatch missed job linkage: ${JSON.stringify(installation)}`);
|
||||
}
|
||||
assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
|
||||
return {
|
||||
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "auditEventId", "alertId"]),
|
||||
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "alertId"]),
|
||||
forbiddenFragmentScan: "passed",
|
||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||
};
|
||||
|
||||
@@ -312,7 +312,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/process/status") && init?.method === "POST") {
|
||||
return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", auditSummary: "private supervised process identity" } } });
|
||||
return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", summary: "private supervised process identity" } } });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) {
|
||||
return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 });
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
ArtifactFilterRequest,
|
||||
ArtifactListResponse,
|
||||
AuthSessionResponse,
|
||||
AuditEventListResponse,
|
||||
ClientManagerBuildRequest,
|
||||
ClientManagerControlRequest,
|
||||
ClientManagerDeployRequest,
|
||||
@@ -95,6 +94,8 @@ import type {
|
||||
ServerMemberListResponse,
|
||||
ServerMemberRequest,
|
||||
ServerMetricsListResponse,
|
||||
SourceRCONCommandRequest,
|
||||
SourceRCONCommandResponse,
|
||||
MetricSampleListResponse,
|
||||
BackupListResponse,
|
||||
BackupResponse,
|
||||
@@ -405,6 +406,10 @@ export class PlatformApiClient {
|
||||
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands`, { method: "POST", body: request }));
|
||||
}
|
||||
|
||||
async dispatchSourceRCONCommand(id: string, request: SourceRCONCommandRequest): Promise<SourceRCONCommandResponse> {
|
||||
return this.request<SourceRCONCommandResponse>(`/server-instances/${encodeURIComponent(id)}/rcon/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)}`));
|
||||
}
|
||||
@@ -627,10 +632,6 @@ export class PlatformApiClient {
|
||||
return this.request<LogStreamCursorResponse>("/log-streams/query", { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async listAuditEvents(): Promise<AuditEventListResponse> {
|
||||
return this.request<AuditEventListResponse>("/audit-events");
|
||||
}
|
||||
|
||||
async suggestServerConfig(request: LlmConfigSuggestionRequest): Promise<LlmConfigSuggestionResponse> {
|
||||
return this.request<LlmConfigSuggestionResponse>("/ai/config-suggestions", { method: "POST", body: request });
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
||||
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details.
|
||||
- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and durable lifecycle status. Job projections include `retrying`, attempt/max-attempt counts, next retry timing, safe ack/lease deadlines, cancellation timestamps/reason, terminal time, and reconciliation outcome/count.
|
||||
- `getDependencyCatalog` reads `GET /server-instances/{id}/dependencies` and returns only target-matched probe state/evidence, typed plan step summaries, approved download hosts, and immutable SHA-256 `planDigest` values. Install requests must submit the selected digest; the browser never receives bindings, commands, paths, credentials, tokens, or private download refs.
|
||||
- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded audit message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health.
|
||||
- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded status message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health.
|
||||
- `listMetricHistory`, `listBackups`, and `getBackup` read bounded owner-scoped metric and backup projections. Backup responses contain artifact IDs/checksums and recovery/retention state only; they never include body bytes or storage paths.
|
||||
- `listRemoteAdapters` and `requestRemoteAdapter` use declaration-backed logical target keys and return queued status/result references. The browser never receives adapter credentials, host addresses, sockets, Run tokens, leases, session hashes, or secret refs.
|
||||
- Server management DTOs may include bounded `ownerUserId` and `adminUserIds` metadata, but must not include raw run credentials, host paths, direct socket details, user password hashes, or AI provider keys.
|
||||
@@ -43,7 +43,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
||||
|
||||
## Redesign Contract Gaps (redesign-platform-web-interactions)
|
||||
|
||||
Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, audit events, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently:
|
||||
Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently:
|
||||
|
||||
- `POST /api/v1/auth/register` (`RegisterRequest`/`AuthSessionResponse`): visitor registration. Implemented: the first registered user becomes an active platform administrator; later self-registered users become pending server-scoped users and do not receive platform administrator privileges.
|
||||
- `POST /api/v1/auth/login` (`LoginRequest`/`AuthSessionResponse`), `POST /api/v1/auth/rotate`, and `POST /api/v1/auth/logout`: implemented bounded, durable bearer session lifecycle for authenticated workspace entry.
|
||||
@@ -56,7 +56,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
||||
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
|
||||
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
|
||||
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
|
||||
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||
|
||||
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.
|
||||
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted SSE history/live output. Raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients; internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
|
||||
|
||||
@@ -16,7 +16,7 @@ const status = {
|
||||
profileKey: "scum-client",
|
||||
available: false,
|
||||
reason: "component heartbeat is unavailable",
|
||||
commandTypes: ["scum.announcement.send"],
|
||||
commandTypes: ["scum.diagnostic.ping"],
|
||||
snapshotTypes: ["scum.players"],
|
||||
queryTemplateKeys: ["scum.player.search"]
|
||||
}]
|
||||
@@ -27,12 +27,11 @@ const pendingCommand = {
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.scum",
|
||||
profileKey: "scum-client",
|
||||
commandType: "scum.announcement.send",
|
||||
commandType: "scum.diagnostic.ping",
|
||||
priority: 20,
|
||||
state: "pending",
|
||||
approvalState: "pending",
|
||||
requesterId: "user-1",
|
||||
auditReferences: ["audit-command-1"],
|
||||
expiresAt: later,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -42,10 +41,10 @@ const completedCommand = {
|
||||
...pendingCommand,
|
||||
state: "succeeded",
|
||||
approvalState: "approved",
|
||||
resultSummary: "announcement delivered",
|
||||
resultSummary: "diagnostic completed",
|
||||
result: {
|
||||
status: "succeeded",
|
||||
summary: "announcement delivered",
|
||||
summary: "diagnostic completed",
|
||||
payload: { delivered: true, recipientCount: 12 },
|
||||
completedAt: later
|
||||
},
|
||||
@@ -57,7 +56,6 @@ 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;
|
||||
|
||||
@@ -73,19 +71,18 @@ const snapshot = {
|
||||
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",
|
||||
type: "scum.diagnostic.ping",
|
||||
title: "Diagnostic ping",
|
||||
permission: "server.game-client.command",
|
||||
approvalLevel: "operator",
|
||||
payloadSchemaRef: "schemas/bridge/commands/announcement.request.json",
|
||||
resultSchemaRef: "schemas/bridge/commands/announcement.result.json",
|
||||
payloadSchemaRef: "schemas/bridge/commands/diagnostic-ping.request.json",
|
||||
resultSchemaRef: "schemas/bridge/commands/diagnostic-ping.result.json",
|
||||
timeoutSeconds: 30,
|
||||
maxPayloadBytes: 4096
|
||||
}],
|
||||
@@ -104,7 +101,7 @@ const manifestDeclaration: GameClientBridgeManifestResponse = {
|
||||
}],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "operations", commandTypes: ["scum.announcement.send"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }],
|
||||
pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }],
|
||||
companion: {
|
||||
profileKey: "scum-client-manager",
|
||||
configTemplateKey: "client-config",
|
||||
@@ -151,15 +148,15 @@ describe("PlatformApiClient Game Client Bridge operator API", () => {
|
||||
const client = new PlatformApiClient("/api/v1", () => "operator-session");
|
||||
const queueRequest: GameClientBridgeQueueRequest = {
|
||||
profileKey: "scum-client",
|
||||
commandType: "scum.announcement.send",
|
||||
commandType: "scum.diagnostic.ping",
|
||||
payload: { message: "Restart in ten minutes", channels: ["global"] },
|
||||
idempotencyKey: "announcement-1",
|
||||
idempotencyKey: "diagnostic-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.listGameClientBridgeCommands("server-1", { profileKey: "scum-client", state: "pending", commandType: "scum.diagnostic.ping" })).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" });
|
||||
@@ -167,7 +164,7 @@ describe("PlatformApiClient Game Client Bridge operator API", () => {
|
||||
|
||||
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",
|
||||
"GET /api/v1/server-instances/server-1/game-client-bridge/commands?profileKey=scum-client&state=pending&commandType=scum.diagnostic.ping",
|
||||
"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",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PlatformApiClient } from "./client";
|
||||
describe("PlatformApiClient production operations", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("uses Platform-only governance routes and bounded request bodies", async () => {
|
||||
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 });
|
||||
|
||||
@@ -146,7 +146,6 @@ export interface GameClientBridgeCommandResponse {
|
||||
resultSummary?: string;
|
||||
result?: GameClientBridgeCommandResultResponse;
|
||||
cancellation?: GameClientBridgeCommandCancellationResponse;
|
||||
auditReferences?: string[];
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -181,7 +180,6 @@ export interface GameClientBridgeCancelResponse {
|
||||
commandId: string;
|
||||
state: GameClientBridgeCommandState;
|
||||
cancellation: GameClientBridgeCommandCancellationResponse;
|
||||
auditReferences?: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -202,7 +200,6 @@ export interface GameClientBridgeSnapshotResponse {
|
||||
observedAt: string;
|
||||
payload: GameClientBridgeJsonObject;
|
||||
retention: GameClientBridgeRetentionResponse;
|
||||
auditReferences?: string[];
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
@@ -695,7 +692,7 @@ export interface JobExecutionResultResponse {
|
||||
version?: number;
|
||||
checksum?: string;
|
||||
sizeBytes?: number;
|
||||
auditSummary?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface JobListResponse {
|
||||
@@ -1537,22 +1534,6 @@ export interface LogStreamEventOptions {
|
||||
historyLimit?: number;
|
||||
}
|
||||
|
||||
export interface AuditEventResponse {
|
||||
id: string;
|
||||
actorId: string;
|
||||
action: string;
|
||||
resourceKind: string;
|
||||
resourceId: string;
|
||||
result: string;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditEventListResponse {
|
||||
items: AuditEventResponse[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface JobCreateRequest {
|
||||
id: string;
|
||||
serverInstanceId?: string;
|
||||
@@ -1628,7 +1609,6 @@ export interface CapacityAdmissionDecisionResponse {
|
||||
pressureCodes?: string[];
|
||||
checkedAt: string;
|
||||
alertId?: string;
|
||||
auditEventId?: string;
|
||||
}
|
||||
|
||||
export interface EndpointCapacityProjectionResponse {
|
||||
@@ -1672,7 +1652,6 @@ export interface AlertResponse {
|
||||
retryable: boolean;
|
||||
retryAfterSeconds?: number;
|
||||
lastJobId?: string;
|
||||
lastAuditEventId?: string;
|
||||
lastSeenAt: string;
|
||||
acknowledgedBy?: string;
|
||||
acknowledgedAt?: string;
|
||||
@@ -1700,7 +1679,6 @@ export interface PluginLifecycleInstallationResponse {
|
||||
dependencyState?: string;
|
||||
jobId?: string;
|
||||
alertId?: string;
|
||||
auditEventId?: string;
|
||||
failureReason?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -181,7 +181,7 @@ function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmComman
|
||||
{(item.retryable || item.requiresRedeploy || item.status === "failed") && (
|
||||
<div className="client-manager-recovery">
|
||||
<ShieldAlert size={16} />
|
||||
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 Platform 审计与 job 失败原因后选择重新部署、回滚或卸载。"}</span>
|
||||
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 job 失败原因后选择重新部署、回滚或卸载。"}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -196,7 +196,7 @@ function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmComman
|
||||
<LifecycleButton icon={<RefreshCw size={14} />} label="重试" disabled={!item.retryable} reason="当前失败不可重试" onClick={() => void runCommand(item, "重试 Client Manager", () => platformApiClient.retryClientManagerLifecycle(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("retry") }))} />
|
||||
<LifecycleButton icon={<Ban size={14} />} label="撤销会话" disabled={!item.activeArtifactId || item.status === "uninstalled"} reason="组件尚未安装" onClick={() => confirmCommand({ title: "撤销 Client Manager 会话", description: `撤销 ${item.profileKey} 的独立组件 session。Run session 与 job lease 不受影响,组件必须使用当前 key generation 重新注册。`, danger: true, execute: () => runCommand(item, "撤销 Client Manager 会话", () => platformApiClient.revokeClientManagerSession(item.serverInstanceId, { profileKey: item.profileKey, reason: "operator revoked component session" })) })} />
|
||||
<LifecycleButton icon={<KeyRound size={14} />} label="重置密钥" disabled={item.status === "uninstalled"} reason="已卸载" onClick={() => confirmCommand({ title: "重置 Client Manager 密钥", description: `重置 ${item.profileKey} 的 component key 会撤销旧 session/artifact generation。必须重新构建并重新部署,不会显示或导出原始密钥。`, danger: true, execute: async () => { await platformApiClient.resetClientManagerKey(item.serverInstanceId, { componentKind: "client-manager", componentKey: item.profileKey }); await runCommand(item, "刷新密钥重置状态", () => platformApiClient.getClientManagerLifecycle(item.serverInstanceId, item.profileKey)); } })} />
|
||||
<LifecycleButton icon={<Trash2 size={14} />} label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName} 的 ${item.profileKey}?Run 只会清理受控 Client Manager workspace,Platform 保留 build、artifact 与审计历史。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
|
||||
<LifecycleButton icon={<Trash2 size={14} />} label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName} 的 ${item.profileKey}?Run 只会清理 Client Manager workspace,Platform 保留 build 与 artifact 记录。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("shared operation dialogs", () => {
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="确认停用"
|
||||
description="会保留审计记录"
|
||||
description="该操作会更新用户状态"
|
||||
confirmLabel="停用"
|
||||
busy
|
||||
onConfirm={() => undefined}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function OperationsTray({ operations }: OperationsTrayProps) {
|
||||
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
|
||||
<div className="operations-tray-heading">
|
||||
<strong>当前浏览器会话</strong>
|
||||
<span>持久任务与审计记录以 Platform 页面为准</span>
|
||||
<span>持久任务与告警记录以 Platform 页面为准</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="operations-tray-empty">本会话尚未提交资源变更。</p>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
|
||||
idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`,
|
||||
confirmed: disruptiveOperations.includes(operation)
|
||||
});
|
||||
const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.auditEventId && `审计 ${response.installation.auditEventId}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · ");
|
||||
const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · ");
|
||||
setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}:${response.status}${evidence ? ` · ${evidence}` : ""}` });
|
||||
setConfirming(false);
|
||||
await refresh();
|
||||
@@ -102,7 +102,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
|
||||
<div className="console-record-head"><strong>{installation.currentState} → {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
|
||||
<div className="console-record-meta">
|
||||
<span>当前 {installation.currentVersion || "--"}</span><span>目标 {installation.targetVersion || "--"}</span><span>依赖 {installation.dependencyState || "unknown"}</span>
|
||||
{installation.jobId && <span>任务 {installation.jobId}</span>}{installation.auditEventId && <span>审计 {installation.auditEventId}</span>}{installation.alertId && <span>告警 {installation.alertId}</span>}
|
||||
{installation.jobId && <span>任务 {installation.jobId}</span>}{installation.alertId && <span>告警 {installation.alertId}</span>}
|
||||
</div>
|
||||
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,21 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
|
||||
import { ProductionGovernancePanel } from "./ProductionGovernancePanel";
|
||||
import governanceSource from "./ProductionGovernancePanel.tsx?raw";
|
||||
import { ProductionOperationsPanel } from "./ProductionOperationsPanel";
|
||||
import operationsSource from "./ProductionOperationsPanel.tsx?raw";
|
||||
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
|
||||
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
|
||||
|
||||
describe("production operations components", () => {
|
||||
it("renders persisted loading states without optimistic terminal success", () => {
|
||||
expect(renderToStaticMarkup(<ProductionGovernancePanel />)).toContain("正在同步容量与告警");
|
||||
expect(renderToStaticMarkup(<ProductionOperationsPanel />)).toContain("正在同步容量与告警");
|
||||
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
|
||||
for (const source of [governanceSource, lifecycleSource, diffSource]) {
|
||||
for (const source of [operationsSource, lifecycleSource, diffSource]) {
|
||||
expect(source).not.toContain("setTimeout");
|
||||
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
||||
expect(source).toContain("disabled=");
|
||||
}
|
||||
expect(governanceSource).toContain("if (!intent || busyKey) return");
|
||||
expect(operationsSource).toContain("if (!intent || busyKey) return");
|
||||
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
|
||||
expect(diffSource).toContain("if (!selected || busyId) return");
|
||||
});
|
||||
|
||||
+5
-6
@@ -9,12 +9,12 @@ import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type AlertAction = "acknowledge" | "resolve" | "retry";
|
||||
|
||||
interface ProductionGovernancePanelProps {
|
||||
interface ProductionOperationsPanelProps {
|
||||
compact?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ProductionGovernancePanel({ compact = false, title = "容量与告警" }: ProductionGovernancePanelProps) {
|
||||
export function ProductionOperationsPanel({ compact = false, title = "容量与告警" }: ProductionOperationsPanelProps) {
|
||||
const [capacity, setCapacity] = useState<ProductionCapacitySummaryResponse | null>(null);
|
||||
const [alerts, setAlerts] = useState<AlertResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -31,7 +31,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
|
||||
setCapacity(capacityResponse);
|
||||
setAlerts(alertResponse.items);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "生产治理状态加载失败");
|
||||
setError(caught instanceof Error ? caught.message : "生产状态加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
|
||||
const visibleEndpoints = compact ? capacity?.endpoints.slice(0, 3) ?? [] : capacity?.endpoints ?? [];
|
||||
|
||||
return (
|
||||
<section className="console-panel console-module production-governance-panel" aria-label="production capacity and alerts">
|
||||
<section className="console-panel console-module production-operations-panel" aria-label="production capacity and alerts">
|
||||
<div className="panel-header">
|
||||
<h2><AlertTriangle size={16} /> {title}</h2>
|
||||
<button type="button" className="icon-command" disabled={loading || Boolean(busyKey)} onClick={() => void refresh()} title="刷新容量与告警">
|
||||
@@ -79,7 +79,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
|
||||
</div>
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
{loading && <LoadingState label="正在同步容量与告警…" compact />}
|
||||
{!loading && error && <ErrorState title="生产治理状态不可用" reason={error} diagnosticId="production-governance" onRetry={() => void refresh()} compact />}
|
||||
{!loading && error && <ErrorState title="生产状态不可用" reason={error} diagnosticId="production-operations" onRetry={() => void refresh()} compact />}
|
||||
{!loading && !error && capacity && (
|
||||
<>
|
||||
<dl className="console-stat-strip console-stat-strip-spaced">
|
||||
@@ -111,7 +111,6 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
|
||||
<span>{alert.sourceKind} · {alert.sourceId}</span>
|
||||
<span>发生 {alert.occurrenceCount} 次</span>
|
||||
{alert.lastJobId && <span>任务 {alert.lastJobId}</span>}
|
||||
{alert.lastAuditEventId && <span>审计 {alert.lastAuditEventId}</span>}
|
||||
</div>
|
||||
{alert.state !== "resolved" && (
|
||||
<div className="row-actions console-row-actions production-alert-actions">
|
||||
@@ -87,7 +87,7 @@ export const runtimeKeyResetStages: RuntimeTaskStage[] = [
|
||||
export const runtimeDependencyStages: RuntimeTaskStage[] = [
|
||||
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
|
||||
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
|
||||
{ key: "install_prepare", label: "安装环境", description: "准备安全、可审计的依赖安装任务。" },
|
||||
{ key: "install_prepare", label: "安装环境", description: "准备依赖安装任务。" },
|
||||
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
|
||||
];
|
||||
|
||||
|
||||
@@ -111,14 +111,14 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
|
||||
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong>创建基础信息</strong><span>插件决定下一步显示哪些部署方式和游戏参数。</span></div><div className="workflow-hint-card"><strong>配置启动项</strong><span>新建安装、接管已有和自定义启动分别填写自己的字段。</span></div><div className="workflow-hint-card"><strong>平台构建 Run 包</strong><span>平台在自有构建器中打包,Run 启动后自动上报心跳。</span></div></div>
|
||||
<div className="form-grid"><label>插件类型<select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label>服务器名称<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
|
||||
</div>}
|
||||
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy">选择这台服务器的创建方式;下一步只显示该方式需要的启动项。</p>{isScum && <div className="form-guidance"><strong>SCUM 受控链路</strong><span>Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。</span></div>}<div className="deployment-mode-grid">
|
||||
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy">选择这台服务器的创建方式;下一步只显示该方式需要的启动项。</p>{isScum && <div className="form-guidance"><strong>SCUM 自动部署链路</strong><span>Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。</span></div>}<div className="deployment-mode-grid">
|
||||
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
|
||||
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
|
||||
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由 Run 策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
|
||||
</div></div>}
|
||||
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong>已读取受保护配置</strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}>重试读取</button></>}</div>}<div className="form-grid">
|
||||
{kind === "edit" && <label>部署方式<select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install">新建并安装</option><option value="existing-server">接管已有服务器</option><option value="custom-command">自定义启动方式</option></select><small className="field-help">可在此调整部署方式;Run 会按心跳自动识别服务器。</small></label>}
|
||||
{form.deploymentMode === "guided-install" && <label>安装目录{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">新建服务器的安装目标;SCUM 受控模板必须明确安装目录。</small></label>}
|
||||
{form.deploymentMode === "guided-install" && <label>安装目录{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">新建服务器的安装目标;SCUM 部署模板必须明确安装目录。</small></label>}
|
||||
{form.deploymentMode === "existing-server" && <label>已有服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run 会先预检目录、插件与端口;不会重装或覆盖现有游戏配置。</small></label>}
|
||||
{form.deploymentMode === "custom-command" && <label>服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help">服务器文件、数据与配置的主目录。</small></label>}
|
||||
{form.deploymentMode === "guided-install" && pluginFields.map((field) => (
|
||||
@@ -150,7 +150,7 @@ function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum:
|
||||
] : [
|
||||
{ icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" },
|
||||
{ icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" },
|
||||
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给受控部署流程。" },
|
||||
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给自动部署流程。" },
|
||||
{ icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" }
|
||||
];
|
||||
|
||||
@@ -162,15 +162,15 @@ function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string
|
||||
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写包含 SCUM 服务端文件、数据与配置的目录,不是 Steam 库或 SteamCMD 目录。" },
|
||||
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、可执行文件、版本、Steam App 标记和所需端口。" },
|
||||
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "识别 ServerSettings.ini 与现有参数;接管不会写入或覆盖它们。" },
|
||||
{ icon: ServerCog, title: "建立受控生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走受控通道。" },
|
||||
{ icon: ServerCog, title: "建立生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走平台通道。" },
|
||||
{ icon: HeartPulse, title: "健康验证", copy: "确认端口、进程与配置可读后,才标记为接管成功。" }
|
||||
] : [
|
||||
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" },
|
||||
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" },
|
||||
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" },
|
||||
{ icon: ServerCog, title: "建立受控生命周期", copy: "后续运行操作由自动识别的 Run 通过平台通道执行。" },
|
||||
{ icon: ServerCog, title: "建立生命周期", copy: "后续运行操作由自动识别的 Run 通过平台通道执行。" },
|
||||
{ icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" }
|
||||
];
|
||||
|
||||
return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>确认后,{pluginName} 会这样接管</strong><span>目录只会交给 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。</span></div><small>先扫描,后自动识别</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM 与 SteamCMD:</strong>接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。<br /><strong>升级:</strong>接管不会升级游戏;当前平台尚未提供 SCUM 服务端的受控升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。</p> : <p><strong>不会做:</strong>不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。</p>}</section>;
|
||||
return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>确认后,{pluginName} 会这样接管</strong><span>目录只会交给 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。</span></div><small>先扫描,后自动识别</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM 与 SteamCMD:</strong>接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。<br /><strong>升级:</strong>接管不会升级游戏;当前平台尚未提供 SCUM 服务端的自动升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。</p> : <p><strong>不会做:</strong>不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。</p>}</section>;
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
||||
import type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types";
|
||||
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getGameClientBridgeCommand: vi.fn(),
|
||||
dispatchSourceRCONCommand: vi.fn(),
|
||||
getJob: vi.fn(),
|
||||
listLogStreams: vi.fn(),
|
||||
openServerLogEvents: vi.fn(),
|
||||
queryLogStream: vi.fn(),
|
||||
queueGameClientBridgeCommand: vi.fn()
|
||||
queryLogStream: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../api/client", () => ({ platformApiClient: apiMocks }));
|
||||
@@ -85,7 +85,7 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1);
|
||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
|
||||
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
|
||||
@@ -97,7 +97,7 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
|
||||
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
|
||||
expect(container?.textContent).not.toContain("legacy output must stay historical");
|
||||
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears generation A on a new session and rejects late generation A events", async () => {
|
||||
@@ -166,18 +166,18 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
expect(container?.textContent).not.toContain("selected historical output");
|
||||
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
|
||||
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 });
|
||||
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses RCON only after an operator submits a command", async () => {
|
||||
const pending = bridgeCommand("pending");
|
||||
const succeeded = bridgeCommand("succeeded");
|
||||
apiMocks.queueGameClientBridgeCommand.mockResolvedValue(pending);
|
||||
apiMocks.getGameClientBridgeCommand.mockResolvedValue(succeeded);
|
||||
const pending = sourceRCONDispatch("queued");
|
||||
const succeeded = jobResponse("succeeded");
|
||||
apiMocks.dispatchSourceRCONCommand.mockResolvedValue(pending);
|
||||
apiMocks.getJob.mockResolvedValue(succeeded);
|
||||
await renderDrawer();
|
||||
await emitSession("session-current");
|
||||
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output"));
|
||||
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||
|
||||
const input = container?.querySelector<HTMLInputElement>('.terminal-command-form input');
|
||||
const form = container?.querySelector<HTMLFormElement>('.terminal-command-form');
|
||||
@@ -186,8 +186,9 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
await act(async () => form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })));
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", pending.id);
|
||||
expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledWith("server-1", expect.objectContaining({ kind: "command", command: "#ListPlayers" }));
|
||||
expect(apiMocks.getJob).toHaveBeenCalledWith(pending.jobId);
|
||||
expect(container?.textContent).toContain("ordinary live output");
|
||||
});
|
||||
});
|
||||
@@ -246,10 +247,24 @@ function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${se
|
||||
return { seq, timestamp, line, redacted: true };
|
||||
}
|
||||
|
||||
function bridgeCommand(state: GameClientBridgeCommandResponse["state"]): GameClientBridgeCommandResponse {
|
||||
function sourceRCONDispatch(status: SourceRCONCommandResponse["status"]): SourceRCONCommandResponse {
|
||||
return { jobId: "job-rcon-1", serverInstanceId: "server-1", status, message: "queued" };
|
||||
}
|
||||
|
||||
function jobResponse(state: JobResponse["state"]): JobResponse {
|
||||
return {
|
||||
id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-rcon", commandType: "management.command", priority: 50, state, approvalState: "not_required",
|
||||
result: state === "succeeded" ? { status: "succeeded", summary: "command completed", completedAt: "2026-08-14T00:00:03Z" } : undefined,
|
||||
expiresAt: "2026-08-14T00:01:00Z", createdAt: "2026-08-14T00:00:00Z", updatedAt: "2026-08-14T00:00:03Z", completedAt: state === "succeeded" ? "2026-08-14T00:00:03Z" : undefined
|
||||
id: "job-rcon-1",
|
||||
serverInstanceId: "server-1",
|
||||
runEndpointId: "run-1",
|
||||
capability: "remote.run.rcon.command",
|
||||
targetKey: "source-rcon/command",
|
||||
idempotencyKey: "idem-rcon-1",
|
||||
state,
|
||||
progress: { percent: state === "succeeded" ? 100 : 50, message: state === "succeeded" ? "command completed" : "running" },
|
||||
retryPolicy: { maxAttempts: 1, initialBackoffSeconds: 1, maxBackoffSeconds: 1 },
|
||||
attempt: 1,
|
||||
reconcileCount: 0,
|
||||
createdAt: "2026-08-14T00:00:00Z",
|
||||
updatedAt: "2026-08-14T00:00:03Z"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide
|
||||
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
||||
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||
import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
||||
import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||
import { cx } from "../utils/classes";
|
||||
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
|
||||
import { EmptyState, ResultBadge } from "./StateViews";
|
||||
@@ -13,8 +13,8 @@ type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>;
|
||||
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
||||
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||
|
||||
const terminalBridgeResultPollMs = 1000;
|
||||
const terminalBridgeResultPollAttempts = 30;
|
||||
const terminalJobResultPollMs = 1000;
|
||||
const terminalJobResultPollAttempts = 30;
|
||||
const terminalInitialHistoryWindow = 500;
|
||||
const maxTerminalLines = 10000;
|
||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||
@@ -285,17 +285,17 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setResult({ status: "pending", label: "正在提交命令" });
|
||||
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
||||
try {
|
||||
const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted));
|
||||
const label = bridgeCommandDispatchLabel(response.state, response.id);
|
||||
const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted));
|
||||
const label = rconJobDispatchLabel(response.status, response.jobId);
|
||||
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
|
||||
appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]);
|
||||
const finalCommand = await waitForBridgeCommandTerminal(response.id);
|
||||
if (finalCommand) {
|
||||
const outcome = terminalLineFromBridgeCommand(finalCommand);
|
||||
appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`)]);
|
||||
const finalJob = await waitForRCONJobTerminal(response.jobId);
|
||||
if (finalJob) {
|
||||
const outcome = terminalLineFromJob(finalJob);
|
||||
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
||||
appendLines([outcome]);
|
||||
} else {
|
||||
const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`);
|
||||
const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`);
|
||||
setResult({ status: "pending", label: "等待 Run 返回结果" });
|
||||
appendLines([timeoutLine]);
|
||||
}
|
||||
@@ -308,11 +308,11 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBridgeCommandTerminal(commandId: string): Promise<GameClientBridgeCommandResponse | null> {
|
||||
for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) {
|
||||
const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId);
|
||||
if (isTerminalBridgeCommandState(current.state)) return current;
|
||||
await delay(terminalBridgeResultPollMs);
|
||||
async function waitForRCONJobTerminal(jobId: string): Promise<JobResponse | null> {
|
||||
for (let attempt = 0; attempt < terminalJobResultPollAttempts; attempt += 1) {
|
||||
const current = await platformApiClient.getJob(jobId);
|
||||
if (isTerminalJobState(current.state)) return current;
|
||||
await delay(terminalJobResultPollMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -401,8 +401,8 @@ function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[
|
||||
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||
}
|
||||
|
||||
function bridgeCommandDispatchLabel(state: string, commandId: string): string {
|
||||
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
||||
function rconJobDispatchLabel(state: string, jobId: string): string {
|
||||
return `已${state === "queued" ? "排队" : "提交"} · RCON 任务 ${jobId}`;
|
||||
}
|
||||
|
||||
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
|
||||
@@ -433,7 +433,6 @@ function terminalSourceClass(value?: string): string {
|
||||
if (key.includes("stdout")) return "stdout";
|
||||
if (key.includes("command")) return "command";
|
||||
if (key.includes("platform")) return "platform";
|
||||
if (key.includes("bridge")) return "bridge";
|
||||
if (key.includes("system")) return "system";
|
||||
return "log";
|
||||
}
|
||||
@@ -448,27 +447,27 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi
|
||||
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId);
|
||||
}
|
||||
|
||||
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
|
||||
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
|
||||
function isTerminalJobState(state: JobResponse["state"]): boolean {
|
||||
return state === "succeeded" || state === "failed" || state === "cancelled";
|
||||
}
|
||||
|
||||
function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine {
|
||||
const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state);
|
||||
const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt;
|
||||
function terminalLineFromJob(job: JobResponse): TerminalLine {
|
||||
const summary = job.progress.message || job.executionResult?.summary || job.cancelReason || jobStateLabel(job.state);
|
||||
const completed = job.updatedAt;
|
||||
const sortKey = Date.parse(completed) || Date.now();
|
||||
const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn";
|
||||
return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" };
|
||||
const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn";
|
||||
return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "PLATFORM" };
|
||||
}
|
||||
|
||||
function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string {
|
||||
function jobStateLabel(state: JobResponse["state"]): string {
|
||||
switch (state) {
|
||||
case "succeeded": return "已成功";
|
||||
case "failed": return "已失败";
|
||||
case "cancelled": return "已取消";
|
||||
case "expired": return "已过期";
|
||||
case "unknown": return "状态未知";
|
||||
case "claimed": return "Run 已领取";
|
||||
case "pending": return "已排队";
|
||||
case "accepted": return "Run 已领取";
|
||||
case "running": return "运行中";
|
||||
case "retrying": return "等待重试";
|
||||
case "queued": return "已排队";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("operations console contracts", () => {
|
||||
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", () => {
|
||||
it("projects a declared operation tray shape and protects unsafe target strings", () => {
|
||||
const operation = {
|
||||
id: "op-1",
|
||||
intent: "更新配置",
|
||||
|
||||
@@ -12,7 +12,7 @@ All first-party pages inherit the platform_web game-operations style with black-
|
||||
|
||||
## 平台概览(原首页)
|
||||
|
||||
Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, run endpoint health, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors, audit events) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states.
|
||||
Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, run endpoint health, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states.
|
||||
|
||||
## 服务器管理
|
||||
|
||||
@@ -31,7 +31,7 @@ Shows available and installed game management plugins, versions, capabilities, a
|
||||
|
||||
## 用户管理
|
||||
|
||||
Shows users, roles, permissions, status, and audit entry points. Platform administrators only.
|
||||
Shows users, roles, permissions, status, and access-management entry points. Platform administrators only.
|
||||
|
||||
## AI 提供商管理
|
||||
|
||||
@@ -39,4 +39,4 @@ Shows configured model providers, base URL, model list, relay mode, status, and
|
||||
|
||||
## 系统维护
|
||||
|
||||
Shows run endpoint health/capacity and audit events. Platform administrators only.
|
||||
Shows run endpoint health/capacity and operational events. Platform administrators only.
|
||||
|
||||
@@ -103,15 +103,13 @@ describe("first-party console pages", () => {
|
||||
},
|
||||
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" }
|
||||
providers: { status: "error", reason: "providers unavailable", diagnosticId: "providers-1" }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("4 个模块不可用");
|
||||
expect(html).toContain("3 个模块不可用");
|
||||
expect(html).toContain("资源指标不可用");
|
||||
expect(html).toContain("审计信号不可用");
|
||||
expect(html).toContain("AI 提供商信号不可用");
|
||||
expect(html).toContain("server.lifecycle.start");
|
||||
expect(html).not.toContain("暂无异常信号");
|
||||
@@ -131,10 +129,9 @@ describe("first-party console pages", () => {
|
||||
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" }
|
||||
}}
|
||||
/>
|
||||
providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain("没有创建服务器的权限");
|
||||
@@ -196,7 +193,7 @@ describe("first-party console pages", () => {
|
||||
expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置");
|
||||
expect(serverDeploymentWorkflowSource).toContain("接管已有服务器执行流程");
|
||||
expect(serverDeploymentWorkflowSource).toContain("不需要填写 SteamCMD 目录");
|
||||
expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务");
|
||||
expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的自动升级任务");
|
||||
expect(serverDeploymentWorkflowSource).toContain("Run 会按心跳自动识别服务器");
|
||||
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })');
|
||||
expect(serverDetailPageSource).not.toContain("运行配置绑定");
|
||||
@@ -229,7 +226,7 @@ describe("first-party console pages", () => {
|
||||
expect(serverDetailPageSource).not.toContain("操作历史");
|
||||
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
||||
expect(serverManagementTerminalSource).toContain("openServerLogEvents");
|
||||
expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand");
|
||||
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
||||
expect(serverManagementTerminalSource).not.toContain("password");
|
||||
expect(serverManagementTerminalSource).not.toContain("direct socket");
|
||||
@@ -342,7 +339,7 @@ describe("first-party console pages", () => {
|
||||
expect(serverDetailPageSource).toContain("打开终端");
|
||||
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||
expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand");
|
||||
expect(serverDetailPageSource).not.toContain("commandHistory");
|
||||
});
|
||||
|
||||
@@ -379,7 +376,7 @@ describe("first-party console pages", () => {
|
||||
expect(html).toContain("维护排障入口");
|
||||
expect(html).toContain("节点详情");
|
||||
expect(html).toContain("最近失败任务");
|
||||
expect(html).toContain("审计异常");
|
||||
expect(html).toContain("最近失败任务");
|
||||
});
|
||||
|
||||
it("renders profile settings as a full page", () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type {
|
||||
AiProviderResponse,
|
||||
AuditEventResponse,
|
||||
JobResponse,
|
||||
PlatformResourceUsageResponse,
|
||||
RunEndpointResponse,
|
||||
@@ -25,7 +24,7 @@ import type {
|
||||
ServerMetricsResponse
|
||||
} from "../api/types";
|
||||
import { UsageMeter } from "../components/OperationControls";
|
||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||||
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";
|
||||
@@ -45,7 +44,6 @@ export interface HomePageInitialState {
|
||||
metrics?: OperationsModuleState<ServerMetricsResponse[]>;
|
||||
usage?: OperationsModuleState<PlatformResourceUsageResponse>;
|
||||
providers?: OperationsModuleState<AiProviderResponse[]>;
|
||||
signals?: OperationsModuleState<AuditEventResponse[]>;
|
||||
}
|
||||
|
||||
interface HomePageProps extends PageComponentProps {
|
||||
@@ -57,7 +55,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||||
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" });
|
||||
@@ -103,23 +100,12 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshSignals = useCallback(async () => {
|
||||
setSignals({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listAuditEvents();
|
||||
setSignals({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
|
||||
} catch (error) {
|
||||
setSignals({ status: "error", reason: errorMessage(error, "审计事件加载失败"), diagnosticId: "overview-audit-events" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
void refreshCore();
|
||||
void refreshMetrics();
|
||||
void refreshUsage();
|
||||
void refreshProviders();
|
||||
void refreshSignals();
|
||||
}, [refreshCore, refreshMetrics, refreshProviders, refreshSignals, refreshUsage]);
|
||||
}, [refreshCore, refreshMetrics, refreshProviders, refreshUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialState) {
|
||||
@@ -139,14 +125,14 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||||
return [...counts.entries()].map(([serverType, count]) => ({ serverType, label: serverType, count }));
|
||||
}, [core]);
|
||||
|
||||
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers, signals), [core, providers, signals]);
|
||||
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers), [core, providers]);
|
||||
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 moduleFailureCount = [core, metrics, usage, providers].filter((module) => module.status === "error").length;
|
||||
const canManageServers = session.capabilities.includes("servers.manage");
|
||||
|
||||
const metricAverages = useMemo(() => {
|
||||
@@ -293,7 +279,7 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<ProductionGovernancePanel compact title="生产容量与告警" />
|
||||
<ProductionOperationsPanel compact title="生产容量与告警" />
|
||||
|
||||
<section className="overview-two-col">
|
||||
<article className="console-panel" aria-label="resource usage">
|
||||
@@ -342,15 +328,14 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||||
<div className="panel-header">
|
||||
<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>
|
||||
<span className="page-status">{moduleFreshnessLabel(core.status === "ready" ? core.refreshedAt : providers.status === "ready" ? providers.refreshedAt : undefined)}</span>
|
||||
<button type="button" className="icon-command" onClick={() => { void refreshCore(); void refreshProviders(); }}><Sparkles size={14} /> 刷新信号</button>
|
||||
</div>
|
||||
</div>
|
||||
{signals.status === "loading" && core.status === "loading" && <LoadingState label="正在收集信号…" compact />}
|
||||
{signals.status === "error" && <ErrorState title="审计信号不可用" reason={signals.reason} diagnosticId={signals.diagnosticId} onRetry={() => void refreshSignals()} compact />}
|
||||
{core.status === "loading" && providers.status === "loading" && <LoadingState label="正在收集信号…" 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="最近没有故障、失败任务或需要关注的审计事件。" />
|
||||
{overviewSignals.length === 0 && core.status === "ready" && providers.status === "ready" ? (
|
||||
<EmptyState title="暂无异常信号" description="最近没有故障、失败任务或 AI 提供商异常。" />
|
||||
) : (
|
||||
<div className="signal-list">
|
||||
{overviewSignals.map((signal) => (
|
||||
@@ -374,8 +359,7 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||||
|
||||
function buildOverviewSignals(
|
||||
core: OperationsModuleState<OverviewData>,
|
||||
providers: OperationsModuleState<AiProviderResponse[]>,
|
||||
signals: OperationsModuleState<AuditEventResponse[]>
|
||||
providers: OperationsModuleState<AiProviderResponse[]>
|
||||
): PlatformOverviewSignal[] {
|
||||
const collected: PlatformOverviewSignal[] = [];
|
||||
if (core.status === "ready") {
|
||||
@@ -391,11 +375,6 @@ function buildOverviewSignals(
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Activity, ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
|
||||
import { ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
||||
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||||
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { cx } from "../utils/classes";
|
||||
@@ -13,7 +13,6 @@ type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string
|
||||
|
||||
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
|
||||
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
|
||||
const [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
|
||||
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
|
||||
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
|
||||
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
@@ -28,16 +27,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshEvents = useCallback(async () => {
|
||||
setEvents({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listAuditEvents();
|
||||
setEvents({ status: "ready", data: response.items });
|
||||
} catch (error) {
|
||||
setEvents({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshJobs = useCallback(async () => {
|
||||
setJobs({ status: "loading" });
|
||||
try {
|
||||
@@ -60,23 +49,20 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
void refreshEndpoints();
|
||||
void refreshEvents();
|
||||
void refreshJobs();
|
||||
void refreshServers();
|
||||
}, [refreshEndpoints, refreshEvents, refreshJobs, refreshServers]);
|
||||
}, [refreshEndpoints, refreshJobs, refreshServers]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
|
||||
const eventItems = events.status === "ready" ? events.data : [];
|
||||
const jobItems = jobs.status === "ready" ? jobs.data : [];
|
||||
const serverItems = servers.status === "ready" ? servers.data : [];
|
||||
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
|
||||
const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
|
||||
const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]);
|
||||
const failedAuditCount = eventItems.filter((event) => event.result !== "success").length;
|
||||
const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length;
|
||||
|
||||
async function retryJob(job: JobResponse) {
|
||||
@@ -121,7 +107,7 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
||||
|
||||
<div className="form-guidance maintenance-triage-intro">
|
||||
<strong>维护排障入口</strong>
|
||||
<span>从节点详情、最近失败任务和审计异常进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。</span>
|
||||
<span>从节点详情和最近失败任务进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。</span>
|
||||
</div>
|
||||
|
||||
<section className="maintenance-triage-grid" aria-label="维护排障入口">
|
||||
@@ -137,17 +123,11 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
||||
<strong>{jobs.status === "ready" ? `${failedJobs.length} 个失败` : "加载中"}</strong>
|
||||
<small>从失败任务进入重试、查看相关服务器和查看日志链路。</small>
|
||||
</button>
|
||||
<button type="button" className="triage-card" onClick={() => void refreshEvents()}>
|
||||
<Activity size={18} />
|
||||
<span>审计异常</span>
|
||||
<strong>{events.status === "ready" ? `${failedAuditCount} 条异常` : "加载中"}</strong>
|
||||
<small>按资源定位失败操作和平台拒绝原因。</small>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
||||
|
||||
<ProductionGovernancePanel title="容量治理与告警闭环" />
|
||||
<ProductionOperationsPanel title="容量与告警闭环" />
|
||||
|
||||
<section className="console-panel" aria-label="run endpoints">
|
||||
<div className="panel-header">
|
||||
@@ -262,53 +242,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="console-panel" aria-label="audit events">
|
||||
<div className="panel-header">
|
||||
<h2>审计事件</h2>
|
||||
</div>
|
||||
{events.status === "loading" && <LoadingState label="正在加载审计事件…" compact />}
|
||||
{events.status === "error" && (
|
||||
<ErrorState title="审计事件加载失败" reason={events.reason} diagnosticId="maintenance-audit" onRetry={() => void refreshEvents()} compact />
|
||||
)}
|
||||
{events.status === "ready" && events.data.length === 0 && (
|
||||
<EmptyState title="暂无审计事件" description="平台还没有记录任何审计事件。" actionLabel="刷新" onAction={() => void refreshEvents()} />
|
||||
)}
|
||||
{events.status === "ready" && events.data.length > 0 && (
|
||||
<div className="console-record-list">
|
||||
{events.data.slice(0, 30).map((event) => (
|
||||
<div key={event.id} className="console-record">
|
||||
<div className="console-record-head">
|
||||
<strong>{event.summary || `${event.action} ${event.resourceKind}`}</strong>
|
||||
<span className={cx("status-pill", auditResultClass(event))}>{event.result}</span>
|
||||
</div>
|
||||
<div className="console-record-meta">
|
||||
<span>
|
||||
事件 <code>{event.id}</code>
|
||||
</span>
|
||||
<span>操作者 {event.actorId}</span>
|
||||
<span>
|
||||
资源 {event.resourceKind}/{event.resourceId}
|
||||
</span>
|
||||
<span>{formatTimestamp(event.createdAt)}</span>
|
||||
</div>
|
||||
<div className="console-row-actions">
|
||||
<button type="button" className="theme-upload" onClick={() => void refreshEvents()}>
|
||||
查看审计链路
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="theme-upload"
|
||||
disabled={event.resourceKind !== "server-instance"}
|
||||
onClick={() => event.resourceKind === "server-instance" && onNavigate("serverDetail", { serverId: event.resourceId })}
|
||||
>
|
||||
查看相关服务器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -355,10 +288,6 @@ function heartbeatAgeMinutes(value: string): number {
|
||||
return (Date.now() - timestamp) / 60000;
|
||||
}
|
||||
|
||||
function auditResultClass(event: AuditEventResponse): string {
|
||||
return event.result === "success" ? "status-active" : "status-error";
|
||||
}
|
||||
|
||||
function jobStateLabel(state: JobResponse["state"]): string {
|
||||
switch (state) {
|
||||
case "queued":
|
||||
|
||||
@@ -71,11 +71,11 @@ const plugin: GamePluginResponse = {
|
||||
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 }],
|
||||
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/diagnostic-ping.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "players", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
|
||||
pages: [{ pageKey: "players", commandTypes: ["diagnostic.ping"], snapshotTypes: ["companion.health"] }]
|
||||
},
|
||||
status: "installed"
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("ServerDetailPage config write approval", () => {
|
||||
|
||||
it("uses the historical terminal drawer without restoring separate raw command panels", () => {
|
||||
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
||||
expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand");
|
||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
||||
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
|
||||
|
||||
@@ -398,7 +398,7 @@ function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSecti
|
||||
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 部署定义</h2><span className="page-status">{view.mode || "未配置"} · 修订 {view.revision}</span></div>
|
||||
<p className="section-copy">服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。</p>
|
||||
<div className="console-row-list"><div className="console-row"><span>服务器目录</span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span>高级执行目录</span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span>启动设置</span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span>最近 Run 调度</span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run 执行确认</span><strong>已按 r{view.latestDispatch.deploymentRevision} 确认执行</strong></div>}</div>
|
||||
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM 受控模板</span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span>预检 / 扫描</span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span>配置映射 / 健康验证</span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span>失败原因</span><strong>{projection.failureCode}</strong></div>}</div>}
|
||||
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM 部署模板</span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span>预检 / 扫描</span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span>配置映射 / 健康验证</span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span>失败原因</span><strong>{projection.failureCode}</strong></div>}</div>}
|
||||
</article>;
|
||||
}
|
||||
|
||||
|
||||
@@ -477,7 +477,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
|
||||
<ConfirmDialog
|
||||
open={confirmRemoval !== null}
|
||||
title="停用用户"
|
||||
description={`确认停用 ${confirmRemoval?.user.displayName ?? ""}?该账号将无法登录,已有审计记录会保留。`}
|
||||
description={`确认停用 ${confirmRemoval?.user.displayName ?? ""}?该账号将无法登录。`}
|
||||
confirmLabel="确认停用"
|
||||
danger
|
||||
busy={confirmBusy}
|
||||
|
||||
@@ -10,7 +10,7 @@ First-party routes must be declared here before page implementation.
|
||||
- `/plugins`: 插件市场.
|
||||
- `/users`: 用户管理.
|
||||
- `/ai-providers`: AI 提供商管理.
|
||||
- `/maintenance`: 系统维护(审计事件与运行节点).
|
||||
- `/maintenance`: 系统维护(运行节点与失败任务).
|
||||
- `/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
|
||||
|
||||
@@ -80,7 +80,7 @@ export const firstPartyRoutes: PageRoute[] = [
|
||||
label: "系统维护",
|
||||
path: "/maintenance",
|
||||
hash: "#/maintenance",
|
||||
description: "审计事件与运行节点健康",
|
||||
description: "运行节点健康与失败任务",
|
||||
requiredCapability: "system.maintenance",
|
||||
showInNav: true
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ const safeCommand = {
|
||||
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,
|
||||
|
||||
@@ -92,8 +92,6 @@ export function parseSafeGameClientBridgeCommand(value: unknown): GameClientBrid
|
||||
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;
|
||||
@@ -115,8 +113,6 @@ export function parseSafeGameClientBridgeCancellation(value: unknown): GameClien
|
||||
cancellation: parseCancellation(record.cancellation),
|
||||
updatedAt: string(record.updatedAt, "updatedAt")
|
||||
};
|
||||
const auditReferences = optionalStringArray(record.auditReferences, "auditReferences");
|
||||
if (auditReferences) result.auditReferences = auditReferences;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -193,8 +189,6 @@ function parseSnapshot(value: unknown): GameClientBridgeSnapshotResponse {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("safe job projection schema", () => {
|
||||
const parsed = parseSafeJobResponse({
|
||||
...retryingJob,
|
||||
state: "succeeded",
|
||||
executionResult: { kind: "file.write", version: 2, checksum: "sha256:" + "a".repeat(64), sizeBytes: 18, auditSummary: "atomic compare-and-swap file write" }
|
||||
executionResult: { kind: "file.write", version: 2, checksum: "sha256:" + "a".repeat(64), sizeBytes: 18, summary: "atomic compare-and-swap file write" }
|
||||
});
|
||||
expect(parsed.executionResult).toMatchObject({ kind: "file.write", version: 2, sizeBytes: 18 });
|
||||
expect(parsed.executionResult).not.toHaveProperty("content");
|
||||
|
||||
@@ -57,7 +57,7 @@ function optionalExecutionResult(value: Record<string, unknown>, key: string): J
|
||||
version: optionalNumber(field, "version"),
|
||||
checksum: optionalString(field, "checksum"),
|
||||
sizeBytes: optionalNumber(field, "sizeBytes"),
|
||||
auditSummary: optionalString(field, "auditSummary")
|
||||
summary: optionalString(field, "summary")
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "./scumManagementRcon";
|
||||
import { scumSourceRCONChatRequest, scumSourceRCONCommandRequest } from "./scumManagementRcon";
|
||||
|
||||
describe("SCUM management RCON bridge schema", () => {
|
||||
it("builds a protected bridge request without connection material", () => {
|
||||
describe("SCUM management Source RCON schema", () => {
|
||||
it("builds a direct RCON job request without connection material", () => {
|
||||
const stamp = Date.UTC(2026, 7, 3, 8, 0, 0);
|
||||
|
||||
expect(scumManagementRCONCommandRequest("server/unsafe", " ListPlayers ", stamp)).toEqual({
|
||||
profileKey: "scum-client-manager",
|
||||
commandType: "management.rcon.request",
|
||||
payload: { requestText: "#ListPlayers" },
|
||||
idempotencyKey: `web:scum-rcon:server-unsafe:${stamp}`,
|
||||
priority: 20,
|
||||
expiresAt: "2026-08-03T08:02:00.000Z"
|
||||
expect(scumSourceRCONCommandRequest("server/unsafe", " ListPlayers ", stamp)).toEqual({
|
||||
kind: "command",
|
||||
command: "#ListPlayers",
|
||||
idempotencyKey: `web:scum-rcon:server-unsafe:${stamp}`
|
||||
});
|
||||
});
|
||||
|
||||
it("formats announcements and rejects framed command text", () => {
|
||||
expect(scumAnnouncementCommand("Restart in ten minutes")).toBe("#Announce Restart in ten minutes");
|
||||
expect(scumManagementRCONCommandRequest("server-1", "#SetTime 12").payload).toEqual({ requestText: "#SetTime 12" });
|
||||
expect(() => scumManagementRCONCommandRequest("server-1", "ListPlayers\nSetTime 12")).toThrow("管理指令必须是受限的单行文本");
|
||||
it("formats direct commands and rejects framed text", () => {
|
||||
expect(scumSourceRCONChatRequest("server-1", "Restart in ten minutes", 42)).toEqual({ kind: "chat", chatType: 4, message: "Restart in ten minutes", idempotencyKey: "web:scum-rcon-chat:server-1:42" });
|
||||
expect(scumSourceRCONCommandRequest("server-1", "#SetTime 12").command).toBe("#SetTime 12");
|
||||
expect(() => scumSourceRCONCommandRequest("server-1", "ListPlayers\nSetTime 12")).toThrow("管理指令必须是单行文本");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import type { GameClientBridgeQueueRequest } from "../api/types";
|
||||
|
||||
export const scumManagementRCONProfileKey = "scum-client-manager";
|
||||
export const scumManagementRCONCommandType = "management.rcon.request";
|
||||
import type { SourceRCONCommandRequest } from "../api/types";
|
||||
|
||||
const maxManagementCommandBytes = 8192;
|
||||
const managementCommandTtlMs = 120_000;
|
||||
|
||||
export function scumManagementRCONCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): GameClientBridgeQueueRequest {
|
||||
export function scumSourceRCONCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): SourceRCONCommandRequest {
|
||||
const stamp = Math.max(0, Math.floor(sequence));
|
||||
return {
|
||||
profileKey: scumManagementRCONProfileKey,
|
||||
commandType: scumManagementRCONCommandType,
|
||||
payload: { requestText: normalizeSCUMManagementCommand(command, "管理指令") },
|
||||
kind: "command",
|
||||
command: normalizeSCUMManagementCommand(command, "管理指令"),
|
||||
idempotencyKey: `web:scum-rcon:${safeBridgeIdentifierPart(serverInstanceId)}:${stamp}`,
|
||||
priority: 20,
|
||||
expiresAt: new Date(stamp + managementCommandTtlMs).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function scumAnnouncementCommand(message: string): string {
|
||||
return `#Announce ${validateSCUMManagementRCONText(message, "公告内容")}`;
|
||||
export function scumSourceRCONChatRequest(serverInstanceId: string, message: string, sequence = Date.now()): SourceRCONCommandRequest {
|
||||
const stamp = Math.max(0, Math.floor(sequence));
|
||||
return {
|
||||
kind: "chat",
|
||||
chatType: 4,
|
||||
message: validateSCUMManagementRCONText(message, "聊天内容"),
|
||||
idempotencyKey: `web:scum-rcon-chat:${safeBridgeIdentifierPart(serverInstanceId)}:${stamp}`
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSCUMManagementRCONText(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || new TextEncoder().encode(normalized).byteLength > maxManagementCommandBytes || /[\u0000\r\n]/.test(normalized)) {
|
||||
throw new Error(`${label}必须是受限的单行文本。`);
|
||||
throw new Error(`${label}必须是单行文本。`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ 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-governance-panel{margin-block:14px}
|
||||
.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}
|
||||
|
||||
Reference in New Issue
Block a user