功能修改

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