feat: 完整游戏运维功能
This commit is contained in:
@@ -57,7 +57,7 @@ async function main() {
|
||||
const plugin = findRequired(plugins.items, (item) => item.id === "game.example", "game.example plugin");
|
||||
const marketplacePlugin = findRequired(marketplace.items, (item) => item.id === "game.example", "game.example marketplace plugin");
|
||||
const operator = findRequired(users.items, (item) => item.email === "operator.local@example.test", "operator local user");
|
||||
const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyRef?.startsWith("secret://"), "redacted AI provider");
|
||||
const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyConfigured === true, "redacted AI provider");
|
||||
|
||||
assertEqual(server.pluginId, "game.example", "server is backed by game.example");
|
||||
assertEqual(server.runEndpointId, "run-local-debug", "server is assigned to run-local-debug");
|
||||
@@ -203,7 +203,7 @@ async function loginApi() {
|
||||
const response = await postJson("/auth/login", {
|
||||
account: "operator.local@example.test",
|
||||
password: "operator-local"
|
||||
});
|
||||
}, { "X-Auth-Token-Response": "bearer" });
|
||||
if (!response.sessionId || response.status !== "authenticated") {
|
||||
throw new Error("local debug API login did not return an active session");
|
||||
}
|
||||
|
||||
+129
-12
@@ -1,14 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PlatformApiClient, setPlatformApiSessionToken } from "./client";
|
||||
import { PlatformApiClient, PlatformApiError, setPlatformApiAuthFailureHandler, setPlatformApiSessionToken } from "./client";
|
||||
import type { AiProviderResponse, ArtifactDownloadReferenceResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types";
|
||||
|
||||
const runtimeDigest = `sha256:${"a".repeat(64)}`;
|
||||
|
||||
const provider: AiProviderResponse = {
|
||||
id: "ai.openai",
|
||||
name: "OpenAI",
|
||||
kind: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKeyRef: "secret://providers/openai",
|
||||
apiKeyConfigured: true,
|
||||
models: ["gpt-4.1"],
|
||||
defaultModel: "gpt-4.1",
|
||||
relayMode: "direct",
|
||||
@@ -33,6 +35,7 @@ const plugin: GamePluginResponse = {
|
||||
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
|
||||
tags: ["example"],
|
||||
aiPurposes: ["logs.diagnose"],
|
||||
runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] },
|
||||
status: "installed"
|
||||
};
|
||||
|
||||
@@ -90,6 +93,9 @@ const job: JobResponse = {
|
||||
idempotencyKey: "idem-start",
|
||||
state: "queued",
|
||||
progress: { percent: 0, message: "queued" },
|
||||
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 2, maxBackoffSeconds: 60 },
|
||||
attempt: 0,
|
||||
reconcileCount: 0,
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
};
|
||||
@@ -139,6 +145,7 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = {
|
||||
describe("PlatformApiClient AI providers", () => {
|
||||
afterEach(() => {
|
||||
setPlatformApiSessionToken(null);
|
||||
setPlatformApiAuthFailureHandler(null);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -170,8 +177,21 @@ describe("PlatformApiClient AI providers", () => {
|
||||
const client = new PlatformApiClient();
|
||||
|
||||
await expect(client.listAiProviders()).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.createAiProvider(provider)).resolves.toMatchObject({ id: provider.id });
|
||||
await expect(client.updateAiProvider(provider.id, { ...provider, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" });
|
||||
const providerRequest = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKeyRef: "secret://providers/openai",
|
||||
models: provider.models,
|
||||
defaultModel: provider.defaultModel,
|
||||
relayMode: provider.relayMode,
|
||||
timeoutMs: provider.timeoutMs,
|
||||
redactionPolicy: provider.redactionPolicy
|
||||
};
|
||||
await expect(client.createAiProvider(providerRequest)).resolves.toMatchObject({ id: provider.id });
|
||||
const { id: _id, ...providerUpdate } = providerRequest;
|
||||
await expect(client.updateAiProvider(provider.id, { ...providerUpdate, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" });
|
||||
await expect(client.setAiProviderStatus(provider.id, { status: "disabled" })).resolves.toMatchObject({ status: "disabled" });
|
||||
await expect(client.testAiProvider(provider.id)).resolves.toMatchObject({ success: true, mode: "metadata" });
|
||||
await expect(client.listAiProviderModels(provider.id)).resolves.toMatchObject({ models: ["gpt-4.1"] });
|
||||
@@ -343,6 +363,9 @@ describe("PlatformApiClient AI providers", () => {
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") {
|
||||
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" } } });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -356,6 +379,13 @@ describe("PlatformApiClient AI providers", () => {
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/runtime/actions")) {
|
||||
return jsonResponse(runtimeActions);
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/runtime-binding") && (!init?.method || init.method === "GET")) {
|
||||
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, profileKey: "local", mode: "local-process", configured: true, keys: [{ key: "server-root", required: true, configured: true, secret: false }], missingKeys: [], status: "complete" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/runtime-binding") && init?.method === "PUT") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ profileKey: "local", bindings: { "server-root": "runtime.server-root" } });
|
||||
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, profileKey: "local", mode: "local-process", configured: true, keys: [{ key: "server-root", required: true, configured: true, secret: false }], missingKeys: [], status: "complete" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/run/generate") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" });
|
||||
return jsonResponse({
|
||||
@@ -392,20 +422,49 @@ describe("PlatformApiClient AI providers", () => {
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/run/update") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" });
|
||||
expect(JSON.parse(String(init.body))).toEqual({ artifactId: "artifact-run-1", checksum: runtimeDigest, idempotencyKey: "idem-run-update" });
|
||||
return jsonResponse({
|
||||
id: "run-update-1",
|
||||
serverInstanceId: server.id,
|
||||
runEndpointId: endpoint.id,
|
||||
artifactId: "artifact-run-1",
|
||||
checksum: "sha256:runchecksum",
|
||||
checksum: runtimeDigest,
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
targetRelease: "run-dist-2",
|
||||
previousVersion: "0.1.0",
|
||||
jobId: "job-run-update",
|
||||
idempotencyKey: "idem-run-update",
|
||||
status: "queued",
|
||||
phase: "queued",
|
||||
rollback: false,
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/run/update") && init?.method === "GET") {
|
||||
return jsonResponse({
|
||||
items: [{
|
||||
id: "run-update-1",
|
||||
serverInstanceId: server.id,
|
||||
runEndpointId: endpoint.id,
|
||||
artifactId: "artifact-run-1",
|
||||
checksum: runtimeDigest,
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
targetRelease: "run-dist-2",
|
||||
previousVersion: "0.1.0",
|
||||
jobId: "job-run-update",
|
||||
status: "running",
|
||||
phase: "restart-requested",
|
||||
message: "verified update staged; restart requested",
|
||||
rollback: false,
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:01:00Z"
|
||||
}],
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/generate") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
profileKey: "scum-client-manager",
|
||||
@@ -457,8 +516,21 @@ describe("PlatformApiClient AI providers", () => {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", idempotencyKey: "idem-dep-check" });
|
||||
return jsonResponse({ ...job, id: "job-dep-check", capability: "dependencies.check", targetKey: "dependencies/java-21" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/dependencies") && init?.method === "GET") {
|
||||
return jsonResponse({
|
||||
serverInstanceId: server.id,
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.version,
|
||||
profileKey: "local",
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
probes: [{ key: "java-21", kind: "java.version", required: true, state: "missing", installPlanKey: "install-java-linux" }],
|
||||
plans: [{ key: "install-java-linux", title: "Install Java", targetOs: "linux", targetArch: "amd64", digest: runtimeDigest, steps: [{ type: "package", targetKey: "java", packageManager: "apt", packageName: "openjdk-21-jre" }] }],
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/dependencies/install") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" });
|
||||
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" });
|
||||
return jsonResponse({ ...job, id: "job-dep-install", capability: "dependencies.install", targetKey: "dependencies/install/install-java-linux" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/logs/live")) {
|
||||
@@ -546,11 +618,12 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] });
|
||||
await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true });
|
||||
await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum });
|
||||
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({
|
||||
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create", profileKey: "local", bindings: {} })).resolves.toMatchObject({
|
||||
action: "create"
|
||||
});
|
||||
await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" });
|
||||
await expect(client.stopServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-stop" })).resolves.toMatchObject({ action: "stop" });
|
||||
await expect(client.queryServerProcessStatus(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-status" })).resolves.toMatchObject({ action: "status", job: { executionResult: { processState: "running" } } });
|
||||
await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
|
||||
await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] });
|
||||
@@ -558,6 +631,8 @@ describe("PlatformApiClient AI providers", () => {
|
||||
const runtime = await client.getServerRuntimeActions(server.id);
|
||||
expect(runtime.runStatus).toBe("online");
|
||||
expect(runtime.actions.some((action) => action.key === "generate-run" && action.available)).toBe(true);
|
||||
await expect(client.getServerRuntimeBinding(server.id)).resolves.toMatchObject({ profileKey: "local", status: "complete", keys: [{ key: "server-root", configured: true }] });
|
||||
await expect(client.updateServerRuntimeBinding(server.id, { profileKey: "local", bindings: { "server-root": "runtime.server-root" } })).resolves.toMatchObject({ status: "complete" });
|
||||
await expect(client.generateRunDistribution(server.id, { targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" })).resolves.toMatchObject({
|
||||
artifactId: "artifact-run-1",
|
||||
keyGeneration: 1,
|
||||
@@ -565,10 +640,11 @@ describe("PlatformApiClient AI providers", () => {
|
||||
});
|
||||
await expect(client.downloadLatestRunDistribution(server.id)).resolves.toMatchObject({ artifactId: "artifact-run-1", rangeSupported: true });
|
||||
await expect(client.resetRunKey(server.id)).resolves.toMatchObject({ componentKind: "run", generation: 2 });
|
||||
await expect(client.pushRunUpdate(server.id, { artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" })).resolves.toMatchObject({
|
||||
await expect(client.pushRunUpdate(server.id, { artifactId: "artifact-run-1", checksum: runtimeDigest, idempotencyKey: "idem-run-update" })).resolves.toMatchObject({
|
||||
jobId: "job-run-update",
|
||||
status: "queued"
|
||||
});
|
||||
await expect(client.listRunUpdates(server.id)).resolves.toMatchObject({ count: 1, items: [{ phase: "restart-requested", rollback: false }] });
|
||||
await expect(
|
||||
client.generateClientManager(server.id, {
|
||||
profileKey: "scum-client-manager",
|
||||
@@ -582,7 +658,8 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.downloadLatestClientManager(server.id, { profileKey: "scum-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" });
|
||||
await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "scum-client-manager" })).resolves.toMatchObject({ generation: 2 });
|
||||
await expect(client.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" });
|
||||
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
|
||||
await expect(client.getDependencyCatalog(server.id)).resolves.toMatchObject({ targetOs: "linux", plans: [{ digest: runtimeDigest }] });
|
||||
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
|
||||
capability: "dependencies.install"
|
||||
});
|
||||
await expect(client.listServerLiveLogs(server.id)).resolves.toMatchObject({ count: 0 });
|
||||
@@ -599,7 +676,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(38);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(43);
|
||||
});
|
||||
|
||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||
@@ -651,7 +728,8 @@ describe("PlatformApiClient AI providers", () => {
|
||||
it("keeps raw key fields out of provider responses", () => {
|
||||
expect("apiKey" in provider).toBe(false);
|
||||
expect("rawApiKey" in provider).toBe(false);
|
||||
expect(provider.apiKeyRef).toBe("secret://providers/openai");
|
||||
expect(provider.apiKeyConfigured).toBe(true);
|
||||
expect("apiKeyRef" in provider).toBe(false);
|
||||
});
|
||||
|
||||
it("calls auth endpoints and attaches bearer sessions", async () => {
|
||||
@@ -687,6 +765,45 @@ describe("PlatformApiClient AI providers", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("resets 401 sessions and redacts auth error details", async () => {
|
||||
const onAuthFailure = vi.fn();
|
||||
setPlatformApiSessionToken("expired-session-token");
|
||||
setPlatformApiAuthFailureHandler(onAuthFailure);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||
code: "unauthorized",
|
||||
message: "expired secret://internal/provider raw-token-value /srv/game unix:///tmp/run.sock"
|
||||
}), { status: 401, headers: { "Content-Type": "application/json" } })));
|
||||
|
||||
const client = new PlatformApiClient();
|
||||
const failure = await client.getCurrentUser().catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(PlatformApiError);
|
||||
expect(failure).toMatchObject({ status: 401, code: "unauthorized", message: "会话已失效,请重新登录。" });
|
||||
expect(String(failure)).not.toMatch(/secret:\/\/|raw-token-value|\/srv\/game|unix:\/\//);
|
||||
expect(onAuthFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps 403 as a safe capability denial without clearing the session", async () => {
|
||||
const onAuthFailure = vi.fn();
|
||||
setPlatformApiAuthFailureHandler(onAuthFailure);
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer owner-session");
|
||||
return new Response(JSON.stringify({ code: "forbidden", message: "owner mismatch secret://internal" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PlatformApiClient("/api/v1", () => "owner-session");
|
||||
await expect(client.getServerInstance("other-server")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: "forbidden",
|
||||
message: "没有权限访问该资源。"
|
||||
});
|
||||
expect(onAuthFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
ComponentKeyResponse,
|
||||
ComponentKeyResetRequest,
|
||||
CurrentUserResponse,
|
||||
DependencyCatalogResponse,
|
||||
DependencyJobRequest,
|
||||
FileOperationDispatchRequest,
|
||||
FileOperationDispatchResponse,
|
||||
@@ -50,7 +51,10 @@ import type {
|
||||
RunDistributionResponse,
|
||||
RunEndpointListResponse,
|
||||
RunUpdateJobResponse,
|
||||
RunUpdateJobListResponse,
|
||||
RunUpdateRequest,
|
||||
RuntimeBindingResponse,
|
||||
RuntimeBindingUpdateRequest,
|
||||
ServerConfigResponse,
|
||||
ServerConfigDiffPreviewRequest,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
@@ -65,6 +69,12 @@ import type {
|
||||
ServerMemberListResponse,
|
||||
ServerMemberRequest,
|
||||
ServerMetricsListResponse,
|
||||
MetricSampleListResponse,
|
||||
BackupListResponse,
|
||||
BackupResponse,
|
||||
RemoteAdapterDeclarationListResponse,
|
||||
RemoteAdapterRequest,
|
||||
RemoteAdapterResponse,
|
||||
ServerRuntimeActionsResponse,
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
@@ -75,13 +85,30 @@ import type {
|
||||
UserUpdateRequest
|
||||
} from "./types";
|
||||
import { readWebRuntimeEnv } from "../schemas/env";
|
||||
import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates";
|
||||
|
||||
let platformApiSessionToken: string | null = null;
|
||||
let platformApiAuthFailureHandler: ((error: PlatformApiError) => void) | null = null;
|
||||
|
||||
export function setPlatformApiSessionToken(token: string | null) {
|
||||
platformApiSessionToken = token;
|
||||
}
|
||||
|
||||
export function setPlatformApiAuthFailureHandler(handler: ((error: PlatformApiError) => void) | null) {
|
||||
platformApiAuthFailureHandler = handler;
|
||||
}
|
||||
|
||||
export class PlatformApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PlatformApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export class PlatformApiClient {
|
||||
constructor(private readonly baseUrl = "/api/v1", private readonly sessionTokenProvider: () => string | null = () => platformApiSessionToken) {}
|
||||
|
||||
@@ -119,6 +146,14 @@ export class PlatformApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getServerRuntimeBinding(id: string): Promise<RuntimeBindingResponse> {
|
||||
return this.request<RuntimeBindingResponse>(`/server-instances/${encodeURIComponent(id)}/runtime-binding`);
|
||||
}
|
||||
|
||||
async updateServerRuntimeBinding(id: string, request: RuntimeBindingUpdateRequest): Promise<RuntimeBindingResponse> {
|
||||
return this.request<RuntimeBindingResponse>(`/server-instances/${encodeURIComponent(id)}/runtime-binding`, { method: "PUT", body: request });
|
||||
}
|
||||
|
||||
async startServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
|
||||
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/start`, {
|
||||
method: "POST",
|
||||
@@ -126,12 +161,19 @@ export class PlatformApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async stopServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
|
||||
async stopServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
|
||||
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/stop`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async queryServerProcessStatus(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
|
||||
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/process/status`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
}
|
||||
|
||||
async listServerAdministratorCandidates(id: string): Promise<ServerMemberListResponse> {
|
||||
return this.request<ServerMemberListResponse>(`/server-instances/${encodeURIComponent(id)}/administrators/candidates`);
|
||||
@@ -177,10 +219,9 @@ export class PlatformApiClient {
|
||||
if (sessionToken) {
|
||||
headers.set("Authorization", `Bearer ${sessionToken}`);
|
||||
}
|
||||
const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content?${params.toString()}`, { headers });
|
||||
const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content?${params.toString()}`, { headers, credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
const apiError = await safeReadError(response);
|
||||
throw new Error(apiError?.message ?? `request failed: ${response.status}`);
|
||||
throw await responseError(response);
|
||||
}
|
||||
const payload = await response.arrayBuffer();
|
||||
return {
|
||||
@@ -225,10 +266,15 @@ export class PlatformApiClient {
|
||||
}
|
||||
|
||||
async pushRunUpdate(id: string, request: RunUpdateRequest): Promise<RunUpdateJobResponse> {
|
||||
return this.request<RunUpdateJobResponse>(`/server-instances/${encodeURIComponent(id)}/run/update`, {
|
||||
const response = await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/run/update`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
return parseSafeRunUpdate(response);
|
||||
}
|
||||
|
||||
async listRunUpdates(id: string): Promise<RunUpdateJobListResponse> {
|
||||
return parseSafeRunUpdateList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/run/update`));
|
||||
}
|
||||
|
||||
async generateClientManager(id: string, request: ClientManagerBuildRequest): Promise<ClientManagerDistributionResponse> {
|
||||
@@ -259,6 +305,10 @@ export class PlatformApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getDependencyCatalog(id: string): Promise<DependencyCatalogResponse> {
|
||||
return parseSafeDependencyCatalog(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/dependencies`));
|
||||
}
|
||||
|
||||
async installDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
|
||||
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/install`, {
|
||||
method: "POST",
|
||||
@@ -340,6 +390,27 @@ export class PlatformApiClient {
|
||||
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
|
||||
}
|
||||
|
||||
async listMetricHistory(serverInstanceId: string, limit = 100): Promise<MetricSampleListResponse> {
|
||||
const params = new URLSearchParams({ serverInstanceId, limit: String(limit) });
|
||||
return this.request<MetricSampleListResponse>(`/metrics/server-instances/history?${params.toString()}`);
|
||||
}
|
||||
|
||||
async listBackups(serverInstanceId: string): Promise<BackupListResponse> {
|
||||
return this.request<BackupListResponse>(`/backups?serverInstanceId=${encodeURIComponent(serverInstanceId)}`);
|
||||
}
|
||||
|
||||
async getBackup(id: string): Promise<BackupResponse> {
|
||||
return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async listRemoteAdapters(serverInstanceId: string): Promise<RemoteAdapterDeclarationListResponse> {
|
||||
return this.request<RemoteAdapterDeclarationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`);
|
||||
}
|
||||
|
||||
async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise<RemoteAdapterResponse> {
|
||||
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async getServerConfig(id: string): Promise<ServerConfigResponse> {
|
||||
return this.request<ServerConfigResponse>(`/server-instances/${encodeURIComponent(id)}/config`);
|
||||
}
|
||||
@@ -446,14 +517,14 @@ export class PlatformApiClient {
|
||||
|
||||
const response = await fetch(options.absolute ? path : `${this.baseUrl}${path}`, {
|
||||
...options.init,
|
||||
credentials: options.init?.credentials ?? "same-origin",
|
||||
method: options.method ?? options.init?.method ?? "GET",
|
||||
headers,
|
||||
body: options.body === undefined ? options.init?.body : JSON.stringify(options.body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const apiError = await safeReadError(response);
|
||||
throw new Error(apiError?.message ?? `request failed: ${response.status}`);
|
||||
throw await responseError(response);
|
||||
}
|
||||
|
||||
if (options.parseJson === false || response.status === 204) {
|
||||
@@ -480,6 +551,21 @@ async function safeReadError(response: Response): Promise<ApiErrorResponse | nul
|
||||
}
|
||||
}
|
||||
|
||||
async function responseError(response: Response): Promise<PlatformApiError> {
|
||||
const apiError = await safeReadError(response);
|
||||
const message = response.status === 401
|
||||
? "会话已失效,请重新登录。"
|
||||
: response.status === 403
|
||||
? "没有权限访问该资源。"
|
||||
: apiError?.message ?? `request failed: ${response.status}`;
|
||||
const error = new PlatformApiError(response.status, apiError?.code ?? "request_failed", message);
|
||||
if (response.status === 401) {
|
||||
platformApiSessionToken = null;
|
||||
platformApiAuthFailureHandler?.(error);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function marketplaceQuery(filter: MarketplacePluginFilterRequest): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.status && filter.status !== "all") {
|
||||
|
||||
@@ -16,9 +16,14 @@ API clients and DTO types live here, not inside page components.
|
||||
|
||||
Every API client must use named request and response types.
|
||||
|
||||
`PlatformApiClient` converts 401 into a safe re-login error, clears its in-memory bearer token, and notifies the session store to remove browser persistence. A 403 remains a safe capability/ownership denial and does not disclose server error details. Neither error path renders tokens, secret refs, paths, or sockets.
|
||||
|
||||
Normal browser login uses the platform's HttpOnly SameSite cookie and `credentials=same-origin`; the JSON response does not expose a session token. Reading an older localStorage bearer remains a migration compatibility path only, and any 401 removes it.
|
||||
|
||||
## Server Management Workflows
|
||||
|
||||
- `createServerWorkflow` posts `ServerLifecycleCreateRequest` to `/server-instances/workflows/create` and receives the accepted instance plus install job.
|
||||
- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with a declared `profileKey` and initial logical `bindings` to `/server-instances/workflows/create`, and receives the accepted instance plus install job only after binding validation.
|
||||
- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values.
|
||||
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
|
||||
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
|
||||
- `getServerConfig`, `previewServerConfigDiff`, and `approveServerConfigWrite` call platform-mediated config routes. ServerDetailPage must preview the platform diff first, keep the explicit confirmation step, and dispatch writes only through the approval API.
|
||||
@@ -27,15 +32,21 @@ Every API client must use named request and response types.
|
||||
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
||||
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
|
||||
- `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 pending lifecycle status.
|
||||
- `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.
|
||||
- `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.
|
||||
- Server creation and detail forms derive profile choices and binding fields from `GamePluginResponse.runtimeProfiles`; they must not hardcode a complete state or game-specific machine paths.
|
||||
- AI provider responses expose `apiKeyConfigured` only. Existing secret refs are never rehydrated into edit forms; a blank update preserves the platform-owned secret reference.
|
||||
|
||||
## 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:
|
||||
|
||||
- `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`) and `POST /api/v1/auth/logout`: implemented bearer session lifecycle for authenticated workspace entry.
|
||||
- `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.
|
||||
- `GET /api/v1/users/current` (`CurrentUserResponse`): implemented current session identity, roles, profile summary, and theme preference reference for role-aware navigation and default landing.
|
||||
- `PUT /api/v1/users/current/profile` (`UserProfileUpdateRequest`/`CurrentUserResponse`): implemented current-user profile updates such as display name, avatar reference, phone, QQ, and bounded contact fields.
|
||||
- `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference.
|
||||
@@ -47,4 +58,6 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
||||
- `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.
|
||||
|
||||
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
|
||||
- Log filtering by level/keyword/time/source is applied client-side over `POST /api/v1/log-streams/query` (`LogStreamCursorRequest`) results until the platform exposes server-side filters.
|
||||
|
||||
+276
-12
@@ -8,8 +8,10 @@ export interface HealthResponse {
|
||||
export type GamePluginStatus = "installed" | "disabled" | "invalid" | "updating";
|
||||
export type ServerInstanceState = "draft" | "installing" | "ready" | "running" | "stopped" | "failed" | "deleted";
|
||||
export type RunEndpointStatus = "online" | "offline" | "degraded" | "disabled";
|
||||
export type JobState = "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
export type ServerLifecycleAction = "create" | "start" | "stop";
|
||||
export type JobState = "queued" | "accepted" | "running" | "retrying" | "succeeded" | "failed" | "cancelled";
|
||||
export type DependencyState = "unknown" | "present" | "missing" | "installing" | "failed";
|
||||
export type RunUpdatePhase = "queued" | "downloading" | "staged" | "restart-requested" | "activating" | "succeeded" | "rolled-back" | "failed";
|
||||
export type ServerLifecycleAction = "create" | "start" | "stop" | "status";
|
||||
|
||||
export interface PluginPermissionsResponse {
|
||||
ai: boolean;
|
||||
@@ -17,6 +19,7 @@ export interface PluginPermissionsResponse {
|
||||
files: boolean;
|
||||
jobs: boolean;
|
||||
artifacts: boolean;
|
||||
remoteAccess?: boolean;
|
||||
}
|
||||
|
||||
export interface GamePluginPageResponse {
|
||||
@@ -27,6 +30,77 @@ export interface GamePluginPageResponse {
|
||||
bridgeActions?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeDiscoveryProbeResponse {
|
||||
key: string;
|
||||
kind: string;
|
||||
targetKey: string;
|
||||
required?: boolean;
|
||||
expected?: string;
|
||||
platforms?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeLifecycleProfileResponse {
|
||||
key: string;
|
||||
mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client";
|
||||
capabilities: string[];
|
||||
actionRefs?: Record<string, string>;
|
||||
transportKeys?: string[];
|
||||
clientManagerRef?: string;
|
||||
platforms?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeDependencyProbeResponse {
|
||||
key: string;
|
||||
kind: string;
|
||||
targetKey: string;
|
||||
required?: boolean;
|
||||
minimumVersion?: string;
|
||||
platforms?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeInstallPlanResponse {
|
||||
key: string;
|
||||
title: string;
|
||||
platforms?: string[];
|
||||
steps: Array<{ type: string; targetKey: string; packageManager?: string; packageName?: string; version?: string; downloadRef?: string; checksum?: string }>;
|
||||
}
|
||||
|
||||
export interface RuntimeLogSourceResponse {
|
||||
key: string;
|
||||
kind: string;
|
||||
targetKey?: string;
|
||||
streamKey: string;
|
||||
cursorKind?: string;
|
||||
retentionDays?: number;
|
||||
}
|
||||
|
||||
export interface RuntimeTransportProfileResponse {
|
||||
key: string;
|
||||
kind: string;
|
||||
targetKey?: string;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeClientManagerProfileResponse {
|
||||
key: string;
|
||||
displayName?: string;
|
||||
repository: { url: string; revisionPolicy: string; branch?: string; tag?: string; revision?: string };
|
||||
supportedTargets: Array<{ os: string; arch: string }>;
|
||||
build: { system: string; workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
|
||||
outputArtifacts: string[];
|
||||
}
|
||||
|
||||
export interface GamePluginRuntimeProfilesResponse {
|
||||
discovery?: RuntimeDiscoveryProbeResponse[];
|
||||
lifecycleProfiles?: RuntimeLifecycleProfileResponse[];
|
||||
dependencyProbes?: RuntimeDependencyProbeResponse[];
|
||||
installPlans?: RuntimeInstallPlanResponse[];
|
||||
logSources?: RuntimeLogSourceResponse[];
|
||||
transportProfiles?: RuntimeTransportProfileResponse[];
|
||||
clientManagers?: RuntimeClientManagerProfileResponse[];
|
||||
}
|
||||
|
||||
export interface GamePluginResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -46,6 +120,7 @@ export interface GamePluginResponse {
|
||||
tags: string[];
|
||||
aiPurposes: string[];
|
||||
validationViolations?: string[];
|
||||
runtimeProfiles?: GamePluginRuntimeProfilesResponse;
|
||||
status: GamePluginStatus;
|
||||
}
|
||||
|
||||
@@ -104,7 +179,10 @@ export interface ServerInstanceResponse {
|
||||
ownerUserId?: string;
|
||||
adminUserIds: string[];
|
||||
state: ServerInstanceState;
|
||||
configVersion: number;
|
||||
configVersion: number;
|
||||
configKey?: string;
|
||||
configChecksum?: string;
|
||||
configUpdatedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -124,10 +202,39 @@ export interface ServerLifecycleCreateRequest {
|
||||
runEndpointId: string;
|
||||
name: string;
|
||||
idempotencyKey: string;
|
||||
profileKey: string;
|
||||
bindings: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RuntimeBindingUpdateRequest {
|
||||
profileKey: string;
|
||||
bindings: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RuntimeBindingKeyResponse {
|
||||
key: string;
|
||||
required: boolean;
|
||||
configured: boolean;
|
||||
secret: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeBindingResponse {
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
profileKey?: string;
|
||||
mode?: string;
|
||||
configured: boolean;
|
||||
keys: RuntimeBindingKeyResponse[];
|
||||
missingKeys: string[];
|
||||
status: "complete" | "incomplete";
|
||||
reason?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ServerLifecycleCommandRequest {
|
||||
expectedConfigVersion: number;
|
||||
expectedConfigVersion: number;
|
||||
expectedChecksum?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
@@ -149,6 +256,8 @@ export interface RunEndpointResponse {
|
||||
id: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
platform?: string;
|
||||
architecture?: string;
|
||||
status: RunEndpointStatus;
|
||||
capabilities: string[];
|
||||
capacity: RunCapacityResponse;
|
||||
@@ -175,11 +284,39 @@ export interface JobResponse {
|
||||
idempotencyKey: string;
|
||||
state: JobState;
|
||||
progress: JobProgressBody;
|
||||
resultRef?: string;
|
||||
resultRef?: string;
|
||||
executionResult?: JobExecutionResultResponse;
|
||||
retryPolicy: {
|
||||
maxAttempts: number;
|
||||
initialBackoffSeconds: number;
|
||||
maxBackoffSeconds: number;
|
||||
};
|
||||
attempt: number;
|
||||
nextAttemptAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
leaseExpiresAt?: string;
|
||||
cancelReason?: string;
|
||||
cancelRequestedAt?: string;
|
||||
cancelCompletedAt?: string;
|
||||
terminalAt?: string;
|
||||
lastReconciledAt?: string;
|
||||
reconcileCount: number;
|
||||
reconcileOutcome?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface JobExecutionResultResponse {
|
||||
kind?: string;
|
||||
processState?: string;
|
||||
exitClassification?: string;
|
||||
exitCode?: number;
|
||||
version?: number;
|
||||
checksum?: string;
|
||||
sizeBytes?: number;
|
||||
auditSummary?: string;
|
||||
}
|
||||
|
||||
export interface JobListResponse {
|
||||
items: JobResponse[];
|
||||
count: number;
|
||||
@@ -288,13 +425,25 @@ export interface RunUpdateJobResponse {
|
||||
runEndpointId: string;
|
||||
artifactId: string;
|
||||
checksum: string;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
targetRelease?: string;
|
||||
previousVersion?: string;
|
||||
jobId?: string;
|
||||
idempotencyKey?: string;
|
||||
status: string;
|
||||
phase: RunUpdatePhase;
|
||||
message?: string;
|
||||
rollback: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RunUpdateJobListResponse {
|
||||
items: RunUpdateJobResponse[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ClientManagerBuildRequest {
|
||||
profileKey: string;
|
||||
targetOs: string;
|
||||
@@ -351,9 +500,51 @@ export interface DependencyJobRequest {
|
||||
installPlanKey?: string;
|
||||
targetOs?: string;
|
||||
targetArch?: string;
|
||||
planDigest?: string;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface DependencyProbeViewResponse {
|
||||
key: string;
|
||||
kind: string;
|
||||
required: boolean;
|
||||
minimumVersion?: string;
|
||||
state: DependencyState;
|
||||
evidence?: string;
|
||||
installPlanKey?: string;
|
||||
}
|
||||
|
||||
export interface DependencyPlanStepViewResponse {
|
||||
type: string;
|
||||
targetKey: string;
|
||||
packageManager?: string;
|
||||
packageName?: string;
|
||||
version?: string;
|
||||
downloadHost?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface DependencyPlanViewResponse {
|
||||
key: string;
|
||||
title: string;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
digest: string;
|
||||
steps: DependencyPlanStepViewResponse[];
|
||||
}
|
||||
|
||||
export interface DependencyCatalogResponse {
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
pluginVersion: string;
|
||||
profileKey: string;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
probes: DependencyProbeViewResponse[];
|
||||
plans: DependencyPlanViewResponse[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LogBackfillRequest {
|
||||
sourceKey: string;
|
||||
checkpointRef?: string;
|
||||
@@ -435,7 +626,7 @@ export interface AiProviderResponse {
|
||||
name: string;
|
||||
kind: AiProviderKind;
|
||||
baseUrl: string;
|
||||
apiKeyRef: string;
|
||||
apiKeyConfigured: boolean;
|
||||
models: string[];
|
||||
defaultModel?: string;
|
||||
relayMode: AiRelayMode;
|
||||
@@ -555,6 +746,7 @@ export interface AuthSessionResponse {
|
||||
sessionId?: string;
|
||||
status: "authenticated" | "pending";
|
||||
message?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface UserProfileUpdateRequest {
|
||||
@@ -608,12 +800,79 @@ export interface ServerMetricsListResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MetricSampleResponse extends ServerMetricsResponse {
|
||||
id?: string;
|
||||
runEndpointId?: string;
|
||||
}
|
||||
|
||||
export interface MetricSampleListResponse {
|
||||
items: MetricSampleResponse[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type BackupState = "pending" | "available" | "failed" | "expired";
|
||||
|
||||
export interface BackupResponse {
|
||||
id: string;
|
||||
serverInstanceId: string;
|
||||
artifactId: string;
|
||||
checksum: string;
|
||||
sizeBytes: number;
|
||||
state: BackupState;
|
||||
recoveryStatus?: string;
|
||||
retentionUntil?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BackupListResponse {
|
||||
items: BackupResponse[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RemoteAdapterDeclarationResponse {
|
||||
key: string;
|
||||
kind: string;
|
||||
targetKeys: string[];
|
||||
capabilities: string[];
|
||||
timeoutSeconds: number;
|
||||
maxAttempts: number;
|
||||
}
|
||||
|
||||
export interface RemoteAdapterDeclarationListResponse {
|
||||
items: RemoteAdapterDeclarationResponse[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RemoteAdapterRequest {
|
||||
declarationKey: string;
|
||||
targetKey: string;
|
||||
capability: string;
|
||||
timeoutSeconds?: number;
|
||||
maxAttempts?: number;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface RemoteAdapterResponse {
|
||||
requestId: string;
|
||||
serverInstanceId: string;
|
||||
declarationKey: string;
|
||||
targetKey: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
retryable: boolean;
|
||||
message: string;
|
||||
resultRef?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface ServerConfigResponse {
|
||||
serverInstanceId: string;
|
||||
configVersion: number;
|
||||
format: string;
|
||||
key?: string;
|
||||
content: string;
|
||||
content: string;
|
||||
checksum?: string;
|
||||
source?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -628,7 +887,8 @@ export interface ConfigDiffLineResponse {
|
||||
}
|
||||
|
||||
export interface ServerConfigDiffPreviewRequest {
|
||||
expectedConfigVersion: number;
|
||||
expectedConfigVersion: number;
|
||||
expectedChecksum?: string;
|
||||
key: string;
|
||||
proposedContent?: string;
|
||||
proposedContentInputRef?: string;
|
||||
@@ -636,7 +896,8 @@ export interface ServerConfigDiffPreviewRequest {
|
||||
|
||||
export interface ServerConfigDiffPreviewResponse {
|
||||
serverInstanceId: string;
|
||||
configVersion: number;
|
||||
configVersion: number;
|
||||
checksum?: string;
|
||||
key: string;
|
||||
currentContent: string;
|
||||
proposedContent?: string;
|
||||
@@ -648,7 +909,8 @@ export interface ServerConfigDiffPreviewResponse {
|
||||
}
|
||||
|
||||
export interface ServerConfigWriteApprovalRequest {
|
||||
expectedConfigVersion: number;
|
||||
expectedConfigVersion: number;
|
||||
expectedChecksum?: string;
|
||||
key: string;
|
||||
proposedContent?: string;
|
||||
proposedContentInputRef?: string;
|
||||
@@ -668,8 +930,10 @@ export interface FileOperationDispatchRequest {
|
||||
pluginId?: string;
|
||||
operation: FileOperationKind;
|
||||
key: string;
|
||||
inputRef?: string;
|
||||
expectedConfigVersion?: number;
|
||||
inputRef?: string;
|
||||
content?: string;
|
||||
expectedConfigVersion?: number;
|
||||
expectedChecksum?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { projectRuntimeTrackedJob, runtimeBuildStages } from "./RuntimeTaskProgress";
|
||||
|
||||
describe("distribution build job progress", () => {
|
||||
it("projects worker progress messages onto the real build stage", () => {
|
||||
const projection = projectRuntimeTrackedJob(runtimeBuildStages, {
|
||||
id: "job-build-1",
|
||||
state: "running",
|
||||
progress: { percent: 65, message: "build_compile: compiling target executable" }
|
||||
});
|
||||
|
||||
expect(projection).toMatchObject({ status: "running", percent: 65, currentStageKey: "build_compile" });
|
||||
expect(projection.stageStatus).toMatchObject({ git_sync: "completed", env_check: "completed", deps_download: "completed", build_compile: "running", package_finalize: "pending" });
|
||||
});
|
||||
|
||||
it("keeps a failed worker stage failed instead of timer-completing later stages", () => {
|
||||
const projection = projectRuntimeTrackedJob(runtimeBuildStages, {
|
||||
id: "job-build-2",
|
||||
state: "failed",
|
||||
progress: { percent: 65, message: "build_compile: Go compilation failed" }
|
||||
});
|
||||
|
||||
expect(projection).toMatchObject({ status: "failed", percent: 65, currentStageKey: "build_compile" });
|
||||
expect(projection.stageStatus.build_compile).toBe("failed");
|
||||
expect(projection.stageStatus.package_finalize).toBe("pending");
|
||||
});
|
||||
|
||||
it("marks every stage complete only when the backend job succeeds", () => {
|
||||
const projection = projectRuntimeTrackedJob(runtimeBuildStages, {
|
||||
id: "job-build-3",
|
||||
state: "succeeded",
|
||||
progress: { percent: 100, message: "package_finalize: build artifact available" }
|
||||
});
|
||||
|
||||
expect(projection.status).toBe("succeeded");
|
||||
expect(projection.percent).toBe(100);
|
||||
expect(Object.values(projection.stageStatus)).toEqual(runtimeBuildStages.map(() => "completed"));
|
||||
});
|
||||
|
||||
it("keeps durable retry-wait jobs active and exposes the next attempt", () => {
|
||||
const projection = projectRuntimeTrackedJob(runtimeBuildStages, {
|
||||
id: "job-build-retry",
|
||||
state: "retrying",
|
||||
progress: { percent: 10 },
|
||||
attempt: 1,
|
||||
retryPolicy: { maxAttempts: 3 },
|
||||
nextAttemptAt: "2026-07-18T12:00:02Z"
|
||||
});
|
||||
|
||||
expect(projection.status).toBe("running");
|
||||
expect(projection.message).toContain("第 2 次尝试");
|
||||
});
|
||||
});
|
||||
@@ -96,8 +96,21 @@ interface RuntimeTaskRunOptions<T> {
|
||||
|
||||
export interface RuntimeTrackedJob {
|
||||
id: string;
|
||||
state: "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
state: "queued" | "accepted" | "running" | "retrying" | "succeeded" | "failed" | "cancelled";
|
||||
progress: { percent: number; message?: string };
|
||||
attempt?: number;
|
||||
retryPolicy?: { maxAttempts: number };
|
||||
nextAttemptAt?: string;
|
||||
cancelReason?: string;
|
||||
reconcileOutcome?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeTrackedJobProjection {
|
||||
status: RuntimeTaskStatus;
|
||||
percent: number;
|
||||
currentStageKey: string;
|
||||
stageStatus: Record<string, RuntimeTaskStageStatus>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface RuntimeTrackedTaskOptions<T> {
|
||||
@@ -242,20 +255,15 @@ export function useRuntimeTaskController() {
|
||||
|
||||
while (true) {
|
||||
const job = await poll(started.jobId);
|
||||
const message = job.progress.message?.trim() || job.state;
|
||||
const stageIndex = trackedStageIndex(stages, message, job.progress.percent);
|
||||
const stage = stages[stageIndex] ?? stages[0];
|
||||
const stageStatus = Object.fromEntries(
|
||||
stages.map((item, index) => [item.key, index < stageIndex || job.state === "succeeded" ? "completed" : index === stageIndex ? "running" : "pending"])
|
||||
) as Record<string, RuntimeTaskStageStatus>;
|
||||
const projection = projectRuntimeTrackedJob(stages, job);
|
||||
setTask((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
percent: Math.max(current.percent, Math.min(99, job.progress.percent)),
|
||||
currentStageKey: stage?.key ?? current.currentStageKey,
|
||||
stageStatus,
|
||||
logs: current.logs[current.logs.length - 1] === message ? current.logs : appendRuntimeLog(current.logs, message)
|
||||
percent: job.state === "succeeded" ? 100 : Math.max(current.percent, projection.percent),
|
||||
currentStageKey: projection.currentStageKey || current.currentStageKey,
|
||||
stageStatus: projection.stageStatus,
|
||||
logs: current.logs[current.logs.length - 1] === projection.message ? current.logs : appendRuntimeLog(current.logs, projection.message)
|
||||
}
|
||||
: current
|
||||
);
|
||||
@@ -275,14 +283,15 @@ export function useRuntimeTaskController() {
|
||||
return started.value;
|
||||
}
|
||||
if (job.state === "failed" || job.state === "cancelled") {
|
||||
const error = message || (job.state === "cancelled" ? "构建已取消" : "构建失败");
|
||||
const error = projection.message || (job.state === "cancelled" ? "构建已取消" : "构建失败");
|
||||
setTask((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
status: "failed",
|
||||
error,
|
||||
stageStatus: { ...stageStatus, [stage?.key ?? firstStage]: "failed" },
|
||||
currentStageKey: projection.currentStageKey || firstStage,
|
||||
stageStatus: projection.stageStatus,
|
||||
logs: appendRuntimeLog(current.logs, error)
|
||||
}
|
||||
: current
|
||||
@@ -484,6 +493,27 @@ function trackedStageIndex(stages: RuntimeTaskStage[], message: string, percent:
|
||||
return Math.min(index, Math.max(0, stages.length - 1));
|
||||
}
|
||||
|
||||
export function projectRuntimeTrackedJob(stages: RuntimeTaskStage[], job: RuntimeTrackedJob): RuntimeTrackedJobProjection {
|
||||
const retryLabel = job.state === "retrying" ? `等待第 ${Math.min((job.attempt ?? 0) + 1, job.retryPolicy?.maxAttempts ?? (job.attempt ?? 0) + 1)} 次尝试${job.nextAttemptAt ? `(${new Date(job.nextAttemptAt).toLocaleString()})` : ""}` : "";
|
||||
const message = job.cancelReason?.trim() || job.progress.message?.trim() || retryLabel || job.reconcileOutcome?.trim() || job.state;
|
||||
const stageIndex = trackedStageIndex(stages, message, job.progress.percent);
|
||||
const currentStageKey = stages[stageIndex]?.key ?? stages[0]?.key ?? "start";
|
||||
const terminalFailure = job.state === "failed" || job.state === "cancelled";
|
||||
const stageStatus = Object.fromEntries(
|
||||
stages.map((item, index) => [
|
||||
item.key,
|
||||
job.state === "succeeded" || index < stageIndex ? "completed" : index === stageIndex ? (terminalFailure ? "failed" : "running") : "pending"
|
||||
])
|
||||
) as Record<string, RuntimeTaskStageStatus>;
|
||||
return {
|
||||
status: job.state === "succeeded" ? "succeeded" : terminalFailure ? "failed" : "running",
|
||||
percent: job.state === "succeeded" ? 100 : Math.min(99, Math.max(0, job.progress.percent)),
|
||||
currentStageKey,
|
||||
stageStatus,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
function appendRuntimeLog(logs: string[], line: string): string[] {
|
||||
return [...logs, line].slice(-8);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface AiProviderFormState {
|
||||
kind: AiProviderKind;
|
||||
baseUrl: string;
|
||||
apiKeyRef: string;
|
||||
apiKeyConfigured: boolean;
|
||||
modelsText: string;
|
||||
defaultModel: string;
|
||||
relayMode: AiRelayMode;
|
||||
@@ -155,7 +156,8 @@ export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormS
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKeyRef: provider.apiKeyRef,
|
||||
apiKeyRef: "",
|
||||
apiKeyConfigured: provider.apiKeyConfigured,
|
||||
modelsText: provider.models.join(", "),
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
relayMode: provider.relayMode,
|
||||
@@ -172,6 +174,7 @@ export function aiProviderFormFromDefaults(kind: AiProviderKind): AiProviderForm
|
||||
kind: defaults.kind,
|
||||
baseUrl: defaults.baseUrl,
|
||||
apiKeyRef: defaults.apiKeyRef,
|
||||
apiKeyConfigured: false,
|
||||
modelsText: defaults.modelsText,
|
||||
defaultModel: defaults.defaultModel,
|
||||
relayMode: defaults.relayMode,
|
||||
@@ -189,6 +192,7 @@ export function applyAiProviderKindDefaults(current: AiProviderFormState, kind:
|
||||
kind: defaults.kind,
|
||||
baseUrl: defaults.baseUrl,
|
||||
apiKeyRef: defaults.apiKeyRef,
|
||||
apiKeyConfigured: false,
|
||||
modelsText: defaults.modelsText,
|
||||
defaultModel: defaults.defaultModel,
|
||||
relayMode: defaults.relayMode,
|
||||
@@ -209,7 +213,7 @@ export function completeAiProviderForm(form: AiProviderFormState): AiProviderFor
|
||||
id: generatedAiProviderId(form),
|
||||
name: form.name.trim() || defaults.name,
|
||||
baseUrl: form.baseUrl.trim() || defaults.baseUrl,
|
||||
apiKeyRef: form.apiKeyRef.trim() || defaults.apiKeyRef,
|
||||
apiKeyRef: form.apiKeyConfigured && !form.apiKeyRef.trim() ? "" : form.apiKeyRef.trim() || defaults.apiKeyRef,
|
||||
modelsText,
|
||||
defaultModel: form.defaultModel.trim() || models[0] || defaults.defaultModel,
|
||||
relayMode: form.relayMode || defaults.relayMode,
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface ServerCreateFormState {
|
||||
name: string;
|
||||
pluginId: string;
|
||||
runEndpointId: string;
|
||||
profileKey: string;
|
||||
bindings: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RuntimeBindingField {
|
||||
key: string;
|
||||
required: boolean;
|
||||
sensitive: boolean;
|
||||
}
|
||||
|
||||
export interface ServerWorkflowActionState {
|
||||
@@ -46,7 +54,9 @@ export const emptyServerCreateForm: ServerCreateFormState = {
|
||||
id: "",
|
||||
name: "",
|
||||
pluginId: "",
|
||||
runEndpointId: ""
|
||||
runEndpointId: "",
|
||||
profileKey: "",
|
||||
bindings: {}
|
||||
};
|
||||
|
||||
export function summarizeServerManagement(instances: ServerInstanceResponse[], jobs: JobResponse[]): ServerManagementSummary {
|
||||
@@ -85,17 +95,43 @@ export function canStopServer(state: ServerInstanceState): boolean {
|
||||
}
|
||||
|
||||
export function isPendingJobState(state: JobResponse["state"]): boolean {
|
||||
return state === "queued" || state === "accepted" || state === "running";
|
||||
return state === "queued" || state === "accepted" || state === "running" || state === "retrying";
|
||||
}
|
||||
|
||||
export function defaultServerCreateForm(plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState {
|
||||
const plugin = plugins[0];
|
||||
return {
|
||||
...emptyServerCreateForm,
|
||||
pluginId: plugins[0]?.id ?? "",
|
||||
pluginId: plugin?.id ?? "",
|
||||
profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "",
|
||||
runEndpointId: endpoints[0]?.id ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
export function runtimeBindingFields(plugin: GamePluginResponse | undefined, profileKey: string): RuntimeBindingField[] {
|
||||
const profiles = plugin?.runtimeProfiles;
|
||||
const lifecycle = profiles?.lifecycleProfiles?.find((profile) => profile.key === profileKey);
|
||||
if (!profiles || !lifecycle) return [];
|
||||
const fields = new Map<string, RuntimeBindingField>();
|
||||
const add = (key: string | undefined, required: boolean) => {
|
||||
if (!key) return;
|
||||
const current = fields.get(key);
|
||||
fields.set(key, { key, required: required || current?.required === true, sensitive: runtimeBindingKeyIsSensitive(key) });
|
||||
};
|
||||
profiles.discovery?.forEach((probe) => add(probe.targetKey, probe.required === true));
|
||||
profiles.dependencyProbes?.forEach((probe) => add(probe.targetKey, probe.required === true));
|
||||
profiles.logSources?.forEach((source) => add(source.targetKey, Boolean(source.targetKey)));
|
||||
profiles.installPlans?.forEach((plan) => plan.steps.forEach((step) => add(step.targetKey, false)));
|
||||
profiles.transportProfiles?.filter((transport) => lifecycle.transportKeys?.includes(transport.key)).forEach((transport) => add(transport.targetKey || transport.key, true));
|
||||
add(lifecycle.clientManagerRef, Boolean(lifecycle.clientManagerRef));
|
||||
return [...fields.values()].sort((left, right) => left.key.localeCompare(right.key));
|
||||
}
|
||||
|
||||
export function runtimeBindingKeyIsSensitive(key: string): boolean {
|
||||
const normalized = key.toLowerCase();
|
||||
return ["password", "credential", "secret", "token", "dsn"].some((part) => normalized.includes(part));
|
||||
}
|
||||
|
||||
export function serverMetadataFormFromInstance(instance: ServerInstanceResponse): ServerMetadataFormState {
|
||||
return { name: instance.name };
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export interface PluginControlDescriptor {
|
||||
label: string;
|
||||
description: string;
|
||||
capability: string;
|
||||
lifecycleAction?: "start" | "stop";
|
||||
lifecycleAction?: "start" | "stop" | "status";
|
||||
dangerous: boolean;
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ export interface DiffLine {
|
||||
|
||||
export interface ConfigDiffView {
|
||||
serverInstanceId: string;
|
||||
configVersion?: number;
|
||||
configVersion?: number;
|
||||
checksum?: string;
|
||||
key?: string;
|
||||
source?: string;
|
||||
summary: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ const provider: AiProviderResponse = {
|
||||
name: "OpenAI Relay",
|
||||
kind: "openai-compatible",
|
||||
baseUrl: "https://relay.example.test/v1",
|
||||
apiKeyRef: "secret://providers/openai",
|
||||
apiKeyConfigured: true,
|
||||
models: ["gpt-4.1", "gpt-4.1-mini"],
|
||||
defaultModel: "gpt-4.1-mini",
|
||||
relayMode: "relay",
|
||||
@@ -43,7 +43,8 @@ describe("AiProvidersPage", () => {
|
||||
|
||||
expect(html).toContain("OpenAI Relay");
|
||||
expect(html).toContain("本地开发");
|
||||
expect(html).toContain("secret://providers/openai");
|
||||
expect(html).toContain("已配置");
|
||||
expect(html).not.toContain("secret://providers/openai");
|
||||
expect(html).toContain("测试");
|
||||
expect(html).toContain("模型");
|
||||
expect(html).toContain("编辑");
|
||||
|
||||
@@ -118,6 +118,8 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
const key = event.target.name as keyof AiProviderFormState;
|
||||
if (key === "kind") {
|
||||
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
|
||||
} else if (key === "apiKeyRef") {
|
||||
setForm((current) => ({ ...current, apiKeyRef: event.target.value, apiKeyConfigured: current.apiKeyConfigured || Boolean(event.target.value.trim()) }));
|
||||
} else {
|
||||
updateForm(key, event.target.value);
|
||||
}
|
||||
@@ -166,7 +168,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
if (!completed.baseUrl.trim()) {
|
||||
missing.push("Base URL");
|
||||
}
|
||||
if (completed.relayMode !== "local" && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
|
||||
if (completed.relayMode !== "local" && !completed.apiKeyConfigured && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
|
||||
missing.push("secret://providers/... 密钥引用");
|
||||
}
|
||||
if (models.length === 0) {
|
||||
@@ -365,7 +367,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
<th>类型</th>
|
||||
<th>模式</th>
|
||||
<th>模型</th>
|
||||
<th>密钥引用</th>
|
||||
<th>密钥状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -385,7 +387,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
<td>{provider.relayMode}</td>
|
||||
<td>{provider.models.length}</td>
|
||||
<td>
|
||||
<code className="secret-ref">{provider.apiKeyRef}</code>
|
||||
<span className={cx("status-pill", provider.apiKeyConfigured ? "status-active" : "status-disabled")}>{provider.apiKeyConfigured ? "已配置" : "未配置"}</span>
|
||||
</td>
|
||||
<td className="provider-actions-cell">
|
||||
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
|
||||
@@ -459,7 +461,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
</label>
|
||||
<label>
|
||||
<span>{secretRequired ? "平台密钥引用" : "密钥引用(本地模式可留空)"}</span>
|
||||
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} placeholder={secretRequired ? formDefaults.apiKeyRef : "本地服务通常不需要"} />
|
||||
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} placeholder={form.apiKeyConfigured ? "已配置;留空保持不变" : secretRequired ? "secret://providers/..." : "本地服务通常不需要"} />
|
||||
<small className="field-help">
|
||||
{secretRequired ? "填写 secret://providers/...;真实密钥进入平台 secret store,不粘贴到页面。" : "Ollama 本地模式默认只需要 Base URL 和模型名。"}
|
||||
</small>
|
||||
|
||||
@@ -80,6 +80,16 @@ describe("first-party console pages", () => {
|
||||
expect(html).not.toContain("/Users/");
|
||||
});
|
||||
|
||||
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
|
||||
expect(serversPageSource).toContain('name="profileKey"');
|
||||
expect(serversPageSource).toContain("runtimeBindingFields");
|
||||
expect(serversPageSource).toContain("updateBinding(field.key");
|
||||
expect(serversPageSource).toContain('type={field.sensitive ? "password" : "text"}');
|
||||
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) {
|
||||
expect(serversPageSource).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => {
|
||||
expect(serversPageSource).toContain('aria-haspopup="menu"');
|
||||
expect(serversPageSource).toContain("createPortal");
|
||||
|
||||
@@ -234,6 +234,9 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
||||
<span>服务器 {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
|
||||
<span>节点 {endpoint ? endpoint.displayName : job.runEndpointId}</span>
|
||||
<span>{progressMessage(job)}</span>
|
||||
<span>
|
||||
尝试 {job.attempt}/{job.retryPolicy.maxAttempts}
|
||||
</span>
|
||||
<span>{formatTimestamp(job.updatedAt)}</span>
|
||||
</div>
|
||||
<div className="maintenance-actions">
|
||||
@@ -360,6 +363,8 @@ function jobStateLabel(state: JobResponse["state"]): string {
|
||||
return "已接收";
|
||||
case "running":
|
||||
return "运行中";
|
||||
case "retrying":
|
||||
return "等待重试";
|
||||
case "succeeded":
|
||||
return "成功";
|
||||
case "cancelled":
|
||||
|
||||
@@ -90,12 +90,24 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("sqlite://");
|
||||
});
|
||||
|
||||
it("reviews and updates only redacted runtime binding metadata", () => {
|
||||
const runtimeBindingSectionSource = serverDetailPageSource.split("function RuntimeBindingSection")[1]?.split("function RuntimeDistributionSection")[0] ?? "";
|
||||
expect(serverDetailPageSource).toContain("getServerRuntimeBinding");
|
||||
expect(serverDetailPageSource).toContain("updateServerRuntimeBinding");
|
||||
expect(runtimeBindingSectionSource).toContain("missingKeys");
|
||||
expect(runtimeBindingSectionSource).toContain('type={field.sensitive ? "password" : "text"}');
|
||||
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://", "mysql://", "sqlite://"]) {
|
||||
expect(runtimeBindingSectionSource).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => {
|
||||
expect(serverDetailPageSource).toContain('action === "install" || action === "restart" || action === "status"');
|
||||
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop"');
|
||||
expect(serverDetailPageSource).toContain('control.lifecycleAction === "start" || control.lifecycleAction === "stop"');
|
||||
expect(serverDetailPageSource).toContain('action === "install" || action === "restart"');
|
||||
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop" && action !== "status"');
|
||||
expect(serverDetailPageSource).toContain('control.lifecycleAction === "start" || control.lifecycleAction === "stop" || control.lifecycleAction === "status"');
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(instance.id");
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(instance.id");
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.queryServerProcessStatus(instance.id");
|
||||
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"start\")");
|
||||
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"stop\")");
|
||||
expect(serverDetailPageSource).not.toContain('capability: "process.start"');
|
||||
|
||||
@@ -6,18 +6,24 @@ import type {
|
||||
ConfigDiffLineResponse,
|
||||
ArtifactDownloadReferenceResponse,
|
||||
ArtifactResponse,
|
||||
BackupResponse,
|
||||
ClientManagerDistributionResponse,
|
||||
DependencyCatalogResponse,
|
||||
GamePluginResponse,
|
||||
JobResponse,
|
||||
LogEntryBody,
|
||||
LogStreamResponse,
|
||||
RunDistributionResponse,
|
||||
RunUpdateJobResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
ServerConfigResponse,
|
||||
ServerInstanceResponse,
|
||||
ServerMemberResponse,
|
||||
ServerMetricsResponse,
|
||||
ServerRuntimeActionsResponse
|
||||
RuntimeBindingResponse,
|
||||
ServerRuntimeActionsResponse,
|
||||
MetricSampleResponse,
|
||||
RemoteAdapterDeclarationResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||||
import {
|
||||
@@ -34,10 +40,11 @@ import {
|
||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
isPlatformAdmin,
|
||||
type ConfigDiffView,
|
||||
type LlmSuggestionView,
|
||||
type PluginControlDescriptor,
|
||||
@@ -72,7 +79,11 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
||||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||||
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
|
||||
const [metricHistory, setMetricHistory] = useState<MetricSampleResponse[]>([]);
|
||||
const [backups, setBackups] = useState<BackupResponse[]>([]);
|
||||
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
|
||||
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
|
||||
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
|
||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
@@ -83,19 +94,30 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
}
|
||||
setInstance({ status: "loading" });
|
||||
try {
|
||||
const [detail, pluginResponse, jobResponse, runtimeResponse] = await Promise.all([
|
||||
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
|
||||
platformApiClient.getServerInstance(serverId),
|
||||
platformApiClient.listGamePlugins(),
|
||||
platformApiClient.listJobs(serverId),
|
||||
platformApiClient
|
||||
.getServerRuntimeActions(serverId)
|
||||
.then((data): LoadState<ServerRuntimeActionsResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<ServerRuntimeActionsResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" }))
|
||||
.catch((error): LoadState<ServerRuntimeActionsResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" })),
|
||||
platformApiClient
|
||||
.getServerRuntimeBinding(serverId)
|
||||
.then((data): LoadState<RuntimeBindingResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<RuntimeBindingResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })),
|
||||
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
|
||||
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
|
||||
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 }))
|
||||
]);
|
||||
setInstance({ status: "ready", data: detail });
|
||||
setPlugins(pluginResponse.items);
|
||||
setJobs(jobResponse.items);
|
||||
setRuntimeActions(runtimeResponse);
|
||||
setRuntimeBinding(bindingResponse);
|
||||
setMetricHistory(metricHistoryResponse.items);
|
||||
setBackups(backupResponse.items);
|
||||
setRemoteAdapters(adapterResponse.items);
|
||||
const artifactLists = await Promise.all(
|
||||
jobResponse.items.slice(0, 20).map((job) =>
|
||||
platformApiClient
|
||||
@@ -109,6 +131,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||
setArtifacts([]);
|
||||
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
|
||||
setRuntimeBinding({ status: "error", reason: "运行配置加载失败" });
|
||||
setMetricHistory([]);
|
||||
setBackups([]);
|
||||
setRemoteAdapters([]);
|
||||
}
|
||||
try {
|
||||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||||
@@ -211,7 +237,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
<button
|
||||
type="button"
|
||||
className="icon-command"
|
||||
disabled={!canStartServer(instance.data.state) || operations.isPending(instance.data.id, "启动服务器")}
|
||||
disabled={!canStartServer(instance.data.state) || runtimeBinding.status !== "ready" || runtimeBinding.data.status !== "complete" || operations.isPending(instance.data.id, "启动服务器")}
|
||||
onClick={() => requestLifecycle(instance.data, "start")}
|
||||
>
|
||||
<WandSparkles size={15} />
|
||||
@@ -220,7 +246,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
<button
|
||||
type="button"
|
||||
className="icon-command danger-command"
|
||||
disabled={!canStopServer(instance.data.state) || operations.isPending(instance.data.id, "停止服务器")}
|
||||
disabled={!canStopServer(instance.data.state) || runtimeBinding.status !== "ready" || runtimeBinding.data.status !== "complete" || operations.isPending(instance.data.id, "停止服务器")}
|
||||
onClick={() => requestLifecycle(instance.data, "stop")}
|
||||
>
|
||||
<Square size={15} />
|
||||
@@ -254,6 +280,16 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
</nav>
|
||||
|
||||
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
|
||||
{section === "overview" && (
|
||||
<RuntimeBindingSection
|
||||
instance={instance.data}
|
||||
plugin={plugins.find((plugin) => plugin.id === instance.data.pluginId)}
|
||||
binding={runtimeBinding}
|
||||
session={session}
|
||||
operations={operations}
|
||||
onChanged={() => void refresh()}
|
||||
/>
|
||||
)}
|
||||
{section === "overview" && (
|
||||
<RuntimeDistributionSection
|
||||
instance={instance.data}
|
||||
@@ -278,7 +314,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} />}
|
||||
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} />}
|
||||
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -554,7 +590,7 @@ interface OverviewSectionProps {
|
||||
}
|
||||
|
||||
function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectionProps) {
|
||||
const pending = jobs.filter((job) => job.state === "queued" || job.state === "accepted" || job.state === "running");
|
||||
const pending = jobs.filter((job) => job.state === "queued" || job.state === "accepted" || job.state === "running" || job.state === "retrying");
|
||||
const failed = jobs.filter((job) => job.state === "failed");
|
||||
return (
|
||||
<div className="overview-two-col">
|
||||
@@ -602,6 +638,125 @@ interface RuntimeDistributionSectionProps {
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
interface RuntimeBindingSectionProps {
|
||||
instance: ServerInstanceResponse;
|
||||
plugin?: GamePluginResponse;
|
||||
binding: LoadState<RuntimeBindingResponse>;
|
||||
session: PageComponentProps["session"];
|
||||
operations: PageComponentProps["operations"];
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
function RuntimeBindingSection({ instance, plugin, binding, session, operations, onChanged }: RuntimeBindingSectionProps) {
|
||||
const bindingData = binding.status === "ready" ? binding.data : null;
|
||||
const [profileKey, setProfileKey] = useState(bindingData?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "");
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||||
const canManage = isPlatformAdmin(session) || instance.ownerUserId === session.id;
|
||||
const activeExistingBinding = bindingData?.configured === true && (instance.state === "installing" || instance.state === "running");
|
||||
const fields = runtimeBindingFields(plugin, profileKey);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileKey(bindingData?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "");
|
||||
setValues({});
|
||||
}, [bindingData?.profileKey, bindingData?.updatedAt, plugin?.id]);
|
||||
|
||||
async function saveBinding(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const operationId = operations.begin({ intent: "更新运行配置", targetKind: "server", targetId: `${instance.id}:runtime-binding`, requester: session.displayName });
|
||||
setResult({ status: "pending", label: "正在保存运行配置" });
|
||||
try {
|
||||
const updated = await platformApiClient.updateServerRuntimeBinding(instance.id, {
|
||||
profileKey,
|
||||
bindings: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== ""))
|
||||
});
|
||||
operations.succeed(operationId, updated.status === "complete" ? "运行配置已就绪" : "运行配置已保存,仍有缺失项");
|
||||
setResult({ status: "succeeded", label: updated.status === "complete" ? "运行配置已就绪" : `仍缺少:${updated.missingKeys.join("、")}` });
|
||||
setValues({});
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : "运行配置保存失败";
|
||||
operations.fail(operationId, reason, operationId);
|
||||
setResult({ status: "failed", label: reason });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="runtime binding">
|
||||
<div className="panel-header">
|
||||
<h2>
|
||||
<ShieldCheck size={16} style={{ verticalAlign: "-2px" }} /> 运行配置绑定
|
||||
</h2>
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
</div>
|
||||
{binding.status === "loading" && <LoadingState label="正在加载运行配置…" />}
|
||||
{binding.status === "error" && <ErrorState title="运行配置加载失败" reason={binding.reason} diagnosticId={`runtime-binding:${instance.id}`} onRetry={onChanged} />}
|
||||
{bindingData && (
|
||||
<>
|
||||
<div className="server-detail-stat-strip" style={{ marginTop: 12 }}>
|
||||
<HeaderStat label="绑定状态" value={bindingData.status === "complete" ? "完整" : "待补齐"} />
|
||||
<HeaderStat label="运行模式" value={bindingData.mode || "未选择"} />
|
||||
<HeaderStat label="配置项" value={`${bindingData.keys.filter((key) => key.configured).length}/${bindingData.keys.length}`} />
|
||||
</div>
|
||||
{bindingData.reason && <p className="page-status">{bindingData.reason}</p>}
|
||||
{bindingData.missingKeys.length > 0 && <p className="page-status">缺少逻辑绑定:{bindingData.missingKeys.join("、")}</p>}
|
||||
{bindingData.keys.length > 0 && (
|
||||
<div className="tag-list" aria-label="runtime binding status">
|
||||
{bindingData.keys.map((key) => (
|
||||
<span key={key.key} className={cx("status-pill", key.configured ? "status-active" : "status-disabled")}>
|
||||
{key.key} · {key.configured ? (key.secret ? "受保护" : "已配置") : "缺失"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveBinding(event)}>
|
||||
<label>
|
||||
运行配置
|
||||
<select
|
||||
value={profileKey}
|
||||
onChange={(event) => {
|
||||
setProfileKey(event.target.value);
|
||||
setValues({});
|
||||
}}
|
||||
disabled={!canManage || activeExistingBinding}
|
||||
required
|
||||
>
|
||||
{(plugin?.runtimeProfiles?.lifecycleProfiles ?? []).map((profile) => (
|
||||
<option key={profile.key} value={profile.key}>
|
||||
{profile.key} · {profile.mode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="form-grid">
|
||||
{fields.map((field) => {
|
||||
const existing = bindingData.keys.find((key) => key.key === field.key);
|
||||
return (
|
||||
<label key={field.key}>
|
||||
{field.key}{field.required ? "(必填)" : ""}
|
||||
<input
|
||||
type={field.sensitive ? "password" : "text"}
|
||||
autoComplete="off"
|
||||
value={values[field.key] ?? ""}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
|
||||
placeholder={existing?.configured ? "已配置" : "待配置"}
|
||||
disabled={!canManage || activeExistingBinding}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button type="submit" className="primary-command" disabled={!canManage || activeExistingBinding || !profileKey}>
|
||||
<ShieldCheck size={16} />
|
||||
<span>保存运行配置</span>
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeDistributionSection({ instance, runtimeActions, session, operations, onOpenLogs, onChanged }: RuntimeDistributionSectionProps) {
|
||||
const defaults = runtimeDefaultsForPlugin(instance.pluginId);
|
||||
const [targetOs, setTargetOs] = useState(defaults.runOs);
|
||||
@@ -617,9 +772,44 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
|
||||
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||||
const [dependencyCatalog, setDependencyCatalog] = useState<LoadState<DependencyCatalogResponse>>({ status: "loading" });
|
||||
const [runUpdates, setRunUpdates] = useState<LoadState<RunUpdateJobResponse[]>>({ status: "loading" });
|
||||
const runtimeTask = useRuntimeTaskController();
|
||||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||||
|
||||
const refreshRuntimeProjections = useCallback(async () => {
|
||||
const [catalog, updates] = await Promise.all([
|
||||
platformApiClient
|
||||
.getDependencyCatalog(instance.id)
|
||||
.then((data): LoadState<DependencyCatalogResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<DependencyCatalogResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" })),
|
||||
platformApiClient
|
||||
.listRunUpdates(instance.id)
|
||||
.then((data): LoadState<RunUpdateJobResponse[]> => ({ status: "ready", data: data.items }))
|
||||
.catch((error): LoadState<RunUpdateJobResponse[]> => ({ status: "error", reason: error instanceof Error ? error.message : "Run 更新状态加载失败" }))
|
||||
]);
|
||||
setDependencyCatalog(catalog);
|
||||
setRunUpdates(updates);
|
||||
}, [instance.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRuntimeProjections();
|
||||
}, [refreshRuntimeProjections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dependencyCatalog.status !== "ready") return;
|
||||
const selectedProbe = dependencyCatalog.data.probes.find((probe) => probe.key === probeKey) ?? dependencyCatalog.data.probes[0];
|
||||
if (selectedProbe && selectedProbe.key !== probeKey) setProbeKey(selectedProbe.key);
|
||||
const matchingPlan = dependencyCatalog.data.plans.find((plan) => plan.key === installPlanKey)
|
||||
?? dependencyCatalog.data.plans.find((plan) => plan.key === selectedProbe?.installPlanKey)
|
||||
?? dependencyCatalog.data.plans[0];
|
||||
if (matchingPlan && matchingPlan.key !== installPlanKey) setInstallPlanKey(matchingPlan.key);
|
||||
}, [dependencyCatalog, installPlanKey, probeKey]);
|
||||
|
||||
const selectedDependencyProbe = dependencyCatalog.status === "ready" ? dependencyCatalog.data.probes.find((probe) => probe.key === probeKey) : undefined;
|
||||
const selectedDependencyPlan = dependencyCatalog.status === "ready" ? dependencyCatalog.data.plans.find((plan) => plan.key === installPlanKey) : undefined;
|
||||
const latestRunUpdate = runUpdates.status === "ready" ? runUpdates.data[0] : undefined;
|
||||
|
||||
const actionByKey = useMemo(() => {
|
||||
if (runtimeActions.status !== "ready") {
|
||||
return new Map<string, { available: boolean; reason?: string }>();
|
||||
@@ -676,6 +866,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
setResult({ status: "succeeded", label });
|
||||
runtimeTask.succeedTask(label);
|
||||
taskOptions?.afterSuccess?.(value);
|
||||
void refreshRuntimeProjections();
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : `${intent} 失败`;
|
||||
@@ -802,11 +993,19 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
</label>
|
||||
<label>
|
||||
依赖 probe
|
||||
<input value={probeKey} onChange={(event) => setProbeKey(event.target.value)} />
|
||||
<select value={probeKey} disabled={dependencyCatalog.status !== "ready" || dependencyCatalog.data.probes.length === 0} onChange={(event) => setProbeKey(event.target.value)}>
|
||||
{dependencyCatalog.status === "ready" && dependencyCatalog.data.probes.map((probe) => (
|
||||
<option key={probe.key} value={probe.key}>{probe.key} · {probe.state}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
安装 plan
|
||||
<input value={installPlanKey} onChange={(event) => setInstallPlanKey(event.target.value)} />
|
||||
<select value={installPlanKey} disabled={dependencyCatalog.status !== "ready" || dependencyCatalog.data.plans.length === 0} onChange={(event) => setInstallPlanKey(event.target.value)}>
|
||||
{dependencyCatalog.status === "ready" && dependencyCatalog.data.plans.map((plan) => (
|
||||
<option key={plan.key} value={plan.key}>{plan.title} · {plan.targetOs}/{plan.targetArch}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
日志源
|
||||
@@ -847,7 +1046,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
/>
|
||||
<RuntimeActionRow
|
||||
title="run 下载与更新"
|
||||
description={lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
|
||||
description={latestRunUpdate ? `最近更新 ${latestRunUpdate.targetOs}/${latestRunUpdate.targetArch} · ${runUpdatePhaseLabel(latestRunUpdate.phase)}` : lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
|
||||
disabled={!canUse("download-run")}
|
||||
reason={reasonFor("download-run")}
|
||||
actionLabel="下载 run"
|
||||
@@ -889,7 +1088,20 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
>
|
||||
{latestRunUpdate && (
|
||||
<div className="tag-list" aria-label="latest Run update status">
|
||||
<span className={cx("status-pill", latestRunUpdate.phase === "succeeded" ? "status-active" : latestRunUpdate.phase === "failed" || latestRunUpdate.phase === "rolled-back" ? "status-disabled" : "status-pending")}>
|
||||
phase {latestRunUpdate.phase}
|
||||
</span>
|
||||
<span className="provider-id" title={latestRunUpdate.checksum}>checksum {shortChecksum(latestRunUpdate.checksum)}</span>
|
||||
<span className="provider-id">release {latestRunUpdate.targetRelease ?? "pending"}</span>
|
||||
<span className="provider-id">rollback {latestRunUpdate.rollback ? "yes" : "no"}</span>
|
||||
{latestRunUpdate.message && <span className="provider-id">audit {latestRunUpdate.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
{runUpdates.status === "error" && <ResultBadge status="failed" label={runUpdates.reason} />}
|
||||
</RuntimeActionRow>
|
||||
<RuntimeActionRow
|
||||
title="run 密钥"
|
||||
description="重置后旧 run 包会失效,必须重新生成并重新部署。"
|
||||
@@ -965,9 +1177,9 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
/>
|
||||
<RuntimeActionRow
|
||||
title="依赖"
|
||||
description={`检查 ${probeKey},安装计划 ${installPlanKey || "未填写"}`}
|
||||
disabled={!canUse("dependencies-check")}
|
||||
reason={reasonFor("dependencies-check")}
|
||||
description={dependencyCatalog.status === "ready" ? `${dependencyCatalog.data.pluginId}@${dependencyCatalog.data.pluginVersion} · ${dependencyCatalog.data.profileKey} · ${dependencyCatalog.data.targetOs}/${dependencyCatalog.data.targetArch}` : "正在读取 Platform 审核后的依赖目录"}
|
||||
disabled={!canUse("dependencies-check") || dependencyCatalog.status !== "ready" || !selectedDependencyProbe}
|
||||
reason={dependencyCatalog.status === "error" ? dependencyCatalog.reason : dependencyCatalog.status !== "ready" || !selectedDependencyProbe ? "依赖目录尚未就绪" : reasonFor("dependencies-check")}
|
||||
actionLabel="依赖检查"
|
||||
onAction={() =>
|
||||
void runOperation(
|
||||
@@ -982,21 +1194,34 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
)
|
||||
}
|
||||
secondaryLabel="依赖安装"
|
||||
secondaryDisabled={!canUse("dependencies-install") || !installPlanKey.trim()}
|
||||
secondaryReason={!installPlanKey.trim() ? "请填写插件声明的 install plan" : reasonFor("dependencies-install")}
|
||||
secondaryDisabled={!canUse("dependencies-install") || !selectedDependencyPlan || selectedDependencyProbe?.installPlanKey !== selectedDependencyPlan.key}
|
||||
secondaryReason={!selectedDependencyPlan ? "请选择 Platform 返回的审核计划" : selectedDependencyProbe?.installPlanKey !== selectedDependencyPlan.key ? "所选计划不属于当前 probe" : reasonFor("dependencies-install")}
|
||||
onSecondary={() =>
|
||||
void runOperation(
|
||||
"依赖安装",
|
||||
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
|
||||
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey, selectedDependencyPlan?.digest ?? "")),
|
||||
(job) => `依赖安装任务已排队,job ${job.id}`,
|
||||
{
|
||||
description: `使用 ${installPlanKey} 安装计划派发依赖安装任务,并保留 job 追踪。`,
|
||||
description: `审批 ${installPlanKey} 的 immutable digest ${shortChecksum(selectedDependencyPlan?.digest ?? "")} 后派发依赖安装任务。`,
|
||||
stages: runtimeDependencyStages,
|
||||
executeStageIndex: 2
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
>
|
||||
{selectedDependencyProbe && (
|
||||
<div className="tag-list" aria-label="dependency status and approved plan">
|
||||
<span className={cx("status-pill", selectedDependencyProbe.state === "present" ? "status-active" : selectedDependencyProbe.state === "failed" ? "status-disabled" : "status-pending")}>
|
||||
{selectedDependencyProbe.key} · {selectedDependencyProbe.state}
|
||||
</span>
|
||||
<span className="provider-id">required {selectedDependencyProbe.required ? "yes" : "no"}</span>
|
||||
{selectedDependencyProbe.evidence && <span className="provider-id">evidence {selectedDependencyProbe.evidence}</span>}
|
||||
{selectedDependencyPlan && <span className="provider-id" title={selectedDependencyPlan.digest}>digest {shortChecksum(selectedDependencyPlan.digest)}</span>}
|
||||
{selectedDependencyPlan && <span className="provider-id">steps {selectedDependencyPlan.steps.map((step) => `${step.type}:${step.packageManager ?? step.downloadHost ?? step.targetKey}`).join(" → ")}</span>}
|
||||
</div>
|
||||
)}
|
||||
{dependencyCatalog.status === "error" && <ResultBadge status="failed" label={dependencyCatalog.reason} />}
|
||||
</RuntimeActionRow>
|
||||
<RuntimeActionRow
|
||||
title="日志"
|
||||
description="实时日志来自平台日志 API,历史日志通过 backfill job 返回 cursor/ref。"
|
||||
@@ -1098,6 +1323,24 @@ function runtimeDefaultsForPlugin(pluginId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function shortChecksum(value: string): string {
|
||||
if (!value) return "unavailable";
|
||||
return value.length > 22 ? `${value.slice(0, 22)}…` : value;
|
||||
}
|
||||
|
||||
function runUpdatePhaseLabel(phase: RunUpdateJobResponse["phase"]): string {
|
||||
switch (phase) {
|
||||
case "queued": return "等待下载";
|
||||
case "downloading": return "分块下载与校验";
|
||||
case "staged": return "已安全暂存";
|
||||
case "restart-requested": return "等待重启激活";
|
||||
case "activating": return "激活与健康确认";
|
||||
case "succeeded": return "更新成功";
|
||||
case "rolled-back": return "已回滚";
|
||||
case "failed": return "更新失败";
|
||||
}
|
||||
}
|
||||
|
||||
function safeRuntimeRef(ref: string): string {
|
||||
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
|
||||
return ref;
|
||||
@@ -1333,6 +1576,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
||||
try {
|
||||
const preview = await platformApiClient.previewServerConfigDiff(serverId, {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
key: defaultConfigKey,
|
||||
proposedContent: draft
|
||||
});
|
||||
@@ -1352,6 +1596,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
||||
try {
|
||||
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
|
||||
expectedConfigVersion: diff.configVersion ?? instance.configVersion,
|
||||
expectedChecksum: diff.checksum ?? instance.configChecksum,
|
||||
key: diff.key ?? defaultConfigKey,
|
||||
proposedContent: diff.nextContent,
|
||||
proposedContentInputRef: diff.proposedContentInputRef,
|
||||
@@ -1371,7 +1616,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
|
||||
<article className="console-panel" aria-label="server configuration">
|
||||
<div className="panel-header">
|
||||
<h2>配置</h2>
|
||||
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}</span>}
|
||||
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}{instance.configChecksum ? ` · ${instance.configChecksum.slice(0, 18)}` : ""}</span>}
|
||||
</div>
|
||||
{writeOperation && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
@@ -1440,10 +1685,10 @@ interface PluginControlsSectionProps {
|
||||
function controlsForPlugin(plugin: GamePluginResponse): PluginControlDescriptor[] {
|
||||
const controls: PluginControlDescriptor[] = [];
|
||||
for (const [action] of Object.entries(plugin.lifecycleActions)) {
|
||||
if (action === "install" || action === "restart" || action === "status") {
|
||||
if (action === "install" || action === "restart") {
|
||||
continue;
|
||||
}
|
||||
if (action !== "start" && action !== "stop") {
|
||||
if (action !== "start" && action !== "stop" && action !== "status") {
|
||||
continue;
|
||||
}
|
||||
controls.push({
|
||||
@@ -1492,6 +1737,8 @@ function lifecycleControlLabel(action: string): string {
|
||||
return "启动进程";
|
||||
case "stop":
|
||||
return "停止进程";
|
||||
case "status":
|
||||
return "查询进程";
|
||||
case "restart":
|
||||
return "重启进程";
|
||||
default:
|
||||
@@ -1538,11 +1785,13 @@ function PluginControlsSection({ serverId, instance, plugins, artifacts, session
|
||||
requester: session.displayName
|
||||
});
|
||||
try {
|
||||
if (control.lifecycleAction === "start" || control.lifecycleAction === "stop") {
|
||||
if (control.lifecycleAction === "start" || control.lifecycleAction === "stop" || control.lifecycleAction === "status") {
|
||||
const result =
|
||||
control.lifecycleAction === "start"
|
||||
? await platformApiClient.startServerInstance(instance.id, serverLifecycleCommandRequest(instance, "start"))
|
||||
: await platformApiClient.stopServerInstance(instance.id, serverLifecycleCommandRequest(instance, "stop"));
|
||||
: control.lifecycleAction === "stop"
|
||||
? await platformApiClient.stopServerInstance(instance.id, serverLifecycleCommandRequest(instance, "stop"))
|
||||
: await platformApiClient.queryServerProcessStatus(instance.id, serverLifecycleCommandRequest(instance, "status"));
|
||||
operations.succeed(operationId, `平台生命周期任务 ${result.job.id} 已派发(${result.job.capability})`, result.job);
|
||||
return;
|
||||
}
|
||||
@@ -1812,6 +2061,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
const preview = response.suggestedConfig
|
||||
? await platformApiClient.previewServerConfigDiff(serverId, {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
key: defaultConfigKey,
|
||||
proposedContent: response.suggestedConfig
|
||||
})
|
||||
@@ -1837,6 +2087,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
try {
|
||||
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
|
||||
expectedConfigVersion: suggestion.diff.configVersion ?? instance.configVersion,
|
||||
expectedChecksum: suggestion.diff.checksum ?? instance.configChecksum,
|
||||
key: suggestion.diff.key ?? defaultConfigKey,
|
||||
proposedContent: suggestion.diff.nextContent,
|
||||
proposedContentInputRef: suggestion.diff.proposedContentInputRef,
|
||||
@@ -1981,6 +2232,7 @@ export function configDiffViewFromPreview(preview: ServerConfigDiffPreviewRespon
|
||||
return {
|
||||
serverInstanceId: preview.serverInstanceId,
|
||||
configVersion: preview.configVersion,
|
||||
checksum: preview.checksum,
|
||||
key: preview.key,
|
||||
source: preview.source,
|
||||
summary: `+${added} / -${removed} 行变更`,
|
||||
@@ -2002,9 +2254,12 @@ interface HistorySectionProps {
|
||||
serverOperations: PageComponentProps["operations"]["operations"];
|
||||
jobs: JobResponse[];
|
||||
artifacts: ArtifactResponse[];
|
||||
metricHistory: MetricSampleResponse[];
|
||||
backups: BackupResponse[];
|
||||
remoteAdapters: RemoteAdapterDeclarationResponse[];
|
||||
}
|
||||
|
||||
function HistorySection({ serverId, serverOperations, jobs, artifacts }: HistorySectionProps) {
|
||||
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
|
||||
return (
|
||||
<div className="overview-two-col" aria-label="operation history">
|
||||
<article className="console-panel">
|
||||
@@ -2071,15 +2326,49 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts }: History
|
||||
任务 <code>{job.id}</code>
|
||||
</span>
|
||||
<span>进度 {job.progress.percent}%</span>
|
||||
<span>
|
||||
尝试 {job.attempt}/{job.retryPolicy.maxAttempts}
|
||||
</span>
|
||||
{job.nextAttemptAt && <span>下次尝试 {new Date(job.nextAttemptAt).toLocaleString()}</span>}
|
||||
{job.lastReconciledAt && <span>最近协调 {new Date(job.lastReconciledAt).toLocaleString()}</span>}
|
||||
<span>{new Date(job.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
{job.progress.message && <span className="provider-id">{job.progress.message}</span>}
|
||||
{job.cancelReason && <span className="provider-id">取消原因:{job.cancelReason}</span>}
|
||||
{job.reconcileOutcome && <span className="provider-id">协调结果:{job.reconcileOutcome}</span>}
|
||||
{job.executionResult && (job.executionResult.processState || job.executionResult.checksum || job.executionResult.version !== undefined) && (
|
||||
<span className="provider-id">
|
||||
执行结果:{job.executionResult.processState ?? job.executionResult.kind ?? "已记录"}
|
||||
{job.executionResult.version !== undefined ? ` · v${job.executionResult.version}` : ""}
|
||||
{job.executionResult.checksum ? ` · ${job.executionResult.checksum.slice(0, 18)}` : ""}
|
||||
{job.executionResult.sizeBytes !== undefined ? ` · ${job.executionResult.sizeBytes} B` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<ArtifactDownloadPanel serverId={serverId} artifacts={artifacts} />
|
||||
<article className="console-panel" aria-label="durable observability">
|
||||
<div className="panel-header">
|
||||
<h2>持久化观测</h2>
|
||||
</div>
|
||||
<div className="operation-list">
|
||||
<div className="operation-item">
|
||||
<div className="operation-item-head"><strong>指标样本</strong><span className="status-pill status-active">{metricHistory.length} 条</span></div>
|
||||
<div className="operation-meta"><span>最新采集 {metricHistory.length > 0 ? new Date(metricHistory[metricHistory.length - 1].collectedAt).toLocaleString() : "暂无"}</span></div>
|
||||
</div>
|
||||
<div className="operation-item">
|
||||
<div className="operation-item-head"><strong>备份记录</strong><span className="status-pill status-active">{backups.length} 条</span></div>
|
||||
<div className="operation-meta">{backups.slice(0, 4).map((backup) => <span key={backup.id}>{backup.id} · {backup.state} · {backup.checksum.slice(0, 18)}</span>)}</div>
|
||||
</div>
|
||||
<div className="operation-item">
|
||||
<div className="operation-item-head"><strong>远端适配器声明</strong><span className="status-pill status-active">{remoteAdapters.length} 个</span></div>
|
||||
<div className="operation-meta">{remoteAdapters.slice(0, 4).map((adapter) => <span key={adapter.key}>{adapter.key} · {adapter.kind} · {adapter.targetKeys.join(", ")}</span>)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
endpointLabel,
|
||||
pendingJobsForServer,
|
||||
pluginLabel,
|
||||
runtimeBindingFields,
|
||||
type ServerCreateFormState
|
||||
} from "../contracts/serverManagement";
|
||||
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
|
||||
@@ -75,13 +76,21 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
setEndpoints(endpointResponse.items);
|
||||
setInstances(instanceResponse.items);
|
||||
setJobs(jobResponse.items);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
pluginId: pluginResponse.items.some((plugin) => plugin.id === current.pluginId) ? current.pluginId : pluginResponse.items[0]?.id || "",
|
||||
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
|
||||
? current.runEndpointId
|
||||
: endpointResponse.items[0]?.id || ""
|
||||
}));
|
||||
setForm((current) => {
|
||||
const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0];
|
||||
const profileKey = plugin?.runtimeProfiles?.lifecycleProfiles?.some((profile) => profile.key === current.profileKey)
|
||||
? current.profileKey
|
||||
: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "";
|
||||
return {
|
||||
...current,
|
||||
pluginId: plugin?.id ?? "",
|
||||
profileKey,
|
||||
bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {},
|
||||
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
|
||||
? current.runEndpointId
|
||||
: endpointResponse.items[0]?.id || ""
|
||||
};
|
||||
});
|
||||
setListState("ready");
|
||||
setListError("");
|
||||
} catch (error) {
|
||||
@@ -115,10 +124,25 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
|
||||
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
|
||||
const createPending = operations.isPending("platform", "创建服务器");
|
||||
const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId);
|
||||
const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey);
|
||||
|
||||
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
||||
const { name, value } = event.target;
|
||||
setForm((current) => ({ ...current, [name]: value }));
|
||||
setForm((current) => {
|
||||
if (name === "pluginId") {
|
||||
const plugin = plugins.find((item) => item.id === value);
|
||||
return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {} };
|
||||
}
|
||||
if (name === "profileKey") {
|
||||
return { ...current, profileKey: value, bindings: {} };
|
||||
}
|
||||
return { ...current, [name]: value };
|
||||
});
|
||||
}
|
||||
|
||||
function updateBinding(key: string, value: string) {
|
||||
setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } }));
|
||||
}
|
||||
|
||||
async function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -203,7 +227,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
return `依赖检查任务已排队,job ${job.id}`;
|
||||
}
|
||||
if (action === "dependencies-install") {
|
||||
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
|
||||
const catalog = await platformApiClient.getDependencyCatalog(instance.id);
|
||||
const plan = catalog.plans.find((candidate) => candidate.key === defaults.installPlanKey);
|
||||
const probe = catalog.probes.find((candidate) => candidate.key === defaults.probeKey);
|
||||
if (!plan || probe?.installPlanKey !== plan.key) throw new Error("Platform 未返回与当前 probe 匹配的审核安装计划");
|
||||
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
|
||||
return `依赖安装任务已排队,job ${job.id}`;
|
||||
}
|
||||
if (action === "live-logs") {
|
||||
@@ -384,8 +412,31 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
运行配置
|
||||
<select name="profileKey" value={form.profileKey} onChange={updateForm} required>
|
||||
{(selectedCreatePlugin?.runtimeProfiles?.lifecycleProfiles ?? []).map((profile) => (
|
||||
<option key={profile.key} value={profile.key}>
|
||||
{profile.key} · {profile.mode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{createBindingFields.map((field) => (
|
||||
<label key={field.key}>
|
||||
{field.key}{field.required ? "(必填)" : ""}
|
||||
<input
|
||||
type={field.sensitive ? "password" : "text"}
|
||||
autoComplete="off"
|
||||
value={form.bindings[field.key] ?? ""}
|
||||
onChange={(event) => updateBinding(field.key, event.target.value)}
|
||||
placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"}
|
||||
required={field.required}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button type="submit" className="primary-command" disabled={createPending} title="创建服务器">
|
||||
<button type="submit" className="primary-command" disabled={createPending || !form.profileKey} title="创建服务器">
|
||||
<Sparkles size={16} />
|
||||
<span>{createPending ? "创建中…" : "创建并安装"}</span>
|
||||
</button>
|
||||
|
||||
@@ -32,4 +32,16 @@ describe("ai provider form schemas", () => {
|
||||
});
|
||||
expect(request.models).toEqual(["gpt-oss:20b"]);
|
||||
});
|
||||
|
||||
it("keeps an existing configured secret opaque during edits", () => {
|
||||
const request = aiProviderUpdateRequestFromForm({
|
||||
...emptyAiProviderForm(),
|
||||
id: "ai.openai",
|
||||
apiKeyRef: "",
|
||||
apiKeyConfigured: true
|
||||
});
|
||||
|
||||
expect(request.apiKeyRef).toBe("");
|
||||
expect(JSON.stringify(request)).not.toContain("secret://providers/openai");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSafeJobResponse } from "./jobs";
|
||||
|
||||
const retryingJob = {
|
||||
id: "job-1",
|
||||
runEndpointId: "run-local",
|
||||
capability: "process.start",
|
||||
idempotencyKey: "idem-1",
|
||||
state: "retrying",
|
||||
progress: { percent: 10, message: "Run acknowledgement deadline expired" },
|
||||
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 2, maxBackoffSeconds: 60 },
|
||||
attempt: 1,
|
||||
nextAttemptAt: "2026-07-18T12:00:02Z",
|
||||
reconcileCount: 1,
|
||||
reconcileOutcome: "missing from Run journal",
|
||||
createdAt: "2026-07-18T12:00:00Z",
|
||||
updatedAt: "2026-07-18T12:00:00Z"
|
||||
};
|
||||
|
||||
describe("safe job projection schema", () => {
|
||||
it("accepts retry and reconciliation metadata", () => {
|
||||
expect(parseSafeJobResponse(retryingJob)).toMatchObject({ state: "retrying", attempt: 1, retryPolicy: { maxAttempts: 3 }, reconcileCount: 1 });
|
||||
});
|
||||
|
||||
it.each(["leaseToken", "leaseTokenHash", "sessionToken", "secretRef", "hostPath", "socket"])("rejects forbidden %s fields", (field) => {
|
||||
expect(() => parseSafeJobResponse({ ...retryingJob, [field]: "forbidden" })).toThrow(/forbidden field/);
|
||||
});
|
||||
|
||||
it("accepts safe typed execution metadata without private content", () => {
|
||||
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" }
|
||||
});
|
||||
expect(parsed.executionResult).toMatchObject({ kind: "file.write", version: 2, sizeBytes: 18 });
|
||||
expect(parsed.executionResult).not.toHaveProperty("content");
|
||||
expect(() => parseSafeJobResponse({ ...retryingJob, executionResult: { content: "private" } })).toThrow(/forbidden field/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { JobResponse, JobState } from "../api/types";
|
||||
|
||||
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
|
||||
const forbiddenProjectionKeys = new Set(["leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket", "credential", "content"]);
|
||||
|
||||
export function parseSafeJobResponse(value: unknown): JobResponse {
|
||||
if (!isRecord(value)) throw new Error("job projection must be an object");
|
||||
rejectForbiddenKeys(value);
|
||||
const state = requiredString(value, "state") as JobState;
|
||||
if (!jobStates.has(state)) throw new Error("job state is invalid");
|
||||
const progress = requiredRecord(value, "progress");
|
||||
const retryPolicy = requiredRecord(value, "retryPolicy");
|
||||
const parsed: JobResponse = {
|
||||
id: requiredString(value, "id"),
|
||||
serverInstanceId: optionalString(value, "serverInstanceId"),
|
||||
runEndpointId: requiredString(value, "runEndpointId"),
|
||||
capability: requiredString(value, "capability"),
|
||||
targetKey: optionalString(value, "targetKey"),
|
||||
inputRef: optionalString(value, "inputRef"),
|
||||
idempotencyKey: requiredString(value, "idempotencyKey"),
|
||||
state,
|
||||
progress: { percent: requiredNumber(progress, "percent"), message: optionalString(progress, "message") },
|
||||
resultRef: optionalString(value, "resultRef"),
|
||||
executionResult: optionalExecutionResult(value, "executionResult"),
|
||||
retryPolicy: {
|
||||
maxAttempts: requiredNumber(retryPolicy, "maxAttempts"),
|
||||
initialBackoffSeconds: requiredNumber(retryPolicy, "initialBackoffSeconds"),
|
||||
maxBackoffSeconds: requiredNumber(retryPolicy, "maxBackoffSeconds")
|
||||
},
|
||||
attempt: requiredNumber(value, "attempt"),
|
||||
nextAttemptAt: optionalString(value, "nextAttemptAt"),
|
||||
ackDeadlineAt: optionalString(value, "ackDeadlineAt"),
|
||||
leaseExpiresAt: optionalString(value, "leaseExpiresAt"),
|
||||
cancelReason: optionalString(value, "cancelReason"),
|
||||
cancelRequestedAt: optionalString(value, "cancelRequestedAt"),
|
||||
cancelCompletedAt: optionalString(value, "cancelCompletedAt"),
|
||||
terminalAt: optionalString(value, "terminalAt"),
|
||||
lastReconciledAt: optionalString(value, "lastReconciledAt"),
|
||||
reconcileCount: requiredNumber(value, "reconcileCount"),
|
||||
reconcileOutcome: optionalString(value, "reconcileOutcome"),
|
||||
createdAt: requiredString(value, "createdAt"),
|
||||
updatedAt: requiredString(value, "updatedAt")
|
||||
};
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalExecutionResult(value: Record<string, unknown>, key: string): JobResponse["executionResult"] {
|
||||
const field = value[key];
|
||||
if (field === undefined) return undefined;
|
||||
if (!isRecord(field)) throw new Error(`${key} must be an object`);
|
||||
rejectForbiddenKeys(field);
|
||||
const result: NonNullable<JobResponse["executionResult"]> = {
|
||||
kind: optionalString(field, "kind"),
|
||||
processState: optionalString(field, "processState"),
|
||||
exitClassification: optionalString(field, "exitClassification"),
|
||||
exitCode: optionalSignedNumber(field, "exitCode"),
|
||||
version: optionalNumber(field, "version"),
|
||||
checksum: optionalString(field, "checksum"),
|
||||
sizeBytes: optionalNumber(field, "sizeBytes"),
|
||||
auditSummary: optionalString(field, "auditSummary")
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
function rejectForbiddenKeys(value: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (forbiddenProjectionKeys.has(key)) throw new Error(`job projection contains forbidden field ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredRecord(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const field = value[key];
|
||||
if (!isRecord(field)) throw new Error(`${key} must be an object`);
|
||||
rejectForbiddenKeys(field);
|
||||
return field;
|
||||
}
|
||||
|
||||
function requiredString(value: Record<string, unknown>, key: string): string {
|
||||
const field = value[key];
|
||||
if (typeof field !== "string" || field.trim() === "") throw new Error(`${key} must be a non-empty string`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalString(value: Record<string, unknown>, key: string): string | undefined {
|
||||
const field = value[key];
|
||||
if (field === undefined) return undefined;
|
||||
if (typeof field !== "string") throw new Error(`${key} must be a string`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalSignedNumber(value: Record<string, unknown>, key: string): number | undefined {
|
||||
const field = value[key];
|
||||
if (field === undefined) return undefined;
|
||||
if (typeof field !== "number" || !Number.isFinite(field)) throw new Error(`${key} must be a number`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalNumber(value: Record<string, unknown>, key: string): number | undefined {
|
||||
const field = optionalSignedNumber(value, key);
|
||||
if (field !== undefined && field < 0) throw new Error(`${key} must be non-negative`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function requiredNumber(value: Record<string, unknown>, key: string): number {
|
||||
const field = value[key];
|
||||
if (typeof field !== "number" || !Number.isFinite(field) || field < 0) throw new Error(`${key} must be a non-negative number`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSafeDependencyCatalog, parseSafeRunUpdateList } from "./runtimeUpdates";
|
||||
|
||||
const digest = `sha256:${"a".repeat(64)}`;
|
||||
|
||||
describe("safe dependency and Run update projections", () => {
|
||||
it("accepts reviewable plans, safe evidence, checksums, and rollback phases", () => {
|
||||
const catalog = parseSafeDependencyCatalog({
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.runtime",
|
||||
pluginVersion: "1.0.0",
|
||||
profileKey: "local",
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
probes: [{ key: "java", kind: "java.version", required: true, state: "present", evidence: "OpenJDK 21", installPlanKey: "java-install" }],
|
||||
plans: [{ key: "java-install", title: "Install Java", targetOs: "linux", targetArch: "amd64", digest, steps: [{ type: "package", targetKey: "java", packageManager: "apt", packageName: "openjdk-21-jre" }] }],
|
||||
updatedAt: "2026-07-18T12:00:00Z"
|
||||
});
|
||||
expect(catalog.plans[0]).toMatchObject({ digest, targetOs: "linux" });
|
||||
|
||||
const updates = parseSafeRunUpdateList({
|
||||
items: [{
|
||||
id: "update-1",
|
||||
serverInstanceId: "server-1",
|
||||
runEndpointId: "run-1",
|
||||
artifactId: "artifact-1",
|
||||
checksum: digest,
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
targetRelease: "release-2",
|
||||
previousVersion: "release-1",
|
||||
jobId: "job-1",
|
||||
status: "failed",
|
||||
phase: "rolled-back",
|
||||
message: "previous executable restored",
|
||||
rollback: true,
|
||||
createdAt: "2026-07-18T12:00:00Z",
|
||||
updatedAt: "2026-07-18T12:01:00Z"
|
||||
}],
|
||||
count: 1
|
||||
});
|
||||
expect(updates.items[0]).toMatchObject({ phase: "rolled-back", rollback: true, checksum: digest });
|
||||
});
|
||||
|
||||
it.each(["leaseToken", "sessionToken", "secretRef", "hostPath", "socket", "credential", "pid", "payload", "bindings"])("rejects forbidden %s fields recursively", (field) => {
|
||||
expect(() => parseSafeDependencyCatalog({
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.runtime",
|
||||
pluginVersion: "1.0.0",
|
||||
profileKey: "local",
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
probes: [{ key: "java", kind: "java.version", required: true, state: "unknown", [field]: "private" }],
|
||||
plans: [],
|
||||
updatedAt: "2026-07-18T12:00:00Z"
|
||||
})).toThrow(/forbidden field/);
|
||||
});
|
||||
|
||||
it("rejects raw host paths or credentials hidden in safe-looking evidence", () => {
|
||||
expect(() => parseSafeDependencyCatalog({
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.runtime",
|
||||
pluginVersion: "1.0.0",
|
||||
profileKey: "local",
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
probes: [{ key: "java", kind: "java.version", required: true, state: "present", evidence: "/Users/operator/private" }],
|
||||
plans: [],
|
||||
updatedAt: "2026-07-18T12:00:00Z"
|
||||
})).toThrow(/unsafe runtime details/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
DependencyCatalogResponse,
|
||||
DependencyPlanStepViewResponse,
|
||||
DependencyPlanViewResponse,
|
||||
DependencyProbeViewResponse,
|
||||
DependencyState,
|
||||
RunUpdateJobListResponse,
|
||||
RunUpdateJobResponse,
|
||||
RunUpdatePhase
|
||||
} from "../api/types";
|
||||
|
||||
const dependencyStates = new Set<DependencyState>(["unknown", "present", "missing", "installing", "failed"]);
|
||||
const updatePhases = new Set<RunUpdatePhase>(["queued", "downloading", "staged", "restart-requested", "activating", "succeeded", "rolled-back", "failed"]);
|
||||
const updateStatuses = new Set(["queued", "running", "succeeded", "failed", "denied"]);
|
||||
const forbiddenProjectionKeys = new Set([
|
||||
"leasetoken",
|
||||
"leasetokenhash",
|
||||
"leasesessiongeneration",
|
||||
"sessiontoken",
|
||||
"runtoken",
|
||||
"secretref",
|
||||
"hostpath",
|
||||
"executablepath",
|
||||
"stagingpath",
|
||||
"backuppath",
|
||||
"socket",
|
||||
"credential",
|
||||
"pid",
|
||||
"content",
|
||||
"payload",
|
||||
"bindings",
|
||||
"downloadref"
|
||||
]);
|
||||
const unsafeProjectionText = /(?:\/Users\/|\/home\/|\/var\/run\/|[A-Za-z]:\\|unix:\/\/|tcp:\/\/|Bearer\s+|password=|token=|sk-[A-Za-z0-9_-]+)/i;
|
||||
|
||||
export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse {
|
||||
const record = requiredRecordValue(value, "dependency catalog");
|
||||
rejectForbiddenProjection(record);
|
||||
return {
|
||||
serverInstanceId: requiredString(record, "serverInstanceId"),
|
||||
pluginId: requiredString(record, "pluginId"),
|
||||
pluginVersion: requiredString(record, "pluginVersion"),
|
||||
profileKey: requiredString(record, "profileKey"),
|
||||
targetOs: requiredString(record, "targetOs"),
|
||||
targetArch: requiredString(record, "targetArch"),
|
||||
probes: requiredArray(record, "probes").map(parseDependencyProbe),
|
||||
plans: requiredArray(record, "plans").map(parseDependencyPlan),
|
||||
updatedAt: requiredString(record, "updatedAt")
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSafeRunUpdateList(value: unknown): RunUpdateJobListResponse {
|
||||
const record = requiredRecordValue(value, "Run update list");
|
||||
rejectForbiddenProjection(record);
|
||||
const items = requiredArray(record, "items").map((item) => parseRunUpdate(requiredRecordValue(item, "Run update")));
|
||||
const count = requiredNumber(record, "count");
|
||||
if (count !== items.length) throw new Error("Run update count does not match items");
|
||||
return { items, count };
|
||||
}
|
||||
|
||||
export function parseSafeRunUpdate(value: unknown): RunUpdateJobResponse {
|
||||
const record = requiredRecordValue(value, "Run update");
|
||||
rejectForbiddenProjection(record);
|
||||
return parseRunUpdate(record);
|
||||
}
|
||||
|
||||
function parseDependencyProbe(value: unknown): DependencyProbeViewResponse {
|
||||
const record = requiredRecordValue(value, "dependency probe");
|
||||
const state = requiredString(record, "state") as DependencyState;
|
||||
if (!dependencyStates.has(state)) throw new Error("dependency state is invalid");
|
||||
return {
|
||||
key: requiredString(record, "key"),
|
||||
kind: requiredString(record, "kind"),
|
||||
required: requiredBoolean(record, "required"),
|
||||
minimumVersion: optionalSafeString(record, "minimumVersion"),
|
||||
state,
|
||||
evidence: optionalSafeString(record, "evidence"),
|
||||
installPlanKey: optionalString(record, "installPlanKey")
|
||||
};
|
||||
}
|
||||
|
||||
function parseDependencyPlan(value: unknown): DependencyPlanViewResponse {
|
||||
const record = requiredRecordValue(value, "dependency plan");
|
||||
return {
|
||||
key: requiredString(record, "key"),
|
||||
title: requiredString(record, "title"),
|
||||
targetOs: requiredString(record, "targetOs"),
|
||||
targetArch: requiredString(record, "targetArch"),
|
||||
digest: requiredChecksum(record, "digest"),
|
||||
steps: requiredArray(record, "steps").map(parseDependencyStep)
|
||||
};
|
||||
}
|
||||
|
||||
function parseDependencyStep(value: unknown): DependencyPlanStepViewResponse {
|
||||
const record = requiredRecordValue(value, "dependency plan step");
|
||||
const downloadHost = optionalString(record, "downloadHost");
|
||||
if (downloadHost && (downloadHost.includes("/") || downloadHost.includes("@") || downloadHost.includes(":"))) throw new Error("dependency download host is invalid");
|
||||
return {
|
||||
type: requiredString(record, "type"),
|
||||
targetKey: requiredString(record, "targetKey"),
|
||||
packageManager: optionalString(record, "packageManager"),
|
||||
packageName: optionalString(record, "packageName"),
|
||||
version: optionalString(record, "version"),
|
||||
downloadHost,
|
||||
sizeBytes: optionalNumber(record, "sizeBytes")
|
||||
};
|
||||
}
|
||||
|
||||
function parseRunUpdate(record: Record<string, unknown>): RunUpdateJobResponse {
|
||||
const phase = requiredString(record, "phase") as RunUpdatePhase;
|
||||
if (!updatePhases.has(phase)) throw new Error("Run update phase is invalid");
|
||||
const status = requiredString(record, "status");
|
||||
if (!updateStatuses.has(status)) throw new Error("Run update status is invalid");
|
||||
return {
|
||||
id: requiredString(record, "id"),
|
||||
serverInstanceId: requiredString(record, "serverInstanceId"),
|
||||
runEndpointId: requiredString(record, "runEndpointId"),
|
||||
artifactId: requiredString(record, "artifactId"),
|
||||
checksum: requiredChecksum(record, "checksum"),
|
||||
targetOs: requiredString(record, "targetOs"),
|
||||
targetArch: requiredString(record, "targetArch"),
|
||||
targetRelease: optionalString(record, "targetRelease"),
|
||||
previousVersion: optionalString(record, "previousVersion"),
|
||||
jobId: optionalString(record, "jobId"),
|
||||
idempotencyKey: optionalString(record, "idempotencyKey"),
|
||||
status,
|
||||
phase,
|
||||
message: optionalSafeString(record, "message"),
|
||||
rollback: requiredBoolean(record, "rollback"),
|
||||
createdAt: requiredString(record, "createdAt"),
|
||||
updatedAt: requiredString(record, "updatedAt")
|
||||
};
|
||||
}
|
||||
|
||||
function rejectForbiddenProjection(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(rejectForbiddenProjection);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) return;
|
||||
for (const [key, field] of Object.entries(value)) {
|
||||
if (forbiddenProjectionKeys.has(key.toLowerCase())) throw new Error(`runtime projection contains forbidden field ${key}`);
|
||||
rejectForbiddenProjection(field);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredRecordValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredArray(value: Record<string, unknown>, key: string): unknown[] {
|
||||
const field = value[key];
|
||||
if (!Array.isArray(field)) throw new Error(`${key} must be an array`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function requiredString(value: Record<string, unknown>, key: string): string {
|
||||
const field = value[key];
|
||||
if (typeof field !== "string" || field.trim() === "") throw new Error(`${key} must be a non-empty string`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalString(value: Record<string, unknown>, key: string): string | undefined {
|
||||
const field = value[key];
|
||||
if (field === undefined) return undefined;
|
||||
if (typeof field !== "string") throw new Error(`${key} must be a string`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalSafeString(value: Record<string, unknown>, key: string): string | undefined {
|
||||
const field = optionalString(value, key);
|
||||
if (field && unsafeProjectionText.test(field)) throw new Error(`${key} contains unsafe runtime details`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function requiredChecksum(value: Record<string, unknown>, key: string): string {
|
||||
const field = requiredString(value, key);
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(field)) throw new Error(`${key} must be a SHA-256 checksum`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function requiredNumber(value: Record<string, unknown>, key: string): number {
|
||||
const field = value[key];
|
||||
if (typeof field !== "number" || !Number.isFinite(field) || field < 0) throw new Error(`${key} must be a non-negative number`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalNumber(value: Record<string, unknown>, key: string): number | undefined {
|
||||
if (value[key] === undefined) return undefined;
|
||||
return requiredNumber(value, key);
|
||||
}
|
||||
|
||||
function requiredBoolean(value: Record<string, unknown>, key: string): boolean {
|
||||
const field = value[key];
|
||||
if (typeof field !== "boolean") throw new Error(`${key} must be a boolean`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
|
||||
import { serverCreateRequestFromForm } from "./serverManagement";
|
||||
|
||||
const plugin: GamePluginResponse = {
|
||||
id: "game.runtime",
|
||||
name: "Runtime Game",
|
||||
version: "1.0.0",
|
||||
serverType: "runtime",
|
||||
manifestRef: "artifact://runtime-manifest",
|
||||
createFormSchemaRef: "schemas/create.json",
|
||||
requiredRunCapabilities: ["process.install"],
|
||||
declaredPermissions: ["server.create"],
|
||||
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false },
|
||||
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
|
||||
bridgeActions: [],
|
||||
pages: [],
|
||||
tags: [],
|
||||
aiPurposes: [],
|
||||
status: "installed",
|
||||
runtimeProfiles: {
|
||||
discovery: [{ key: "root-check", kind: "file.exists", targetKey: "server-root", required: true }],
|
||||
dependencyProbes: [{ key: "java", kind: "java.version", targetKey: "java-runtime", required: false }],
|
||||
installPlans: [{ key: "java-install", title: "Java", steps: [{ type: "package", targetKey: "package-source" }] }],
|
||||
logSources: [{ key: "main-log", kind: "file.tail", targetKey: "log-source", streamKey: "main" }],
|
||||
transportProfiles: [
|
||||
{ key: "rcon", kind: "rcon", targetKey: "rcon.password", capabilities: ["remote.run.rcon.command"] },
|
||||
{ key: "ftp", kind: "ftp", targetKey: "ftp.profile", capabilities: ["remote.ftp.read"] }
|
||||
],
|
||||
lifecycleProfiles: [
|
||||
{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"], transportKeys: ["rcon"] },
|
||||
{ key: "hosted", mode: "hosted-ftp-rcon", capabilities: ["remote.ftp.read"], transportKeys: ["ftp"] }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
describe("runtime profile server creation contracts", () => {
|
||||
it("derives logical binding fields from the selected profile", () => {
|
||||
expect(runtimeBindingFields(plugin, "local")).toEqual([
|
||||
{ key: "java-runtime", required: false, sensitive: false },
|
||||
{ key: "log-source", required: true, sensitive: false },
|
||||
{ key: "package-source", required: false, sensitive: false },
|
||||
{ key: "rcon.password", required: true, sensitive: true },
|
||||
{ key: "server-root", required: true, sensitive: false }
|
||||
]);
|
||||
expect(runtimeBindingFields(plugin, "local").some((field) => field.key === "ftp.profile")).toBe(false);
|
||||
});
|
||||
|
||||
it("selects the plugin profile and submits real profile bindings", () => {
|
||||
const form = defaultServerCreateForm([plugin], []);
|
||||
expect(form.profileKey).toBe("local");
|
||||
expect(
|
||||
serverCreateRequestFromForm(
|
||||
{
|
||||
...form,
|
||||
id: " server-1 ",
|
||||
name: " Runtime Server ",
|
||||
bindings: { "server-root": " runtime.server-root ", "rcon.password": " secret://runtime/server-1/rcon ", "java-runtime": " " }
|
||||
},
|
||||
17
|
||||
)
|
||||
).toEqual({
|
||||
id: "server-1",
|
||||
pluginId: "game.runtime",
|
||||
runEndpointId: "",
|
||||
name: "Runtime Server",
|
||||
idempotencyKey: "web:create:server-1:17",
|
||||
profileKey: "local",
|
||||
bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,13 +18,16 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc
|
||||
pluginId: form.pluginId.trim(),
|
||||
runEndpointId: form.runEndpointId.trim(),
|
||||
name: form.name.trim(),
|
||||
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence)
|
||||
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence),
|
||||
profileKey: form.profileKey.trim(),
|
||||
bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== ""))
|
||||
};
|
||||
}
|
||||
|
||||
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop", sequence = Date.now()): ServerLifecycleCommandRequest {
|
||||
return {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
|
||||
return {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence)
|
||||
};
|
||||
}
|
||||
@@ -77,10 +80,11 @@ export function clientManagerBuildRequest(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", sequence = Date.now()): DependencyJobRequest {
|
||||
export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", planDigest = "", sequence = Date.now()): DependencyJobRequest {
|
||||
return {
|
||||
probeKey: probeKey.trim(),
|
||||
installPlanKey: installPlanKey.trim() || undefined,
|
||||
planDigest: planDigest.trim() || undefined,
|
||||
idempotencyKey: runtimeIdempotencyKey(installPlanKey ? "dependencies.install" : "dependencies.check", serverInstanceId, sequence)
|
||||
};
|
||||
}
|
||||
@@ -98,6 +102,6 @@ export function runtimeIdempotencyKey(action: string, serverInstanceId: string,
|
||||
return `web:${action}:${serverInstanceId}:${sequence}`;
|
||||
}
|
||||
|
||||
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop", serverInstanceId: string, sequence: number): string {
|
||||
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop" | "status", serverInstanceId: string, sequence: number): string {
|
||||
return `web:${action}:${serverInstanceId}:${sequence}`;
|
||||
}
|
||||
|
||||
@@ -25,9 +25,24 @@ describe("session store helpers", () => {
|
||||
|
||||
it("returns null without a stored API session token", async () => {
|
||||
stubWindowStorage(new Map());
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: "unauthorized" }, 401)));
|
||||
await expect(loadCurrentUser()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("restores an HttpOnly cookie session without a script-readable token", async () => {
|
||||
const storage = new Map<string, string>();
|
||||
stubWindowStorage(storage);
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).has("Authorization")).toBe(false);
|
||||
expect(init?.credentials).toBe("same-origin");
|
||||
return jsonResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(loadCurrentUser()).resolves.toMatchObject({ id: "user-admin", source: "api" });
|
||||
expect(storage.size).toBe(0);
|
||||
});
|
||||
|
||||
it("loads current user with a stored API bearer token", async () => {
|
||||
const storage = new Map([["platform-web.session.apiToken", "session-token"]]);
|
||||
stubWindowStorage(storage);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { platformApiClient, setPlatformApiSessionToken } from "../api/client";
|
||||
import { platformApiClient, setPlatformApiAuthFailureHandler, setPlatformApiSessionToken } from "../api/client";
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
CurrentUserResponse,
|
||||
@@ -59,9 +59,6 @@ export interface SessionState {
|
||||
export async function loadCurrentUser(): Promise<CurrentUserView | null> {
|
||||
const storedToken = readStoredSessionToken();
|
||||
setPlatformApiSessionToken(storedToken);
|
||||
if (!storedToken) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await platformApiClient.getCurrentUser();
|
||||
return currentUserFromResponse(response, "api");
|
||||
@@ -93,6 +90,16 @@ export function useSession(): SessionState {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPlatformApiAuthFailureHandler(() => {
|
||||
persistSessionToken(null);
|
||||
setUser(undefined);
|
||||
setAuthUnavailable(false);
|
||||
setAuth({ mode: "login", pending: false, error: "会话已失效,请重新登录。" });
|
||||
});
|
||||
return () => setPlatformApiAuthFailureHandler(null);
|
||||
}, []);
|
||||
|
||||
async function login(request: LoginRequest) {
|
||||
setAuth({ mode: "login", pending: true });
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user