功能修改
This commit is contained in:
@@ -9,6 +9,7 @@ export interface AiProviderFormState {
|
||||
name: string;
|
||||
kind: AiProviderKind;
|
||||
baseUrl: string;
|
||||
baseUrlConfigured: boolean;
|
||||
apiKeyRef: string;
|
||||
apiKeyConfigured: boolean;
|
||||
modelsText: string;
|
||||
@@ -155,7 +156,8 @@ export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormS
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
baseUrl: provider.baseUrl,
|
||||
baseUrl: "",
|
||||
baseUrlConfigured: provider.baseUrlConfigured,
|
||||
apiKeyRef: "",
|
||||
apiKeyConfigured: provider.apiKeyConfigured,
|
||||
modelsText: provider.models.join(", "),
|
||||
@@ -173,6 +175,7 @@ export function aiProviderFormFromDefaults(kind: AiProviderKind): AiProviderForm
|
||||
name: defaults.name,
|
||||
kind: defaults.kind,
|
||||
baseUrl: defaults.baseUrl,
|
||||
baseUrlConfigured: false,
|
||||
apiKeyRef: defaults.apiKeyRef,
|
||||
apiKeyConfigured: false,
|
||||
modelsText: defaults.modelsText,
|
||||
@@ -191,6 +194,7 @@ export function applyAiProviderKindDefaults(current: AiProviderFormState, kind:
|
||||
name: defaults.name,
|
||||
kind: defaults.kind,
|
||||
baseUrl: defaults.baseUrl,
|
||||
baseUrlConfigured: false,
|
||||
apiKeyRef: defaults.apiKeyRef,
|
||||
apiKeyConfigured: false,
|
||||
modelsText: defaults.modelsText,
|
||||
@@ -212,7 +216,7 @@ export function completeAiProviderForm(form: AiProviderFormState): AiProviderFor
|
||||
...form,
|
||||
id: generatedAiProviderId(form),
|
||||
name: form.name.trim() || defaults.name,
|
||||
baseUrl: form.baseUrl.trim() || defaults.baseUrl,
|
||||
baseUrl: form.baseUrl.trim() || (form.baseUrlConfigured ? "" : defaults.baseUrl),
|
||||
apiKeyRef: form.apiKeyConfigured && !form.apiKeyRef.trim() ? "" : form.apiKeyRef.trim() || defaults.apiKeyRef,
|
||||
modelsText,
|
||||
defaultModel: form.defaultModel.trim() || models[0] || defaults.defaultModel,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
||||
import type { OperationRecord } from "./workspace";
|
||||
import { jobBuckets, projectOperationForTray, summarizeEndpointOperations, summarizeServerOperations } from "./operationsConsole";
|
||||
|
||||
function job(id: string, state: JobResponse["state"], updatedAt: string, serverInstanceId = "server-1"): JobResponse {
|
||||
return {
|
||||
id,
|
||||
serverInstanceId,
|
||||
runEndpointId: "endpoint-1",
|
||||
capability: "server.lifecycle.start",
|
||||
idempotencyKey: `idem-${id}`,
|
||||
state,
|
||||
progress: { percent: state === "succeeded" ? 100 : 40 },
|
||||
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 1, maxBackoffSeconds: 10 },
|
||||
attempt: 1,
|
||||
reconcileCount: 0,
|
||||
createdAt: updatedAt,
|
||||
updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
function server(id: string, state: ServerInstanceResponse["state"], updatedAt: string): ServerInstanceResponse {
|
||||
return {
|
||||
id,
|
||||
pluginId: "scum-server-plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
runEndpointId: "endpoint-1",
|
||||
name: id,
|
||||
adminUserIds: [],
|
||||
state,
|
||||
configVersion: 1,
|
||||
createdAt: updatedAt,
|
||||
updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
describe("operations console contracts", () => {
|
||||
it("classifies active, retrying, failed, and terminal jobs using Platform state", () => {
|
||||
const buckets = jobBuckets([
|
||||
job("queued", "queued", "2026-07-18T10:00:00Z"),
|
||||
job("retrying", "retrying", "2026-07-18T10:02:00Z"),
|
||||
job("failed", "failed", "2026-07-18T10:03:00Z"),
|
||||
job("succeeded", "succeeded", "2026-07-18T10:01:00Z")
|
||||
]);
|
||||
|
||||
expect(buckets.active.map((item) => item.id)).toEqual(["retrying", "queued"]);
|
||||
expect(buckets.retrying.map((item) => item.id)).toEqual(["retrying"]);
|
||||
expect(buckets.failed.map((item) => item.id)).toEqual(["failed"]);
|
||||
expect(buckets.succeeded.map((item) => item.id)).toEqual(["succeeded"]);
|
||||
});
|
||||
|
||||
it("orders failed servers and failed jobs before active and healthy servers", () => {
|
||||
const summaries = summarizeServerOperations(
|
||||
[
|
||||
server("healthy", "running", "2026-07-18T10:04:00Z"),
|
||||
server("active", "ready", "2026-07-18T10:03:00Z"),
|
||||
server("job-failed", "stopped", "2026-07-18T10:02:00Z"),
|
||||
server("server-failed", "failed", "2026-07-18T10:01:00Z")
|
||||
],
|
||||
new Map(),
|
||||
[job("active-job", "running", "2026-07-18T10:05:00Z", "active"), job("failed-job", "failed", "2026-07-18T10:06:00Z", "job-failed")]
|
||||
);
|
||||
|
||||
expect(summaries.map((item) => item.instance.id)).toEqual(["server-failed", "job-failed", "active", "healthy"]);
|
||||
expect(summaries.find((item) => item.instance.id === "job-failed")?.failedJobs).toBe(1);
|
||||
});
|
||||
|
||||
it("summarizes only endpoint-safe capacity and status values", () => {
|
||||
const endpoints: RunEndpointResponse[] = [
|
||||
{
|
||||
id: "endpoint-1",
|
||||
displayName: "Primary",
|
||||
version: "1.0.0",
|
||||
status: "online",
|
||||
capabilities: ["server.lifecycle.start"],
|
||||
capacity: { maxJobs: 4, runningJobs: 2, queuedJobs: 1 },
|
||||
lastHeartbeatAt: "2026-07-18T10:00:00Z"
|
||||
},
|
||||
{
|
||||
id: "endpoint-2",
|
||||
displayName: "Secondary",
|
||||
version: "1.0.0",
|
||||
status: "degraded",
|
||||
capabilities: [],
|
||||
capacity: { maxJobs: 2, runningJobs: 1, queuedJobs: 3 },
|
||||
lastHeartbeatAt: "2026-07-18T10:00:00Z"
|
||||
}
|
||||
];
|
||||
|
||||
expect(summarizeEndpointOperations(endpoints)).toEqual({ total: 2, online: 1, degraded: 1, offline: 0, disabled: 0, activeJobs: 3, queuedJobs: 4 });
|
||||
});
|
||||
|
||||
it("projects an allowlisted operation tray shape and protects unsafe target strings", () => {
|
||||
const operation = {
|
||||
id: "op-1",
|
||||
intent: "更新配置",
|
||||
targetKind: "config",
|
||||
targetId: "/Users/operator/private.cfg",
|
||||
requester: "Operator",
|
||||
status: "failed",
|
||||
errorReason: "平台拒绝请求:Bearer raw-token /Users/operator/private.cfg PID=4201 unix:///var/run/run.sock",
|
||||
diagnosticId: "/private/tmp/diagnostic.json",
|
||||
createdAt: "2026-07-18T10:00:00Z",
|
||||
updatedAt: "2026-07-18T10:01:00Z",
|
||||
rawToken: "run-token-should-not-project"
|
||||
} as OperationRecord & { rawToken: string };
|
||||
|
||||
const projected = projectOperationForTray(operation);
|
||||
expect(projected.targetLabel).toBe("配置 / 受保护目标");
|
||||
expect(JSON.stringify(projected)).not.toContain("/Users/");
|
||||
expect(JSON.stringify(projected)).not.toContain("run-token");
|
||||
expect(JSON.stringify(projected)).not.toContain("raw-token");
|
||||
expect(JSON.stringify(projected)).not.toContain("/var/run/");
|
||||
expect(JSON.stringify(projected)).not.toContain("4201");
|
||||
expect(projected.diagnosticId).toBe("受保护诊断");
|
||||
expect(projected).not.toHaveProperty("requester");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
|
||||
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
|
||||
import type { OperationRecord } from "./workspace";
|
||||
|
||||
export type OperationsModuleState<T> =
|
||||
| { status: "loading"; refreshedAt?: string }
|
||||
| { status: "error"; reason: string; diagnosticId: string; refreshedAt?: string }
|
||||
| { status: "ready"; data: T; refreshedAt: string };
|
||||
|
||||
export interface JobBuckets {
|
||||
active: JobResponse[];
|
||||
retrying: JobResponse[];
|
||||
failed: JobResponse[];
|
||||
succeeded: JobResponse[];
|
||||
cancelled: JobResponse[];
|
||||
}
|
||||
|
||||
export interface ServerOperationsSummary {
|
||||
instance: ServerInstanceResponse;
|
||||
metrics?: ServerMetricsResponse;
|
||||
activeJobs: number;
|
||||
failedJobs: number;
|
||||
latestJob?: JobResponse;
|
||||
}
|
||||
|
||||
export interface EndpointOperationsSummary {
|
||||
total: number;
|
||||
online: number;
|
||||
degraded: number;
|
||||
offline: number;
|
||||
disabled: number;
|
||||
activeJobs: number;
|
||||
queuedJobs: number;
|
||||
}
|
||||
|
||||
export interface OperationTrayItem {
|
||||
id: string;
|
||||
intent: string;
|
||||
targetLabel: string;
|
||||
status: OperationRecord["status"];
|
||||
message?: string;
|
||||
errorReason?: string;
|
||||
diagnosticId?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const activeJobStates = new Set<JobResponse["state"]>(["queued", "accepted", "running", "retrying"]);
|
||||
|
||||
export function isActiveJob(job: JobResponse): boolean {
|
||||
return activeJobStates.has(job.state);
|
||||
}
|
||||
|
||||
export function jobBuckets(jobs: JobResponse[]): JobBuckets {
|
||||
const sorted = [...jobs].sort((left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt));
|
||||
return {
|
||||
active: sorted.filter(isActiveJob),
|
||||
retrying: sorted.filter((job) => job.state === "retrying"),
|
||||
failed: sorted.filter((job) => job.state === "failed"),
|
||||
succeeded: sorted.filter((job) => job.state === "succeeded"),
|
||||
cancelled: sorted.filter((job) => job.state === "cancelled")
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeServerOperations(
|
||||
instances: ServerInstanceResponse[],
|
||||
metrics: Map<string, ServerMetricsResponse>,
|
||||
jobs: JobResponse[]
|
||||
): ServerOperationsSummary[] {
|
||||
const jobsByServer = new Map<string, JobResponse[]>();
|
||||
for (const job of jobs) {
|
||||
if (!job.serverInstanceId) {
|
||||
continue;
|
||||
}
|
||||
const serverJobs = jobsByServer.get(job.serverInstanceId) ?? [];
|
||||
serverJobs.push(job);
|
||||
jobsByServer.set(job.serverInstanceId, serverJobs);
|
||||
}
|
||||
|
||||
return instances
|
||||
.map((instance) => {
|
||||
const serverJobs = [...(jobsByServer.get(instance.id) ?? [])].sort(
|
||||
(left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt)
|
||||
);
|
||||
return {
|
||||
instance,
|
||||
metrics: metrics.get(instance.id),
|
||||
activeJobs: serverJobs.filter(isActiveJob).length,
|
||||
failedJobs: serverJobs.filter((job) => job.state === "failed").length,
|
||||
latestJob: serverJobs[0]
|
||||
};
|
||||
})
|
||||
.sort(compareServerOperations);
|
||||
}
|
||||
|
||||
export function summarizeEndpointOperations(endpoints: RunEndpointResponse[]): EndpointOperationsSummary {
|
||||
return endpoints.reduce<EndpointOperationsSummary>(
|
||||
(summary, endpoint) => ({
|
||||
total: summary.total + 1,
|
||||
online: summary.online + Number(endpoint.status === "online"),
|
||||
degraded: summary.degraded + Number(endpoint.status === "degraded"),
|
||||
offline: summary.offline + Number(endpoint.status === "offline"),
|
||||
disabled: summary.disabled + Number(endpoint.status === "disabled"),
|
||||
activeJobs: summary.activeJobs + endpoint.capacity.runningJobs,
|
||||
queuedJobs: summary.queuedJobs + endpoint.capacity.queuedJobs
|
||||
}),
|
||||
{ total: 0, online: 0, degraded: 0, offline: 0, disabled: 0, activeJobs: 0, queuedJobs: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
export function projectOperationForTray(operation: OperationRecord): OperationTrayItem {
|
||||
return {
|
||||
id: operation.id,
|
||||
intent: operation.intent,
|
||||
targetLabel: safeOperationTarget(operation.targetKind, operation.targetId),
|
||||
status: operation.status,
|
||||
message: safeDiagnosticText(operation.message),
|
||||
errorReason: safeDiagnosticText(operation.errorReason),
|
||||
diagnosticId: safeDiagnosticId(operation.diagnosticId),
|
||||
updatedAt: operation.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function moduleFreshnessLabel(refreshedAt?: string): string {
|
||||
if (!refreshedAt) {
|
||||
return "尚未刷新";
|
||||
}
|
||||
const time = new Date(refreshedAt);
|
||||
return Number.isNaN(time.getTime()) ? "刷新时间未知" : `刷新于 ${time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
}
|
||||
|
||||
function compareServerOperations(left: ServerOperationsSummary, right: ServerOperationsSummary): number {
|
||||
const attentionDifference = serverAttentionRank(right) - serverAttentionRank(left);
|
||||
if (attentionDifference !== 0) {
|
||||
return attentionDifference;
|
||||
}
|
||||
const updatedDifference = timestamp(right.instance.updatedAt) - timestamp(left.instance.updatedAt);
|
||||
return updatedDifference !== 0 ? updatedDifference : left.instance.name.localeCompare(right.instance.name, "zh-CN");
|
||||
}
|
||||
|
||||
function serverAttentionRank(summary: ServerOperationsSummary): number {
|
||||
if (summary.instance.state === "failed") {
|
||||
return 50;
|
||||
}
|
||||
if (summary.failedJobs > 0) {
|
||||
return 40;
|
||||
}
|
||||
if (summary.instance.state === "installing") {
|
||||
return 30;
|
||||
}
|
||||
if (summary.activeJobs > 0) {
|
||||
return 20;
|
||||
}
|
||||
if (summary.instance.state === "running") {
|
||||
return 10;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function safeOperationTarget(kind: OperationRecord["targetKind"], targetId: string): string {
|
||||
const kindLabel: Record<OperationRecord["targetKind"], string> = {
|
||||
server: "服务器",
|
||||
plugin: "插件",
|
||||
config: "配置",
|
||||
llm: "AI 提供商",
|
||||
platform: "平台"
|
||||
};
|
||||
const safeId = /^[a-zA-Z0-9._:-]{1,96}$/.test(targetId) ? targetId : "受保护目标";
|
||||
return `${kindLabel[kind]} / ${safeId}`;
|
||||
}
|
||||
|
||||
function safeDiagnosticId(value: string | undefined): string | undefined {
|
||||
return value && /^[a-zA-Z0-9._:-]{1,96}$/.test(value) ? value : value ? "受保护诊断" : undefined;
|
||||
}
|
||||
|
||||
function timestamp(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import type { UserProfileUpdateRequest, UserThemePreferenceRequest, UserThemePre
|
||||
import type { CurrentUserView, WorkspaceCapability } from "./workspace";
|
||||
import type { OperationTracker } from "../stores/operations";
|
||||
|
||||
export type PageId = "home" | "servers" | "serverDetail" | "plugins" | "users" | "aiProviders" | "maintenance" | "profileSettings";
|
||||
export type PageId = "home" | "servers" | "serverDetail" | "pluginPage" | "plugins" | "users" | "aiProviders" | "maintenance" | "profileSettings";
|
||||
|
||||
export interface PageParams {
|
||||
serverId?: string;
|
||||
pluginId?: string;
|
||||
routeKey?: string;
|
||||
}
|
||||
|
||||
export interface PageRoute {
|
||||
|
||||
@@ -17,8 +17,16 @@ Plugin page runs with safe platform context.
|
||||
- `logs.query`: historical log query and analysis windows.
|
||||
- `artifacts.open`: artifact upload/download references.
|
||||
- `files.request`: scoped file operation requests.
|
||||
- `remote.access.request`: declared logical remote-access or read-only query-template request.
|
||||
- `run.distribution.request`: platform-mediated Run distribution request.
|
||||
- `dependencies.request`: declared dependency check or install request.
|
||||
- `logs.backfill.request`: bounded historical log backfill request.
|
||||
- `client-manager.request`: Client Manager lifecycle request through Platform.
|
||||
- `plugin-lifecycle.request`: declared plugin lifecycle request through Platform.
|
||||
- `ai.invoke`: platform-mediated AI invocation.
|
||||
|
||||
The host intersects manifest-level and page-level permissions/actions before exposing context. The SCUM operations page additionally intersects its command, snapshot, and query-template keys with `gameClientBridge.pages.operations`; it does not synthesize undeclared SCUM semantics.
|
||||
|
||||
## Forbidden
|
||||
|
||||
The host must not expose raw auth storage, AI keys, run credentials, host paths, or storage backend credentials.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
|
||||
export type PluginPermission =
|
||||
| "server.create"
|
||||
| "server.read"
|
||||
@@ -7,6 +9,13 @@ export type PluginPermission =
|
||||
| "server.logs.read"
|
||||
| "server.artifacts.read"
|
||||
| "server.artifacts.write"
|
||||
| "server.remote.access"
|
||||
| "server.run.distribution"
|
||||
| "server.dependencies.manage"
|
||||
| "server.client-manager.manage"
|
||||
| "server.game-client.read"
|
||||
| "server.game-client.command"
|
||||
| "server.game-client.maintenance"
|
||||
| "ai.invoke";
|
||||
|
||||
export type PluginBridgeAction =
|
||||
@@ -15,8 +24,56 @@ export type PluginBridgeAction =
|
||||
| "logs.query"
|
||||
| "artifacts.open"
|
||||
| "files.request"
|
||||
| "remote.access.request"
|
||||
| "run.distribution.request"
|
||||
| "dependencies.request"
|
||||
| "logs.backfill.request"
|
||||
| "client-manager.request"
|
||||
| "plugin-lifecycle.request"
|
||||
| "ai.invoke";
|
||||
|
||||
const pluginPermissions: readonly PluginPermission[] = [
|
||||
"server.create",
|
||||
"server.read",
|
||||
"server.lifecycle",
|
||||
"server.files.read",
|
||||
"server.files.write",
|
||||
"server.logs.read",
|
||||
"server.artifacts.read",
|
||||
"server.artifacts.write",
|
||||
"server.remote.access",
|
||||
"server.run.distribution",
|
||||
"server.dependencies.manage",
|
||||
"server.client-manager.manage",
|
||||
"server.game-client.read",
|
||||
"server.game-client.command",
|
||||
"server.game-client.maintenance",
|
||||
"ai.invoke"
|
||||
];
|
||||
|
||||
const pluginBridgeActions: readonly PluginBridgeAction[] = [
|
||||
"server.instances.read",
|
||||
"jobs.dispatch",
|
||||
"logs.query",
|
||||
"artifacts.open",
|
||||
"files.request",
|
||||
"remote.access.request",
|
||||
"run.distribution.request",
|
||||
"dependencies.request",
|
||||
"logs.backfill.request",
|
||||
"client-manager.request",
|
||||
"plugin-lifecycle.request",
|
||||
"ai.invoke"
|
||||
];
|
||||
|
||||
export function isPluginPermission(value: string): value is PluginPermission {
|
||||
return pluginPermissions.includes(value as PluginPermission);
|
||||
}
|
||||
|
||||
export function isPluginBridgeAction(value: string): value is PluginBridgeAction {
|
||||
return pluginBridgeActions.includes(value as PluginBridgeAction);
|
||||
}
|
||||
|
||||
export interface PluginPageContract {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -33,6 +90,24 @@ export interface PluginBridgeManifestContract {
|
||||
aiPurposes?: string[];
|
||||
}
|
||||
|
||||
export function pluginBridgeManifestContractFromResponse(
|
||||
plugin: Pick<GamePluginResponse, "id" | "declaredPermissions" | "bridgeActions" | "pages" | "aiPurposes">
|
||||
): PluginBridgeManifestContract {
|
||||
return {
|
||||
id: plugin.id,
|
||||
declaredPermissions: plugin.declaredPermissions.filter(isPluginPermission),
|
||||
bridgeActions: plugin.bridgeActions.filter(isPluginBridgeAction),
|
||||
pages: plugin.pages.map((page) => ({
|
||||
key: page.key,
|
||||
title: page.title,
|
||||
path: page.path,
|
||||
permissions: page.permissions.filter(isPluginPermission),
|
||||
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction)
|
||||
})),
|
||||
aiPurposes: [...plugin.aiPurposes]
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginBridgeThemeTokens {
|
||||
colorScheme: "light" | "dark";
|
||||
accentColor: string;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { resolveScumOperationsPageContract } from "./scumOperations";
|
||||
|
||||
const plugin = {
|
||||
id: "game.scum",
|
||||
pages: [{
|
||||
key: "operations",
|
||||
title: "SCUM 运维",
|
||||
path: "/operations",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access", "unknown.permission"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request", "unknown.action"]
|
||||
}],
|
||||
gameClientBridge: {
|
||||
commands: [
|
||||
{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 },
|
||||
{ type: "maintenance.prepare", title: "Prepare maintenance", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", payloadSchemaRef: "schemas/bridge/maintenance.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }
|
||||
],
|
||||
snapshots: [
|
||||
{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 },
|
||||
{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.json", keepForSeconds: 3600, maxRecords: 24 }
|
||||
],
|
||||
queryTemplates: [
|
||||
{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }
|
||||
],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
|
||||
},
|
||||
runtimeProfiles: {
|
||||
logSources: [{ key: "scum-chat-events", kind: "file.tail", streamKey: "scum.chat", retentionDays: 30 }],
|
||||
logEvents: [{ key: "scum-chat", title: "SCUM chat", sourceKey: "scum-chat-events", eventType: "scum.chat", permission: "server.logs.read", schemaRef: "schemas/log-events/chat.json", retentionDays: 30, severity: "info" }]
|
||||
},
|
||||
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }
|
||||
} satisfies Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle">;
|
||||
|
||||
describe("SCUM operations page contract", () => {
|
||||
it("projects only declarations owned by the plugin operations page", () => {
|
||||
const resolution = resolveScumOperationsPageContract(plugin, "server-1");
|
||||
expect(resolution).toMatchObject({
|
||||
available: true,
|
||||
contract: {
|
||||
pluginId: "game.scum",
|
||||
routeKey: "operations",
|
||||
serverInstanceId: "server-1",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
|
||||
commands: [{ type: "announcement.send" }],
|
||||
snapshots: [{ type: "companion.health" }],
|
||||
queryTemplates: [{ key: "scum.player.search", engine: "sqlite" }],
|
||||
logEvents: [{ eventType: "scum.chat" }],
|
||||
productionLifecycle: { dependencyPolicy: "required" }
|
||||
}
|
||||
});
|
||||
expect(JSON.stringify(resolution)).not.toMatch(/sqlText|hostPath|sessionToken|componentKey|credential|socket/i);
|
||||
});
|
||||
|
||||
it("reports declaration and server-context availability without inventing fallback semantics", () => {
|
||||
expect(resolveScumOperationsPageContract({ ...plugin, id: "game.other" }, "server-1")).toMatchObject({ available: false });
|
||||
expect(resolveScumOperationsPageContract(plugin, "")).toMatchObject({ available: false, reason: "缺少服务器实例上下文。" });
|
||||
expect(resolveScumOperationsPageContract({ ...plugin, gameClientBridge: undefined }, "server-1")).toMatchObject({ available: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
GameClientBridgeCommandDeclarationResponse,
|
||||
GameClientBridgeQueryTemplateDeclarationResponse,
|
||||
GameClientBridgeSnapshotDeclarationResponse,
|
||||
GamePluginResponse,
|
||||
PluginProductionLifecycleDeclaration,
|
||||
RuntimeLogEventResponse,
|
||||
RuntimeLogSourceResponse
|
||||
} from "../api/types";
|
||||
import {
|
||||
isPluginBridgeAction,
|
||||
isPluginPermission,
|
||||
type PluginBridgeAction,
|
||||
type PluginPermission
|
||||
} from "./pluginBridge";
|
||||
|
||||
export const scumOperationsPluginId = "game.scum";
|
||||
export const scumOperationsRouteKey = "operations";
|
||||
|
||||
export interface ScumOperationsPageContract {
|
||||
pluginId: typeof scumOperationsPluginId;
|
||||
routeKey: typeof scumOperationsRouteKey;
|
||||
serverInstanceId: string;
|
||||
title: string;
|
||||
permissions: PluginPermission[];
|
||||
bridgeActions: PluginBridgeAction[];
|
||||
commands: GameClientBridgeCommandDeclarationResponse[];
|
||||
snapshots: GameClientBridgeSnapshotDeclarationResponse[];
|
||||
queryTemplates: GameClientBridgeQueryTemplateDeclarationResponse[];
|
||||
logSources: RuntimeLogSourceResponse[];
|
||||
logEvents: RuntimeLogEventResponse[];
|
||||
productionLifecycle: PluginProductionLifecycleDeclaration;
|
||||
}
|
||||
|
||||
export interface ScumCompanionHealthView {
|
||||
status: "online" | "degraded" | "offline" | "unknown";
|
||||
version?: string;
|
||||
observedAt?: string;
|
||||
latencyMs?: number;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export interface ScumSessionView {
|
||||
sessionId: string;
|
||||
playerName: string;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
export interface ScumPlayerView {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
status: string;
|
||||
squadId?: string;
|
||||
pingMs?: number;
|
||||
lastSeenAt?: string;
|
||||
}
|
||||
|
||||
export interface ScumSquadView {
|
||||
squadId: string;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
leaderPlayerId?: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
export interface ScumVehicleView {
|
||||
vehicleId: string;
|
||||
vehicleType: string;
|
||||
status: string;
|
||||
ownerPlayerId?: string;
|
||||
squadId?: string;
|
||||
fuelPercent?: number;
|
||||
healthPercent?: number;
|
||||
lastSeenAt?: string;
|
||||
}
|
||||
|
||||
export interface ScumFlagView {
|
||||
flagId: string;
|
||||
status: string;
|
||||
ownerPlayerId?: string;
|
||||
squadId?: string;
|
||||
radiusMeters?: number;
|
||||
lastUpdatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ScumSnapshotCollection<T> {
|
||||
observedAt?: string;
|
||||
total: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface ScumOperationsSnapshotView {
|
||||
health?: ScumCompanionHealthView;
|
||||
sessions: ScumSnapshotCollection<ScumSessionView>;
|
||||
players: ScumSnapshotCollection<ScumPlayerView>;
|
||||
squads: ScumSnapshotCollection<ScumSquadView>;
|
||||
vehicles: ScumSnapshotCollection<ScumVehicleView>;
|
||||
flags: ScumSnapshotCollection<ScumFlagView>;
|
||||
}
|
||||
|
||||
export type ScumOperationsPageResolution =
|
||||
| { available: true; contract: ScumOperationsPageContract }
|
||||
| { available: false; reason: string };
|
||||
|
||||
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle">;
|
||||
|
||||
export function resolveScumOperationsPageContract(plugin: ScumPluginProjection, serverInstanceId: string): ScumOperationsPageResolution {
|
||||
if (plugin.id !== scumOperationsPluginId) {
|
||||
return { available: false, reason: "该路由仅承载 game.scum 插件声明的运维页。" };
|
||||
}
|
||||
if (!serverInstanceId.trim()) {
|
||||
return { available: false, reason: "缺少服务器实例上下文。" };
|
||||
}
|
||||
const page = plugin.pages.find((candidate) => candidate.key === scumOperationsRouteKey);
|
||||
if (!page) {
|
||||
return { available: false, reason: "SCUM 插件未声明 operations 页面。" };
|
||||
}
|
||||
const manifest = plugin.gameClientBridge;
|
||||
const bridgePage = manifest?.pages?.find((candidate) => candidate.pageKey === scumOperationsRouteKey);
|
||||
if (!manifest || !bridgePage) {
|
||||
return { available: false, reason: "SCUM 插件未声明 operations 的 Game Client Bridge 契约。" };
|
||||
}
|
||||
|
||||
const commandTypes = new Set(bridgePage.commandTypes ?? []);
|
||||
const snapshotTypes = new Set(bridgePage.snapshotTypes ?? []);
|
||||
const queryTemplateKeys = new Set(bridgePage.queryTemplateKeys ?? []);
|
||||
return {
|
||||
available: true,
|
||||
contract: {
|
||||
pluginId: scumOperationsPluginId,
|
||||
routeKey: scumOperationsRouteKey,
|
||||
serverInstanceId,
|
||||
title: page.title,
|
||||
permissions: page.permissions.filter(isPluginPermission),
|
||||
bridgeActions: (page.bridgeActions ?? []).filter(isPluginBridgeAction),
|
||||
commands: manifest.commands.filter((command) => commandTypes.has(command.type)),
|
||||
snapshots: manifest.snapshots.filter((snapshot) => snapshotTypes.has(snapshot.type)),
|
||||
queryTemplates: (manifest.queryTemplates ?? []).filter((template) => queryTemplateKeys.has(template.key)),
|
||||
logSources: [...(plugin.runtimeProfiles?.logSources ?? [])],
|
||||
logEvents: [...(plugin.runtimeProfiles?.logEvents ?? [])],
|
||||
productionLifecycle: plugin.productionLifecycle
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -92,6 +92,9 @@ export interface ServerCardView {
|
||||
instance: ServerInstanceResponse;
|
||||
metrics?: ServerMetricsResponse;
|
||||
pendingJobs: number;
|
||||
activeJobs?: number;
|
||||
failedJobs?: number;
|
||||
latestJob?: JobResponse;
|
||||
}
|
||||
|
||||
export type ServerStatusFilter = "all" | "online" | "offline" | "attention";
|
||||
@@ -187,7 +190,9 @@ export interface ConfigDiffView {
|
||||
|
||||
export interface LlmSuggestionView {
|
||||
serverInstanceId: string;
|
||||
source: "api" | "local";
|
||||
source: "api";
|
||||
recommendation: string;
|
||||
diffId?: string;
|
||||
expiresAt?: string;
|
||||
diff?: ConfigDiffView;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user