feat: 完整游戏运维功能

This commit is contained in:
npc0-hue
2026-07-18 09:04:01 +08:00
parent f3b14b7945
commit 48b8ad8d6c
187 changed files with 16607 additions and 1140 deletions
@@ -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
View File
@@ -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 {
+94 -8
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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 次尝试");
});
});
+43 -13
View File
@@ -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);
}
+6 -2
View File
@@ -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,
+39 -3
View File
@@ -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 };
}
+3 -2
View File
@@ -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;
+3 -2
View File
@@ -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("编辑");
+6 -4
View File
@@ -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>
+10
View File
@@ -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");
+5
View File
@@ -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":
+15 -3
View File
@@ -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"');
+315 -26
View File
@@ -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>
);
}
+61 -10
View File
@@ -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>
+12
View File
@@ -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");
});
});
+40
View File
@@ -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/);
});
});
+112
View File
@@ -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/);
});
});
+202
View File
@@ -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" }
});
});
});
+10 -6
View File
@@ -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}`;
}
+15
View File
@@ -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);
+11 -4
View File
@@ -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 {