Integrate SCUM real ops workflows
This commit is contained in:
@@ -178,14 +178,10 @@ async function main() {
|
||||
markers: [
|
||||
server.name,
|
||||
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
|
||||
"概览",
|
||||
"启动",
|
||||
"停止",
|
||||
"日志",
|
||||
"管理终端",
|
||||
"配置",
|
||||
"AI 助手",
|
||||
"操作历史"
|
||||
"管理",
|
||||
"AI 助手"
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -818,8 +814,8 @@ async function verifyLifecycleOperation(headers, server, chrome) {
|
||||
const job = await waitForJob(headers, currentServer.id, result.job.id);
|
||||
|
||||
await chrome.navigate(`${webUrl}/#/servers/${encodeURIComponent(currentServer.id)}`);
|
||||
await chrome.waitForText([currentServer.name, "操作历史"], "server detail after lifecycle operation");
|
||||
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
|
||||
await chrome.waitForText([currentServer.name, "管理", "AI 助手"], "server detail after lifecycle operation");
|
||||
const manageState = await clickAndVerify(chrome, "管理", ["部署定义", "基本信息", "管理成员"]);
|
||||
|
||||
return {
|
||||
action,
|
||||
@@ -832,10 +828,10 @@ async function verifyLifecycleOperation(headers, server, chrome) {
|
||||
},
|
||||
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
||||
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
||||
proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access`,
|
||||
proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified safe management entry point without direct run access`,
|
||||
browserEvidence: {
|
||||
...historyState,
|
||||
proofMode: "operation-history-entry-point"
|
||||
...manageState,
|
||||
proofMode: "safe-management-entry-point"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -244,61 +244,18 @@ describe("PlatformApiClient AI providers", () => {
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/config")) {
|
||||
return jsonResponse({
|
||||
serverInstanceId: server.id,
|
||||
configVersion: 1,
|
||||
format: "properties",
|
||||
key: "server.properties",
|
||||
content: "server.name=Example Survival #1\n",
|
||||
source: "platform-derived",
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
expectedConfigVersion: 1,
|
||||
key: "server.properties",
|
||||
proposedContent: "server.name=Example Survival #2\n"
|
||||
});
|
||||
return jsonResponse({
|
||||
serverInstanceId: server.id,
|
||||
configVersion: 1,
|
||||
key: "server.properties",
|
||||
currentContent: "server.name=Example Survival #1\n",
|
||||
proposedContent: "server.name=Example Survival #2\n",
|
||||
diff: [
|
||||
{ kind: "removed", oldNumber: 1, content: "server.name=Example Survival #1" },
|
||||
{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }
|
||||
],
|
||||
hasChanges: true,
|
||||
source: "platform-review",
|
||||
reviewedAt: "2026-07-03T00:00:00Z"
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/config/approve") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
expectedConfigVersion: 1,
|
||||
key: "server.properties",
|
||||
proposedContent: "server.name=Example Survival #2\n",
|
||||
idempotencyKey: "idem-config"
|
||||
});
|
||||
return jsonResponse({
|
||||
status: "queued",
|
||||
preview: {
|
||||
serverInstanceId: server.id,
|
||||
configVersion: 1,
|
||||
key: "server.properties",
|
||||
currentContent: "server.name=Example Survival #1\n",
|
||||
proposedContent: "server.name=Example Survival #2\n",
|
||||
diff: [{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }],
|
||||
hasChanges: true,
|
||||
source: "platform-review",
|
||||
reviewedAt: "2026-07-03T00:00:00Z"
|
||||
},
|
||||
job: { ...job, id: "job-config-write", capability: "config.write", targetKey: "server.properties", inputRef: "input://server-config/server-1/server.properties/v1" }
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/positions")) return jsonResponse({ items: [{ id: "position-1", subjectType: "player", subjectId: "steam-1", x: 1, y: 2, z: 3 }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "waiting", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations/op-1/approve") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "queued", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && init?.method === "POST") return jsonResponse({ id: "workflow-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "scum.world-refresh", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflow-steps?workflowId=workflow-1")) return jsonResponse({ items: [{ id: "step-1", workflowId: "workflow-1", serverInstanceId: server.id, stepKey: "read-positions", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
serverInstanceId: server.id,
|
||||
@@ -536,13 +493,6 @@ describe("PlatformApiClient AI providers", () => {
|
||||
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")) {
|
||||
return jsonResponse({ items: [], count: 0 });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/logs/backfill") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" });
|
||||
return jsonResponse({ ...job, id: "job-log-backfill", capability: "logs.backfill", targetKey: "logs/latest-log", inputRef: "artifact://logs/checkpoint/1" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") {
|
||||
return jsonResponse({
|
||||
pluginId: plugin.id,
|
||||
@@ -604,13 +554,18 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined();
|
||||
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
|
||||
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
|
||||
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
|
||||
await expect(
|
||||
client.previewServerConfigDiff(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n" })
|
||||
).resolves.toMatchObject({ hasChanges: true, source: "platform-review" });
|
||||
await expect(
|
||||
client.approveServerConfigWrite(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n", idempotencyKey: "idem-config" })
|
||||
).resolves.toMatchObject({ status: "queued", job: { capability: "config.write", targetKey: "server.properties" } });
|
||||
await expect(client.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] });
|
||||
await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMPositions(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMOperations(server.id)).resolves.toMatchObject({ count: 0 });
|
||||
await expect(client.createSCUMOperation(server.id, { templateKey: "player.fame.set", playerId: "steam-1", payload: { fame: 100 }, reason: "typed correction", idempotencyKey: "idem-scum-op" })).resolves.toMatchObject({ id: "op-1", status: "waiting" });
|
||||
await expect(client.approveSCUMOperation(server.id, "op-1")).resolves.toMatchObject({ id: "op-1", status: "queued" });
|
||||
await expect(client.listSCUMWorkflows(server.id)).resolves.toMatchObject({ count: 0 });
|
||||
await expect(client.createSCUMWorkflow(server.id, { templateKey: "scum.world-refresh", idempotencyKey: "idem-scum-workflow" })).resolves.toMatchObject({ id: "workflow-1", status: "queued" });
|
||||
await expect(client.listSCUMWorkflowSteps(server.id, "workflow-1")).resolves.toMatchObject({ count: 1, items: [{ stepKey: "read-positions" }] });
|
||||
await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({
|
||||
status: "queued",
|
||||
job: { capability: "files.read", targetKey: "logs/latest.log" }
|
||||
@@ -665,21 +620,11 @@ describe("PlatformApiClient AI providers", () => {
|
||||
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 });
|
||||
await expect(client.requestLogBackfill(server.id, { sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" })).resolves.toMatchObject({
|
||||
capability: "logs.backfill"
|
||||
});
|
||||
await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({
|
||||
allowed: true
|
||||
});
|
||||
await expect(
|
||||
client.executePluginBridge({ requestId: "req-bridge", pluginId: plugin.id, routeKey: "logs", serverInstanceId: server.id, action: "logs.query", payload: { logStreamId: "log-1" } })
|
||||
).resolves.toMatchObject({ status: "ok", result: { entryCount: "0" } });
|
||||
await expect(
|
||||
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(43);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(48);
|
||||
});
|
||||
|
||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||
@@ -708,11 +653,11 @@ describe("PlatformApiClient AI providers", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("surfaces config diff preview failures from the platform", async () => {
|
||||
it("surfaces SCUM typed operation failures from the platform", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") {
|
||||
return new Response(JSON.stringify({ code: "validation", message: "expectedConfigVersion must match server instance" }), {
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") {
|
||||
return new Response(JSON.stringify({ code: "validation", message: "SCUM operation template is not declared" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
@@ -723,9 +668,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
|
||||
const client = new PlatformApiClient();
|
||||
|
||||
await expect(client.previewServerConfigDiff(server.id, { expectedConfigVersion: 0, key: "server.properties", proposedContent: "changed=true\n" })).rejects.toThrow(
|
||||
"expectedConfigVersion must match server instance"
|
||||
);
|
||||
await expect(client.createSCUMOperation(server.id, { templateKey: "raw.sql", reason: "unsafe", idempotencyKey: "bad-scum-op" })).rejects.toThrow("SCUM operation template is not declared");
|
||||
});
|
||||
|
||||
it("keeps raw key and base URL fields out of provider responses", () => {
|
||||
|
||||
+51
-51
@@ -36,7 +36,6 @@ import type {
|
||||
CurrentUserResponse,
|
||||
DependencyCatalogResponse,
|
||||
DependencyJobRequest,
|
||||
DeclaredFileReadSnapshotResponse,
|
||||
FileOperationDispatchRequest,
|
||||
FileOperationDispatchResponse,
|
||||
GameClientBridgeCancelRequest,
|
||||
@@ -55,8 +54,6 @@ import type {
|
||||
JobResponse,
|
||||
LlmConfigSuggestionRequest,
|
||||
LlmConfigSuggestionResponse,
|
||||
LogBackfillRequest,
|
||||
LogStreamEventOptions,
|
||||
LogStreamCursorRequest,
|
||||
LogStreamCursorResponse,
|
||||
LogStreamListResponse,
|
||||
@@ -84,19 +81,12 @@ import type {
|
||||
RunUpdateRequest,
|
||||
RuntimeBindingResponse,
|
||||
RuntimeBindingUpdateRequest,
|
||||
ServerConfigResponse,
|
||||
ServerConfigDiffPreviewRequest,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
ServerLifecycleCommandRequest,
|
||||
ServerLifecycleCreateRequest,
|
||||
ServerLifecycleResponse,
|
||||
ServerDeploymentRequest,
|
||||
ServerDeploymentRevealResponse,
|
||||
ServerDeploymentResponse,
|
||||
ServerConfigWriteApprovalRequest,
|
||||
ServerConfigWriteDispatchResponse,
|
||||
SourceRCONCommandRequest,
|
||||
SourceRCONCommandResponse,
|
||||
ServerInstanceListResponse,
|
||||
ServerDeletionRequest,
|
||||
ServerInstanceUpdateRequest,
|
||||
@@ -110,6 +100,14 @@ import type {
|
||||
RemoteAdapterDeclarationListResponse,
|
||||
RemoteAdapterRequest,
|
||||
RemoteAdapterResponse,
|
||||
SCUMListResponse,
|
||||
SCUMOperationListResponse,
|
||||
SCUMOperationRequest,
|
||||
SCUMOperationResponse,
|
||||
SCUMWorkflowCreateRequest,
|
||||
SCUMWorkflowListResponse,
|
||||
SCUMWorkflowResponse,
|
||||
SCUMWorkflowStepListResponse,
|
||||
ServerRuntimeActionsResponse,
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
@@ -444,28 +442,6 @@ export class PlatformApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listServerLiveLogs(id: string): Promise<LogStreamListResponse> {
|
||||
return this.request<LogStreamListResponse>(`/server-instances/${encodeURIComponent(id)}/logs/live`);
|
||||
}
|
||||
|
||||
openServerLogEvents(id: string, options: LogStreamEventOptions = {}): EventSource {
|
||||
return new EventSource(this.serverLogEventsUrl(id, options), { withCredentials: true });
|
||||
}
|
||||
|
||||
serverLogEventsUrl(id: string, options: LogStreamEventOptions = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options.historyLimit !== undefined) params.set("historyLimit", String(options.historyLimit));
|
||||
const query = params.toString();
|
||||
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
async requestLogBackfill(id: string, request: LogBackfillRequest): Promise<JobResponse> {
|
||||
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/logs/backfill`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
}
|
||||
|
||||
async createJob(request: JobCreateRequest): Promise<JobResponse> {
|
||||
return this.request<JobResponse>("/jobs", { method: "POST", body: request });
|
||||
}
|
||||
@@ -599,26 +575,55 @@ export class PlatformApiClient {
|
||||
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async sendSourceRCONCommand(serverInstanceId: string, request: SourceRCONCommandRequest): Promise<SourceRCONCommandResponse> {
|
||||
return this.request<SourceRCONCommandResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/rcon/commands`, { method: "POST", body: request });
|
||||
async listSCUMPlayers(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`);
|
||||
}
|
||||
|
||||
async getServerConfig(id: string): Promise<ServerConfigResponse> {
|
||||
return this.request<ServerConfigResponse>(`/server-instances/${encodeURIComponent(id)}/config`);
|
||||
async listSCUMSquads(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`);
|
||||
}
|
||||
|
||||
async previewServerConfigDiff(id: string, request: ServerConfigDiffPreviewRequest): Promise<ServerConfigDiffPreviewResponse> {
|
||||
return this.request<ServerConfigDiffPreviewResponse>(`/server-instances/${encodeURIComponent(id)}/config/diff`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
async listSCUMSquadMembers(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squad-members`);
|
||||
}
|
||||
|
||||
async approveServerConfigWrite(id: string, request: ServerConfigWriteApprovalRequest): Promise<ServerConfigWriteDispatchResponse> {
|
||||
return this.request<ServerConfigWriteDispatchResponse>(`/server-instances/${encodeURIComponent(id)}/config/approve`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
async listSCUMVehicles(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles`);
|
||||
}
|
||||
|
||||
async listSCUMFlags(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/flags`);
|
||||
}
|
||||
|
||||
async listSCUMPositions(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
|
||||
}
|
||||
|
||||
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
|
||||
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
|
||||
}
|
||||
|
||||
async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise<SCUMOperationResponse> {
|
||||
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise<SCUMOperationResponse> {
|
||||
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} });
|
||||
}
|
||||
|
||||
async listSCUMWorkflows(serverInstanceId: string): Promise<SCUMWorkflowListResponse> {
|
||||
return this.request<SCUMWorkflowListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`);
|
||||
}
|
||||
|
||||
async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise<SCUMWorkflowResponse> {
|
||||
return this.request<SCUMWorkflowResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise<SCUMWorkflowStepListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (workflowId) params.set("workflowId", workflowId);
|
||||
const query = params.toString();
|
||||
return this.request<SCUMWorkflowStepListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> {
|
||||
@@ -628,11 +633,6 @@ export class PlatformApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getDeclaredFileReadSnapshot(serverInstanceId: string, fileKey: string): Promise<DeclaredFileReadSnapshotResponse> {
|
||||
const query = new URLSearchParams({ key: fileKey });
|
||||
return this.request<DeclaredFileReadSnapshotResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${query.toString()}`);
|
||||
}
|
||||
|
||||
async listLogStreams(): Promise<LogStreamListResponse> {
|
||||
return this.request<LogStreamListResponse>("/log-streams");
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ API clients and DTO types live here, not inside page components.
|
||||
|
||||
- `users`: user and role APIs.
|
||||
- `serverPlugins`: plugin marketplace and installed plugin APIs.
|
||||
- `serverInstances`: create server, lifecycle, config read, config diff/approval, scoped files, logs, and detail APIs.
|
||||
- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM typed projection/workflow APIs.
|
||||
- `aiProviders`: provider CRUD, test, and model APIs.
|
||||
- `jobs`: job status and operation APIs.
|
||||
- `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs.
|
||||
- `artifacts`: artifact upload/download APIs.
|
||||
- `logs`: historical query and tail APIs.
|
||||
- `logs`: internal historical cursor APIs used for maintenance/debug views, not server-detail raw log products.
|
||||
- `pluginPageBridge`: safe bridge APIs for hosted plugin page.
|
||||
|
||||
Every API client must use named request and response types.
|
||||
@@ -26,8 +26,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
||||
- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs for internal/advanced logical transports. Server detail must not expose a manual runtime-binding tab or require these fields before normal start/stop when plugin-declared deployment/lifecycle data is sufficient. 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.
|
||||
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths.
|
||||
- SCUM projection reads use `listSCUMPlayers`, `listSCUMSquads`, `listSCUMSquadMembers`, `listSCUMVehicles`, `listSCUMFlags`, and `listSCUMPositions`; SCUM writes use `createSCUMOperation`, `approveSCUMOperation`, `createSCUMWorkflow`, and workflow/step list APIs. These APIs expose only projection rows, typed template keys, status, and safe summaries, never SQL text, RCON text, DSNs, host paths, or protected payloads.
|
||||
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench.
|
||||
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
|
||||
- `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.
|
||||
@@ -52,15 +52,14 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
||||
- `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.
|
||||
- `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen.
|
||||
- `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards and the server detail header.
|
||||
- `GET /api/v1/server-instances/{id}/config` (`ServerConfigResponse`): implemented readable configuration content for diff-based editing.
|
||||
- `POST /api/v1/server-instances/{id}/config/diff` (`ServerConfigDiffPreviewRequest`/`ServerConfigDiffPreviewResponse`) and `POST /api/v1/server-instances/{id}/config/approve` (`ServerConfigWriteApprovalRequest`/`ServerConfigWriteDispatchResponse`): implemented platform-mediated config write review and approval. Manual config edits and AI suggestion applies must not create generic `config.write` jobs through `POST /api/v1/jobs`.
|
||||
- Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` plus reviewable AI config-diff approval APIs; plugin pages do not receive raw config text.
|
||||
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
|
||||
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
|
||||
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
|
||||
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||
|
||||
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.
|
||||
- Live log and management-terminal output uses `GET /api/v1/server-instances/{id}/logs/events` as a single `EventSource`/SSE stream with bounded initial history. `POST /api/v1/log-streams/query` remains available for explicit historical cursor reads and reconnect repair, not periodic browser polling.
|
||||
- Server-scoped raw log routes (`GET /api/v1/server-instances/{id}/logs/live`, `GET .../logs/events`, `POST .../logs/backfill`) and management-terminal/RCON input routes are removed from product clients. Internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
|
||||
# Client Manager API projection
|
||||
|
||||
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PlatformApiClient } from "./client";
|
||||
import type { SourceRCONCommandRequest } from "./types";
|
||||
|
||||
describe("PlatformApiClient Source RCON command dispatch", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("posts the typed server-scoped request and projects only safe queue state", async () => {
|
||||
const request: SourceRCONCommandRequest = {
|
||||
kind: "chat",
|
||||
chatType: 4,
|
||||
message: "Maintenance complete",
|
||||
targetSteamId: "76561198000000001",
|
||||
idempotencyKey: "web:source-rcon:chat:server-1:12"
|
||||
};
|
||||
const calls: Array<{ url: string; method: string; body?: unknown; authorization: string | null }> = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
authorization: new Headers(init?.headers).get("Authorization")
|
||||
});
|
||||
return new Response(JSON.stringify({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" }), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}));
|
||||
|
||||
const response = await new PlatformApiClient("/api/v1", () => "operator-session").sendSourceRCONCommand("server/1", request);
|
||||
|
||||
expect(calls).toEqual([{
|
||||
url: "/api/v1/server-instances/server%2F1/rcon/commands",
|
||||
method: "POST",
|
||||
body: request,
|
||||
authorization: "Bearer operator-session"
|
||||
}]);
|
||||
expect(response).toEqual({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" });
|
||||
for (const forbidden of ["command", "password", "host", "configRef", "response"]) {
|
||||
expect(Object.keys(response)).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1373,6 +1373,17 @@ export interface RemoteAdapterResponse {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export type SCUMJsonRecord = Record<string, unknown>;
|
||||
export interface SCUMListResponse<T = SCUMJsonRecord> { items: T[]; count: number; }
|
||||
export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; }
|
||||
export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; }
|
||||
export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
|
||||
export interface SCUMWorkflowStepResponse { id: string; workflowId: string; serverInstanceId: string; stepKey: string; dependsOn?: string[]; status: string; operationKey?: string; queryTemplateKey?: string; capability?: string; targetKey?: string; jobId?: string; attempt?: number; maxAttempts?: number; mutatesState?: boolean; confirmation?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
|
||||
export interface SCUMOperationResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; playerId?: string; requesterId?: string; approverId?: string; approvalLevel: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; confirmation?: SCUMJsonRecord; status: string; reason?: string; runJobId?: string; safeSummary?: SCUMJsonRecord; auditReferences?: string[]; createdAt: string; approvedAt?: string; completedAt?: string; updatedAt: string; }
|
||||
export type SCUMWorkflowListResponse = SCUMListResponse<SCUMWorkflowResponse>;
|
||||
export type SCUMWorkflowStepListResponse = SCUMListResponse<SCUMWorkflowStepResponse>;
|
||||
export type SCUMOperationListResponse = SCUMListResponse<SCUMOperationResponse>;
|
||||
|
||||
export interface ServerConfigResponse {
|
||||
serverInstanceId: string;
|
||||
configVersion: number;
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
||||
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||
import { cx } from "../utils/classes";
|
||||
import { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
||||
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||
|
||||
const terminalBridgeResultPollMs = 1000;
|
||||
const terminalBridgeResultPollAttempts = 30;
|
||||
const terminalInitialHistoryWindow = 500;
|
||||
const maxTerminalLines = 10000;
|
||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||
"game.scum": [
|
||||
{ label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" },
|
||||
{ label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" },
|
||||
{ label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" },
|
||||
{ label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" }
|
||||
]
|
||||
};
|
||||
|
||||
interface LiveOperationDrawerProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
backdropClassName?: string;
|
||||
panelClassName?: string;
|
||||
bodyClassName?: string;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName, hideHeader }: LiveOperationDrawerProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const previous = document.body.style.overflow;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className={cx("drawer-backdrop", backdropClassName)} role="presentation" onClick={onClose}>
|
||||
<aside className={cx("drawer-panel live-operation-drawer", panelClassName)} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
||||
{!hideHeader && (
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{description && <p className="page-status">{description}</p>}
|
||||
</div>
|
||||
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}><X size={14} /><span>关闭</span></button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cx("live-operation-content", bodyClassName)}>{children}</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerManagementTerminalDrawerProps {
|
||||
open: boolean;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
pluginId: string;
|
||||
canManage: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServerManagementTerminalDrawer({ open, serverId, serverName, pluginId, canManage, onClose }: ServerManagementTerminalDrawerProps) {
|
||||
const [command, setCommand] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [lines, setLines] = useState<TerminalLine[]>([]);
|
||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
const [followLatest, setFollowLatest] = useState(true);
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const followLatestRef = useRef(true);
|
||||
const initialHistoryPendingRef = useRef(false);
|
||||
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
|
||||
const supportsCommands = quickCommands.length > 0;
|
||||
|
||||
const appendLines = useCallback((incoming: TerminalLine[]) => {
|
||||
if (incoming.length === 0) return;
|
||||
setLines((current) => mergeTerminalLines(current, incoming));
|
||||
}, []);
|
||||
|
||||
const lockTerminalFollow = useCallback(() => {
|
||||
followLatestRef.current = true;
|
||||
setFollowLatest(true);
|
||||
window.requestAnimationFrame(() => {
|
||||
const output = outputRef.current;
|
||||
if (output) output.scrollTop = output.scrollHeight;
|
||||
window.requestAnimationFrame(() => {
|
||||
const output = outputRef.current;
|
||||
if (output) output.scrollTop = output.scrollHeight;
|
||||
initialHistoryPendingRef.current = false;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStreams({ status: "loading" });
|
||||
setCommand("");
|
||||
setPending(false);
|
||||
setResult(null);
|
||||
setHistoryIndex(null);
|
||||
initialHistoryPendingRef.current = true;
|
||||
followLatestRef.current = true;
|
||||
setFollowLatest(true);
|
||||
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
||||
}, [open, supportsCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const output = outputRef.current;
|
||||
if (output) output.scrollTop = output.scrollHeight;
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [lines, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
let ready = false;
|
||||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow });
|
||||
events.addEventListener("stream", (event) => {
|
||||
const stream = parseLogStreamEvent(event);
|
||||
if (!stream) return;
|
||||
ready = true;
|
||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||
});
|
||||
events.addEventListener("ready", () => {
|
||||
ready = true;
|
||||
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
||||
lockTerminalFollow();
|
||||
});
|
||||
events.addEventListener("log", (event) => {
|
||||
const payload = parseServerLogEvent(event);
|
||||
if (!payload) return;
|
||||
ready = true;
|
||||
const stream = streamFromServerLogEvent(payload);
|
||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||
appendLines([terminalLineFromLog(stream, payload.entry)]);
|
||||
});
|
||||
events.onerror = () => {
|
||||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||
};
|
||||
return () => events.close();
|
||||
}, [appendLines, lockTerminalFollow, open, serverId]);
|
||||
|
||||
function selectQuickCommand(item: TerminalQuickCommand) {
|
||||
setCommand(item.command);
|
||||
setHistoryIndex(null);
|
||||
}
|
||||
|
||||
function handleCommandKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||
if (commandHistory.length === 0) return;
|
||||
event.preventDefault();
|
||||
const lastIndex = commandHistory.length - 1;
|
||||
if (event.key === "ArrowUp") {
|
||||
const nextIndex = historyIndex === null ? lastIndex : Math.max(0, historyIndex - 1);
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(commandHistory[nextIndex] ?? "");
|
||||
return;
|
||||
}
|
||||
if (historyIndex === null) return;
|
||||
const nextIndex = historyIndex + 1;
|
||||
if (nextIndex > lastIndex) {
|
||||
setHistoryIndex(null);
|
||||
setCommand("");
|
||||
return;
|
||||
}
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(commandHistory[nextIndex] ?? "");
|
||||
}
|
||||
|
||||
function clearTerminalBuffer() {
|
||||
setLines([]);
|
||||
}
|
||||
|
||||
function handleTerminalScroll() {
|
||||
const output = outputRef.current;
|
||||
if (!output || initialHistoryPendingRef.current) return;
|
||||
const nextFollowLatest = output.scrollHeight - output.clientHeight - output.scrollTop <= 24;
|
||||
followLatestRef.current = nextFollowLatest;
|
||||
setFollowLatest(nextFollowLatest);
|
||||
}
|
||||
|
||||
async function submitCommand(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!supportsCommands || !canManage || pending || !command.trim()) return;
|
||||
const submitted = command.trim();
|
||||
setCommand("");
|
||||
setHistoryIndex(null);
|
||||
setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50));
|
||||
setPending(true);
|
||||
setResult({ status: "pending", label: "正在提交命令" });
|
||||
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
||||
try {
|
||||
const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted));
|
||||
const label = bridgeCommandDispatchLabel(response.state, response.id);
|
||||
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
|
||||
appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]);
|
||||
const finalCommand = await waitForBridgeCommandTerminal(response.id);
|
||||
if (finalCommand) {
|
||||
const outcome = terminalLineFromBridgeCommand(finalCommand);
|
||||
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
||||
appendLines([outcome]);
|
||||
} else {
|
||||
const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`);
|
||||
setResult({ status: "pending", label: "等待 Run 返回结果" });
|
||||
appendLines([timeoutLine]);
|
||||
}
|
||||
} catch (error) {
|
||||
const label = error instanceof Error ? error.message : "命令提交失败";
|
||||
setResult({ status: "failed", label });
|
||||
appendLines([terminalSystemLine("error", label, "ERROR")]);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBridgeCommandTerminal(commandId: string): Promise<GameClientBridgeCommandResponse | null> {
|
||||
for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) {
|
||||
const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId);
|
||||
if (isTerminalBridgeCommandState(current.state)) return current;
|
||||
await delay(terminalBridgeResultPollMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LiveOperationDrawer open={open} title="管理终端" onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body" hideHeader>
|
||||
<section className="terminal-output-panel" aria-label="terminal output">
|
||||
<div className="terminal-output-topbar">
|
||||
<div>
|
||||
<strong>{serverName}</strong>
|
||||
<span>当前服务器 Run 日志 · 已接受历史 + SSE 实时推送 · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||
<button type="button" className="terminal-output-action" aria-label="关闭管理终端" onClick={onClose}><X size={14} /><span>关闭</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
|
||||
{streams.status === "error" && <div className="terminal-line terminal-line-error"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">{streams.reason}</span></div>}
|
||||
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">暂无已接受日志。Run 恢复连接并完成日志水位校准后,新输出会继续追加。</span></div>}
|
||||
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span className="terminal-stream">{line.streamKey || line.level || "LOG"}</span><span className="terminal-text">{line.text}</span></div>)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="terminal-command-dock" aria-label="terminal command controls">
|
||||
<div className="terminal-command-dock-header">
|
||||
<span><ListChecks size={14} />{quickCommands.length > 0 ? "插件快捷指令" : "命令输入"}</span>
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
</div>
|
||||
{quickCommands.length > 0 && <div className="terminal-quick-command-list">
|
||||
{quickCommands.map((item) => <button key={item.command} type="button" className="terminal-quick-command" disabled={!canManage || pending} onClick={() => selectQuickCommand(item)}><strong>{item.label}</strong><span>{item.command}</span><small>{item.hint}</small></button>)}
|
||||
</div>}
|
||||
{!supportsCommands && <EmptyState icon={<Terminal size={24} />} title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />}
|
||||
{supportsCommands && (
|
||||
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
|
||||
<label>
|
||||
<span>SCUM 管理命令</span>
|
||||
<small>Enter 发送,↑/↓ 调出历史;命令结果以 Run 日志追加为准。</small>
|
||||
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令" : "当前账号没有运行操作权限"} onKeyDown={handleCommandKeyDown} onChange={(event) => { setCommand(event.target.value); setHistoryIndex(null); }} />
|
||||
</label>
|
||||
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</LiveOperationDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
||||
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||
}
|
||||
|
||||
function bridgeCommandDispatchLabel(state: string, commandId: string): string {
|
||||
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
||||
}
|
||||
|
||||
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
|
||||
return {
|
||||
id: `log-${stream.id}-${entry.seq}`,
|
||||
tone: terminalTone(entry),
|
||||
text: entry.line,
|
||||
at: new Date(entry.timestamp).toLocaleTimeString(),
|
||||
sortKey: Date.parse(entry.timestamp) || Date.now(),
|
||||
streamKey: stream.streamKey || stream.source,
|
||||
level: entry.level,
|
||||
seq: entry.seq
|
||||
};
|
||||
}
|
||||
|
||||
function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine {
|
||||
const now = Date.now();
|
||||
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
|
||||
}
|
||||
|
||||
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
|
||||
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
|
||||
}
|
||||
|
||||
function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine {
|
||||
const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state);
|
||||
const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt;
|
||||
const sortKey = Date.parse(completed) || Date.now();
|
||||
const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn";
|
||||
return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" };
|
||||
}
|
||||
|
||||
function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string {
|
||||
switch (state) {
|
||||
case "succeeded": return "已成功";
|
||||
case "failed": return "已失败";
|
||||
case "cancelled": return "已取消";
|
||||
case "expired": return "已过期";
|
||||
case "unknown": return "状态未知";
|
||||
case "claimed": return "Run 已领取";
|
||||
case "pending": return "已排队";
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function terminalTone(entry: LogEntryBody): TerminalLine["tone"] {
|
||||
const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase();
|
||||
if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error";
|
||||
if (/\b(warn|warning|timeout|retry)\b/.test(value)) return "warn";
|
||||
if (/\b(success|succeeded|ready|started|online|listening|accepted)\b/.test(value)) return "success";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] {
|
||||
const seen = new Set(current.map((line) => line.id));
|
||||
const merged = [...current];
|
||||
for (const line of incoming) {
|
||||
if (seen.has(line.id)) continue;
|
||||
seen.add(line.id);
|
||||
merged.push(line);
|
||||
}
|
||||
return merged.sort(compareTerminalLines).slice(-maxTerminalLines);
|
||||
}
|
||||
|
||||
function compareTerminalLines(a: TerminalLine, b: TerminalLine): number {
|
||||
return a.sortKey - b.sortKey || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw";
|
||||
|
||||
describe("SourceRCONCommandPanel", () => {
|
||||
it("uses protected bridge dispatch without confirmation, transcript, or connection fields", () => {
|
||||
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest");
|
||||
expect(sourceRCONCommandPanelSource).toContain("scumAnnouncementCommand");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("sendSourceRCONCommand");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("transcript");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("history");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("password");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("host");
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||
import { ResultBadge } from "./StateViews";
|
||||
|
||||
interface SourceRCONCommandPanelProps {
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null;
|
||||
|
||||
export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) {
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [rawCommand, setRawCommand] = useState("");
|
||||
const [pending, setPending] = useState<"announcement" | "command" | null>(null);
|
||||
const [dispatch, setDispatch] = useState<DispatchState>(null);
|
||||
|
||||
if (pluginId !== "game.scum") {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendAnnouncement(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending("announcement");
|
||||
setDispatch({ status: "pending", label: "正在提交服务器公告" });
|
||||
try {
|
||||
const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, scumAnnouncementCommand(announcement)));
|
||||
setAnnouncement("");
|
||||
setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) });
|
||||
} catch (error) {
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "服务器公告提交失败" });
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRawCommand(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending("command");
|
||||
setDispatch({ status: "pending", label: "正在提交原始管理员指令" });
|
||||
try {
|
||||
const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, rawCommand));
|
||||
setRawCommand("");
|
||||
setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) });
|
||||
} catch (error) {
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" });
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="SCUM protected RCON controls">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>SCUM 公告与管理员指令</h2>
|
||||
<p className="page-status">通过插件声明的 protected RCON 通道派发;只显示安全状态,不保留指令原文。</p>
|
||||
</div>
|
||||
{dispatch && <ResultBadge status={dispatch.status} label={dispatch.label} />}
|
||||
</div>
|
||||
<div className="operations-command-grid">
|
||||
<section className="console-module" aria-label="SCUM announcement command">
|
||||
<div className="panel-header"><h2>发送公告</h2></div>
|
||||
<form className="provider-form" onSubmit={(event) => void sendAnnouncement(event)}>
|
||||
<label>
|
||||
公告内容
|
||||
<textarea value={announcement} maxLength={1024} rows={3} onChange={(event) => setAnnouncement(event.target.value)} disabled={pending !== null} placeholder="输入单行公告内容" />
|
||||
</label>
|
||||
<p className="page-status">会生成 SCUM 管理命令:#Announce 公告内容。</p>
|
||||
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !announcement.trim()}>{pending === "announcement" ? "提交中…" : "发送公告"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section className="console-module" aria-label="SCUM raw administrator command">
|
||||
<div className="panel-header"><h2>原始管理员指令</h2></div>
|
||||
<form className="provider-form" onSubmit={(event) => void sendRawCommand(event)}>
|
||||
<label>
|
||||
指令
|
||||
<textarea value={rawCommand} maxLength={8192} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 #ListPlayers 或 #SetTime 12" />
|
||||
</label>
|
||||
<p className="page-status">裸命令会交给当前运行中的 SCUM;执行结果以 Run 日志为准。</p>
|
||||
<div className="action-strip"><button type="submit" className="icon-command" disabled={pending !== null || !rawCommand.trim()}>{pending === "command" ? "提交中…" : "发送指令"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function protectedRCONDispatchLabel(commandId: string, state: string): string {
|
||||
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Shared Visual Contract
|
||||
|
||||
All first-party pages inherit the platform_web game-operations style with black-mecha default materials and a selectable magical-girl theme. Page implementations must use shared theme tokens and surface classes so 首页、服务器管理、插件市场、用户管理、AI 提供商管理、系统维护, server details, drawers, dialogs, logs, diffs, plugin-declared pages, and operation history all feel like one console.
|
||||
All first-party pages inherit the platform_web game-operations style with black-mecha default materials and a selectable magical-girl theme. Page implementations must use shared theme tokens and surface classes so 首页、服务器管理、插件市场、用户管理、AI 提供商管理、系统维护, server details, drawers, dialogs, safe diffs, plugin-declared pages, and workflow/status surfaces all feel like one console.
|
||||
|
||||
- Major surfaces remain transparent jelly/glass panels with visible background desktop, icy rim light, diamond borders, shine sweeps, and candy-color accents.
|
||||
- Built-in magical desktops and user-uploaded backgrounds render behind readable contrast surfaces.
|
||||
@@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab
|
||||
|
||||
## 服务器详情
|
||||
|
||||
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, confirmed start/stop lifecycle actions, and direct live-log/management-terminal entry points. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its menu surface. Built-in sections: 日志 (level/keyword/time/source filters + log detail drawer with diagnostics), 管理终端 (platform-mediated plugin command surface), 配置 (deployment status, metadata, administrators, and config edit with reviewable diff before any write job), AI 助手 (LLM suggestions produce recommendation/diff; write jobs require explicit diff confirmation; no raw AI keys reach the frontend), 操作历史 (operation/job IDs, status, timestamps, target, requester, error reasons). Generic runtime-binding and generic plugin-control tabs must not be exposed in server detail.
|
||||
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM pages use projection-backed users, squads, map, gifts, and workflows. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
|
||||
|
||||
## 插件市场
|
||||
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
export interface PluginPageFileRequestResult {
|
||||
status: string;
|
||||
message: string;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
export interface PluginPageFileReadSnapshot {
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
key: string;
|
||||
state: "ready" | "pending" | "not-read" | "unavailable" | string;
|
||||
content?: string;
|
||||
version?: number;
|
||||
checksum?: string;
|
||||
sizeBytes?: number;
|
||||
jobId?: string;
|
||||
readAt?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface PluginPageWorkspaceActions {
|
||||
refreshWorkspace?: () => Promise<unknown>;
|
||||
requestFile?: (fileKey: string) => Promise<PluginPageFileRequestResult>;
|
||||
getFileSnapshot?: (fileKey: string) => Promise<PluginPageFileReadSnapshot>;
|
||||
writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise<PluginPageFileRequestResult>;
|
||||
listSCUMPlayers?: () => Promise<unknown>;
|
||||
listSCUMSquads?: () => Promise<unknown>;
|
||||
listSCUMSquadMembers?: () => Promise<unknown>;
|
||||
listSCUMVehicles?: () => Promise<unknown>;
|
||||
listSCUMFlags?: () => Promise<unknown>;
|
||||
listSCUMPositions?: () => Promise<unknown>;
|
||||
listSCUMOperations?: () => Promise<unknown>;
|
||||
createSCUMOperation?: (request: unknown) => Promise<unknown>;
|
||||
approveSCUMOperation?: (operationId: string) => Promise<unknown>;
|
||||
listSCUMWorkflows?: () => Promise<unknown>;
|
||||
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
|
||||
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -144,14 +144,11 @@ export interface PlatformOverviewSignal {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type ServerDetailSection = "logs" | "terminal" | "config" | "llm" | "history" | `plugin:${string}`;
|
||||
export type ServerDetailSection = "manage" | "llm" | `plugin:${string}`;
|
||||
|
||||
export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [
|
||||
{ id: "logs", label: "日志" },
|
||||
{ id: "terminal", label: "管理终端" },
|
||||
{ id: "config", label: "配置" },
|
||||
{ id: "llm", label: "AI 助手" },
|
||||
{ id: "history", label: "操作历史" }
|
||||
{ id: "manage", label: "管理" },
|
||||
{ id: "llm", label: "AI 助手" }
|
||||
];
|
||||
|
||||
export interface DiffLine {
|
||||
@@ -177,5 +174,5 @@ export interface LlmSuggestionView {
|
||||
recommendation: string;
|
||||
diffId?: string;
|
||||
expiresAt?: string;
|
||||
diff?: ConfigDiffView;
|
||||
diffSummary?: string;
|
||||
}
|
||||
|
||||
@@ -5,20 +5,6 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location ~ ^/api/v1/server-instances/[^/]+/logs/events$ {
|
||||
proxy_pass http://platform:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header X-Accel-Buffering no;
|
||||
}
|
||||
|
||||
location /api/v1/ {
|
||||
proxy_pass http://platform:8080/api/v1/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ServersPage } from "./ServersPage";
|
||||
import { UsersPage } from "./UsersPage";
|
||||
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
|
||||
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
|
||||
import serverLiveOperationsSource from "../components/ServerLiveOperations.tsx?raw";
|
||||
import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw";
|
||||
import serversPageSource from "./ServersPage.tsx?raw";
|
||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||
@@ -214,17 +213,25 @@ describe("first-party console pages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps live logs and management terminal on server detail instead of the server list", () => {
|
||||
it("removes raw log and management terminal entry points from server pages", () => {
|
||||
expect(serversPageSource).not.toContain("ServerLiveLogDrawer");
|
||||
expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer");
|
||||
expect(serversPageSource).not.toContain("live-logs");
|
||||
expect(serversPageSource).not.toContain("historical-logs");
|
||||
expect(serversPageSource).not.toContain("requestLogBackfill");
|
||||
expect(serversPageSource).not.toContain("管理终端");
|
||||
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
|
||||
expect(serverDetailPageSource).not.toContain("ServerManagementTerminalDrawer");
|
||||
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
||||
expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer");
|
||||
expect(serverDetailPageSource).not.toContain('<span>实时日志</span>');
|
||||
expect(serverDetailPageSource).not.toContain("openServerLogEvents");
|
||||
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
||||
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
||||
expect(serverDetailPageSource).not.toContain("操作历史");
|
||||
expect(serverDetailPageSource).toContain("runtimeObservationFreshness");
|
||||
expect(serverDetailPageSource).toContain("Run 未验证");
|
||||
expect(serverDetailPageSource).toContain("管理终端");
|
||||
expect(serverDetailPageSource).toContain("PluginPageSection");
|
||||
expect(serverDetailPageSource).toContain("AI 配置助手");
|
||||
});
|
||||
|
||||
it("keeps create target and profile controls out while preserving deployment steps", () => {
|
||||
@@ -311,52 +318,30 @@ describe("first-party console pages", () => {
|
||||
|
||||
expect(html).toContain("返回列表");
|
||||
expect(html).not.toContain("概览");
|
||||
expect(html).toContain("日志");
|
||||
expect(html).toContain("管理终端");
|
||||
expect(html).toContain("配置");
|
||||
expect(html).toContain("管理");
|
||||
expect(html).not.toContain("日志");
|
||||
expect(html).not.toContain("管理终端");
|
||||
expect(html).not.toContain("配置");
|
||||
expect(html).not.toContain("运行操作");
|
||||
expect(html).not.toContain("插件控制");
|
||||
expect(html).toContain("AI 助手");
|
||||
expect(html).toContain("操作历史");
|
||||
expect(html).not.toContain("操作历史");
|
||||
});
|
||||
|
||||
it("renders management terminal as a large command console with quick commands", () => {
|
||||
expect(serverLiveOperationsSource).toContain("management-terminal-drawer");
|
||||
expect(serverLiveOperationsSource).toContain("terminal-output-topbar");
|
||||
expect(serverLiveOperationsSource).toContain("terminal-command-dock");
|
||||
expect(serverLiveOperationsSource).toContain("terminalQuickCommandCatalog");
|
||||
expect(serverLiveOperationsSource).toContain("terminalQuickCommandsForPlugin");
|
||||
expect(serverLiveOperationsSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(serverLiveOperationsSource).toContain("scumManagementRCONCommandRequest");
|
||||
expect(serverLiveOperationsSource).toContain("#ListSquads");
|
||||
expect(serverLiveOperationsSource).toContain("#ListSpawnedVehicles");
|
||||
expect(serverLiveOperationsSource).toContain("selectQuickCommand(item)");
|
||||
expect(serverLiveOperationsSource).toContain("hideHeader");
|
||||
expect(serverLiveOperationsSource).toContain("handleCommandKeyDown");
|
||||
expect(serverLiveOperationsSource).toContain("commandHistory");
|
||||
expect(serverLiveOperationsSource).toContain("ArrowUp");
|
||||
expect(serverLiveOperationsSource).not.toContain("listServerLiveLogs");
|
||||
expect(serverLiveOperationsSource).toContain("openServerLogEvents");
|
||||
expect(serverLiveOperationsSource).toContain("getGameClientBridgeCommand");
|
||||
expect(serverLiveOperationsSource).toContain("terminalLineFromBridgeCommand");
|
||||
expect(serverLiveOperationsSource).not.toContain("terminalRelevantStreams");
|
||||
expect(serverLiveOperationsSource).toContain("SSE 实时推送");
|
||||
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
|
||||
expect(serverLiveOperationsSource).toContain("terminalInitialHistoryWindow = 500");
|
||||
expect(serverLiveOperationsSource).toContain("maxTerminalLines = 10000");
|
||||
expect(serverLiveOperationsSource).toContain("followLatestRef");
|
||||
expect(serverLiveOperationsSource).toContain("initialHistoryPendingRef");
|
||||
expect(serverLiveOperationsSource).toContain("lockTerminalFollow");
|
||||
expect(serverLiveOperationsSource).toContain("handleTerminalScroll");
|
||||
expect(serverLiveOperationsSource).toContain("scrollHeight - output.clientHeight - output.scrollTop <= 24");
|
||||
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
|
||||
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
|
||||
expect(serverLiveOperationsSource).not.toContain("queryLogStream(");
|
||||
expect(serverLiveOperationsSource).not.toContain("SaveWorld");
|
||||
it("routes SCUM operations through typed plugin workflow surfaces", () => {
|
||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||
expect(serverDetailPageSource).toContain("plugin:${page.key}");
|
||||
expect(serverDetailPageSource).toContain("section === \"llm\"");
|
||||
expect(serverDetailPageSource).not.toContain("terminalQuickCommandCatalog");
|
||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
||||
expect(serverDetailPageSource).not.toContain("commandHistory");
|
||||
});
|
||||
|
||||
it("uses declared SCUM log source keys for log backfill defaults", () => {
|
||||
expect(serversPageSource).toContain("scum-server-events");
|
||||
it("does not expose SCUM log backfill defaults as product actions", () => {
|
||||
expect(serversPageSource).not.toContain("scum-server-events");
|
||||
expect(serversPageSource).not.toContain("requestLogBackfill");
|
||||
expect(serversPageSource).not.toContain("historical-logs");
|
||||
expect(serversPageSource).not.toContain("server-log");
|
||||
expect(serverDetailPageSource).not.toContain("server-log");
|
||||
});
|
||||
|
||||
@@ -26,19 +26,19 @@ const plugin: GamePluginResponse = {
|
||||
manifestRef: "artifact://manifests/game.scum/1.0.0",
|
||||
createFormSchemaRef: "schemas/create-form.schema.json",
|
||||
requiredRunCapabilities: [],
|
||||
declaredPermissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"],
|
||||
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: false },
|
||||
declaredPermissions: ["server.read", "server.game-client.read", "server.game-client.command"],
|
||||
permissions: { ai: true, logs: false, files: false, jobs: true, artifacts: false, remoteAccess: false },
|
||||
lifecycleActions: {},
|
||||
bridgeActions: ["server.instances.read", "logs.query"],
|
||||
bridgeActions: ["server.instances.read"],
|
||||
pages: [{
|
||||
key: "files-config",
|
||||
title: "文件与配置",
|
||||
path: "/files-config",
|
||||
key: "players",
|
||||
title: "用户管理",
|
||||
path: "/players",
|
||||
bundleKey: "scum-server-plugin",
|
||||
bundleVersion: "1.0.1",
|
||||
bundleIntegritySha256: "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read"],
|
||||
bridgeActions: ["server.instances.read", "logs.query"]
|
||||
bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
permissions: ["server.game-client.read", "server.game-client.command"],
|
||||
bridgeActions: ["server.instances.read"]
|
||||
}],
|
||||
tags: ["scum"],
|
||||
aiPurposes: [],
|
||||
@@ -48,7 +48,7 @@ const plugin: GamePluginResponse = {
|
||||
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
|
||||
pages: [{ pageKey: "players", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
|
||||
},
|
||||
status: "installed"
|
||||
};
|
||||
@@ -65,7 +65,7 @@ function props(serverId = "server-1"): PageComponentProps {
|
||||
};
|
||||
return {
|
||||
session,
|
||||
params: { pluginId: "game.scum", routeKey: "files-config", serverId },
|
||||
params: { pluginId: "game.scum", routeKey: "players", serverId },
|
||||
operations,
|
||||
onNavigate: () => undefined,
|
||||
onLogout: async () => undefined,
|
||||
@@ -77,7 +77,7 @@ function props(serverId = "server-1"): PageComponentProps {
|
||||
describe("PluginPageHostPage", () => {
|
||||
it("renders a generic manifest-owned bundle declaration", () => {
|
||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
|
||||
expect(html).toContain("文件与配置");
|
||||
expect(html).toContain("用户管理");
|
||||
expect(html).toContain("平台托管上下文");
|
||||
expect(html).toContain("scum-server-plugin@1.0.1");
|
||||
expect(html).toContain("完整性");
|
||||
@@ -96,11 +96,17 @@ describe("PluginPageHostPage", () => {
|
||||
expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/);
|
||||
});
|
||||
|
||||
it("keeps file workspace callbacks stable across parent operational refreshes", () => {
|
||||
it("keeps typed SCUM workspace callbacks stable across parent operational refreshes", () => {
|
||||
expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
|
||||
expect(hostSource).toContain("hostContextRef.current = hostContext");
|
||||
expect(hostSource).toContain("refreshWorkspace: async ()");
|
||||
expect(hostSource).toContain("platformApiClient.listGamePlugins()");
|
||||
expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)");
|
||||
expect(hostSource).toContain("createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never)");
|
||||
expect(hostSource).toContain("createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never)");
|
||||
expect(hostSource).toContain("listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)");
|
||||
expect(hostSource).not.toContain("refreshWorkspace");
|
||||
expect(hostSource).not.toContain("requestFile");
|
||||
expect(hostSource).not.toContain("writeFile");
|
||||
expect(hostSource).not.toContain("getDeclaredFileReadSnapshot");
|
||||
expect(hostSource).toContain("}, [pluginId, serverId]);");
|
||||
expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);");
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
|
||||
import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
|
||||
|
||||
type PluginPageState =
|
||||
@@ -27,7 +27,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
const routeKey = params.routeKey ?? "";
|
||||
const serverId = params.serverId ?? "";
|
||||
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
|
||||
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
|
||||
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
|
||||
const [bundleError, setBundleError] = useState("");
|
||||
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" });
|
||||
const readyPluginRef = useRef<GamePluginResponse | undefined>(undefined);
|
||||
@@ -69,97 +69,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
|
||||
if (!pluginId) return undefined;
|
||||
return {
|
||||
refreshWorkspace: async () => {
|
||||
const response = await platformApiClient.listGamePlugins();
|
||||
const plugin = response.items.find((candidate) => candidate.id === pluginId);
|
||||
if (!plugin) {
|
||||
throw new Error("未找到已注册的插件声明。");
|
||||
}
|
||||
readyPluginRef.current = plugin;
|
||||
setState({ status: "ready", plugin });
|
||||
return plugin.fileWorkspace;
|
||||
},
|
||||
requestFile: async (fileKey) => {
|
||||
const plugin = readyPluginRef.current;
|
||||
const context = hostContextRef.current;
|
||||
if (!serverId) {
|
||||
return { status: "denied", message: "插件页面没有绑定服务器,无法读取文件。" };
|
||||
}
|
||||
if (!plugin || !context) {
|
||||
return { status: "denied", message: "插件页面上下文尚未就绪。" };
|
||||
}
|
||||
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
|
||||
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
|
||||
}
|
||||
const dispatch = createPluginBridgeDispatcher(context, platformApiClient);
|
||||
const response = await dispatch({
|
||||
requestId: `web:plugin-file-read:${serverId}:${fileKey}:${Date.now()}`,
|
||||
action: "files.request",
|
||||
payload: { operation: "read", key: fileKey }
|
||||
});
|
||||
const jobId = response.result?.jobId;
|
||||
if (response.status === "queued" || response.status === "ok") {
|
||||
return { status: response.status, jobId, message: jobId ? `读取任务 ${jobId} 已提交。` : "已提交文件读取请求。" };
|
||||
}
|
||||
return { status: response.status, message: response.error?.message ?? "文件读取请求未能提交。" };
|
||||
},
|
||||
getFileSnapshot: async (fileKey) => {
|
||||
const plugin = readyPluginRef.current;
|
||||
if (!serverId) {
|
||||
return { serverInstanceId: "", pluginId, key: fileKey, state: "unavailable", reason: "插件页面没有绑定服务器,无法读取文件快照。" };
|
||||
}
|
||||
if (!plugin) {
|
||||
return { serverInstanceId: serverId, pluginId, key: fileKey, state: "unavailable", reason: "插件页面上下文尚未就绪。" };
|
||||
}
|
||||
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
|
||||
return { serverInstanceId: serverId, pluginId: plugin.id, key: fileKey, state: "unavailable", reason: "该文件不在当前插件声明的工作区内。" };
|
||||
}
|
||||
try {
|
||||
return await platformApiClient.getDeclaredFileReadSnapshot(serverId, fileKey);
|
||||
} catch (error) {
|
||||
return {
|
||||
serverInstanceId: serverId,
|
||||
pluginId: plugin.id,
|
||||
key: fileKey,
|
||||
state: "unavailable",
|
||||
reason: error instanceof Error ? error.message : "无法读取文件快照。"
|
||||
};
|
||||
}
|
||||
},
|
||||
writeFile: async (fileKey, content, options) => {
|
||||
const plugin = readyPluginRef.current;
|
||||
const context = hostContextRef.current;
|
||||
if (!serverId) {
|
||||
return { status: "denied", message: "插件页面没有绑定服务器,无法写入文件。" };
|
||||
}
|
||||
if (!plugin || !context) {
|
||||
return { status: "denied", message: "插件页面上下文尚未就绪。" };
|
||||
}
|
||||
const file = plugin.fileWorkspace?.files.find((candidate) => candidate.key === fileKey);
|
||||
if (!file) {
|
||||
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
|
||||
}
|
||||
if (file.kind !== "config" || !file.editable) {
|
||||
return { status: "denied", message: "该声明文件不允许通过配置工作台写入。" };
|
||||
}
|
||||
if (!context.permissions.includes("server.files.write")) {
|
||||
return { status: "denied", message: "当前页面没有声明文件写入权限。" };
|
||||
}
|
||||
try {
|
||||
const response = await platformApiClient.dispatchFileOperation({
|
||||
serverInstanceId: serverId,
|
||||
pluginId: plugin.id,
|
||||
operation: "write",
|
||||
key: fileKey,
|
||||
content,
|
||||
expectedChecksum: options?.expectedChecksum,
|
||||
idempotencyKey: `web:plugin-file-write:${serverId}:${fileKey}:${Date.now()}`
|
||||
});
|
||||
return { status: response.status, jobId: response.job.id, message: `写入任务 ${response.job.id} 已提交。` };
|
||||
} catch (error) {
|
||||
return { status: "error", message: error instanceof Error ? error.message : "文件写入请求未能提交。" };
|
||||
}
|
||||
}
|
||||
listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId),
|
||||
listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId),
|
||||
listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId),
|
||||
listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId),
|
||||
listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId),
|
||||
listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId),
|
||||
listSCUMOperations: () => platformApiClient.listSCUMOperations(serverId),
|
||||
createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never),
|
||||
approveSCUMOperation: (operationId) => platformApiClient.approveSCUMOperation(serverId, operationId),
|
||||
listSCUMWorkflows: () => platformApiClient.listSCUMWorkflows(serverId),
|
||||
createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never),
|
||||
listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)
|
||||
};
|
||||
}, [pluginId, serverId]);
|
||||
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
|
||||
@@ -196,7 +117,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
<>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -226,7 +147,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
</section>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { configDiffViewFromPreview } from "./ServerDetailPage";
|
||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||
import sourceRCONCommandPanelSource from "../components/SourceRCONCommandPanel.tsx?raw";
|
||||
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
|
||||
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
||||
|
||||
const preview: ServerConfigDiffPreviewResponse = {
|
||||
@@ -41,9 +39,11 @@ describe("ServerDetailPage config write approval", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses config preview and approval APIs instead of generic config.write job creation", () => {
|
||||
expect(serverDetailPageSource).toContain("previewServerConfigDiff");
|
||||
expect(serverDetailPageSource).toContain("approveServerConfigWrite");
|
||||
it("uses AI config diff approval without exposing raw config workbench APIs", () => {
|
||||
expect(serverDetailPageSource).toContain("approveAIConfigDiff");
|
||||
expect(serverDetailPageSource).toContain("AI 配置助手");
|
||||
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
||||
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
||||
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
|
||||
});
|
||||
|
||||
@@ -62,10 +62,10 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
|
||||
});
|
||||
|
||||
it("uses platform-mediated artifact download and bridge references without backend internals", () => {
|
||||
expect(serverDetailPageSource).toContain("openArtifactDownload");
|
||||
expect(serverDetailPageSource).toContain("readArtifactContent");
|
||||
expect(serverDetailPageSource).toContain("浏览器制品传输");
|
||||
it("keeps artifact transfer and backend internals out of server detail", () => {
|
||||
expect(serverDetailPageSource).not.toContain("openArtifactDownload");
|
||||
expect(serverDetailPageSource).not.toContain("readArtifactContent");
|
||||
expect(serverDetailPageSource).not.toContain("浏览器制品传输");
|
||||
expect(serverDetailPageSource).not.toContain("storage://");
|
||||
expect(serverDetailPageSource).not.toContain("unix://");
|
||||
expect(serverDetailPageSource).not.toContain("Bearer ");
|
||||
@@ -79,7 +79,7 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("pushRunUpdate");
|
||||
expect(serverDetailPageSource).not.toContain("generateClientManager");
|
||||
expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel");
|
||||
expect(serverDetailPageSource).toContain("openServerLogEvents");
|
||||
expect(serverDetailPageSource).not.toContain("openServerLogEvents");
|
||||
expect(serverDetailPageSource).not.toContain("authKey");
|
||||
expect(serverDetailPageSource).not.toContain("password=");
|
||||
expect(serverDetailPageSource).not.toContain("unix://");
|
||||
@@ -95,17 +95,12 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("RuntimeDLLExtensionsPanel");
|
||||
});
|
||||
|
||||
it("adds SCUM announcements and raw commands through protected RCON bridge jobs", () => {
|
||||
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel");
|
||||
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest");
|
||||
expect(sourceRCONCommandPanelSource).toContain("不保留指令原文");
|
||||
expect(sourceRCONCommandPanelSource).toContain("执行结果以 Run 日志为准");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||
for (const forbidden of ["password", "host", "transcript", "history"]) {
|
||||
expect(sourceRCONCommandPanelSource).not.toContain(forbidden);
|
||||
}
|
||||
it("does not expose raw SCUM RCON command panels", () => {
|
||||
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
||||
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||
expect(serverDetailPageSource).not.toContain("管理终端");
|
||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||
});
|
||||
|
||||
it("removes generic plugin controls from server detail", () => {
|
||||
@@ -136,12 +131,9 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("ScumFileManagementSection");
|
||||
});
|
||||
|
||||
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => {
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
|
||||
expect(serverDetailPageSource).toContain("downloadArtifactReference(reference");
|
||||
expect(artifactTransferSource).toContain("readContent(reference.artifactId");
|
||||
expect(artifactTransferSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
|
||||
expect(artifactTransferSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
|
||||
it("keeps plugin lifecycle output out of raw bridge-visible detail surfaces", () => {
|
||||
expect(serverDetailPageSource).not.toContain("platformApiClient.openArtifactDownload(artifact.id)");
|
||||
expect(serverDetailPageSource).not.toContain("downloadArtifactReference(reference");
|
||||
expect(serverDetailPageSource).not.toContain("storage://bucket");
|
||||
expect(serverDetailPageSource).not.toContain("runSocket");
|
||||
expect(serverDetailPageSource).not.toContain("rawApiKey");
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
import { Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type {
|
||||
ConfigDiffLineResponse,
|
||||
ArtifactResponse,
|
||||
BackupResponse,
|
||||
GamePluginResponse,
|
||||
JobResponse,
|
||||
LogStreamResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
ServerConfigResponse,
|
||||
ServerInstanceResponse,
|
||||
ServerMemberResponse,
|
||||
ServerMetricsResponse,
|
||||
ServerDeploymentResponse,
|
||||
MetricSampleResponse,
|
||||
RemoteAdapterDeclarationResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
RunEndpointResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||||
import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
|
||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import { ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
isPlatformAdmin,
|
||||
type ConfigDiffView,
|
||||
type LlmSuggestionView,
|
||||
type ServerDetailSection
|
||||
@@ -39,34 +28,25 @@ import {
|
||||
serverLifecycleCommandRequest,
|
||||
serverMetadataUpdateRequestFromForm
|
||||
} from "../schemas/serverManagement";
|
||||
import { diffHasChanges } from "../utils/diff";
|
||||
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
|
||||
import { cx } from "../utils/classes";
|
||||
import { stateLabel, statusClass } from "./ServersPage";
|
||||
import { PluginPageHostPage } from "./PluginPageHostPage";
|
||||
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
|
||||
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
|
||||
const defaultConfigKey = "server.properties";
|
||||
const serverDetailRefreshMs = 5000;
|
||||
const serverMetricFreshMs = 30000;
|
||||
|
||||
export function ServerDetailPage(props: PageComponentProps) {
|
||||
const { session, params, operations, onNavigate } = props;
|
||||
const serverId = params.serverId ?? "";
|
||||
const [section, setSection] = useState<ServerDetailSection>("logs");
|
||||
const [section, setSection] = useState<ServerDetailSection>("manage");
|
||||
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
|
||||
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
|
||||
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 [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
|
||||
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
@@ -77,7 +57,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
}
|
||||
setInstance({ status: "loading" });
|
||||
try {
|
||||
const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse, endpointResponse] = await Promise.all([
|
||||
const [detail, pluginResponse, jobResponse, deploymentResponse, endpointResponse] = await Promise.all([
|
||||
platformApiClient.getServerInstance(serverId),
|
||||
platformApiClient.listGamePlugins(),
|
||||
platformApiClient.listJobs(serverId),
|
||||
@@ -85,36 +65,17 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
.getServerDeployment(serverId)
|
||||
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<ServerDeploymentResponse> => ({ 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 })),
|
||||
platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 }))
|
||||
]);
|
||||
setInstance({ status: "ready", data: detail });
|
||||
setPlugins(pluginResponse.items);
|
||||
setJobs(jobResponse.items);
|
||||
setDeployment(deploymentResponse);
|
||||
setMetricHistory(metricHistoryResponse.items);
|
||||
setBackups(backupResponse.items);
|
||||
setRemoteAdapters(adapterResponse.items);
|
||||
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
|
||||
const artifactLists = await Promise.all(
|
||||
jobResponse.items.slice(0, 20).map((job) =>
|
||||
platformApiClient
|
||||
.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })
|
||||
.then((response) => response.items)
|
||||
.catch(() => [] as ArtifactResponse[])
|
||||
)
|
||||
);
|
||||
setArtifacts(uniqueArtifacts(artifactLists.flat()));
|
||||
} catch (error) {
|
||||
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||
setArtifacts([]);
|
||||
setRunEndpoint(undefined);
|
||||
setDeployment({ status: "error", reason: "部署定义加载失败" });
|
||||
setMetricHistory([]);
|
||||
setBackups([]);
|
||||
setRemoteAdapters([]);
|
||||
}
|
||||
try {
|
||||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||||
@@ -151,10 +112,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshOperationalState]);
|
||||
|
||||
const serverOperations = useMemo(
|
||||
() => operations.operations.filter((operation) => operation.targetId === serverId || operation.targetId.startsWith(`${serverId}:`)),
|
||||
[operations.operations, serverId]
|
||||
);
|
||||
const canManageServers = session.capabilities.includes("servers.manage");
|
||||
const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined;
|
||||
const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified";
|
||||
@@ -168,7 +125,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (detailSections.some((entry) => entry.id === section)) return;
|
||||
setSection(detailSections[0]?.id ?? "logs");
|
||||
setSection(detailSections[0]?.id ?? "manage");
|
||||
}, [detailSections, section]);
|
||||
|
||||
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
|
||||
@@ -270,7 +227,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
<Square size={15} />
|
||||
<span>停止</span>
|
||||
</button>
|
||||
<button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "管理终端" : "当前账号没有管理权限"} onClick={() => setTerminalOpen(true)}><Terminal size={15} /><span>管理终端</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="server-detail-stat-strip">
|
||||
@@ -301,11 +257,9 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{section === "logs" && <LogsSection serverId={serverId} />}
|
||||
{section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
|
||||
{pluginPageKeyFromSection(section) && readyPlugin && <PluginPageSection pageProps={props} serverId={serverId} plugin={readyPlugin} routeKey={pluginPageKeyFromSection(section) ?? ""} />}
|
||||
{section === "config" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
|
||||
{section === "config" && (
|
||||
{section === "manage" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
|
||||
{section === "manage" && (
|
||||
<ServerMetadataSection
|
||||
instance={instance.data}
|
||||
session={session}
|
||||
@@ -313,11 +267,8 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
onChanged={(next) => setInstance({ status: "ready", data: next })}
|
||||
/>
|
||||
)}
|
||||
{section === "config" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
|
||||
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
{section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
|
||||
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
|
||||
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -344,14 +295,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
|
||||
const byID = new Map<string, ArtifactResponse>();
|
||||
for (const artifact of artifacts) {
|
||||
byID.set(artifact.id, artifact);
|
||||
}
|
||||
return [...byID.values()];
|
||||
}
|
||||
|
||||
function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> {
|
||||
const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title }));
|
||||
return [...pluginPages, ...serverDetailSections];
|
||||
@@ -619,343 +562,6 @@ function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string {
|
||||
return new Date(metrics.collectedAt).toLocaleTimeString();
|
||||
}
|
||||
|
||||
interface LogsSectionProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface LogFilterState {
|
||||
level: string;
|
||||
keyword: string;
|
||||
source: string;
|
||||
sinceMinutes: string;
|
||||
}
|
||||
|
||||
function LogsSection({ serverId }: LogsSectionProps) {
|
||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
|
||||
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
|
||||
const [selected, setSelected] = useState<LiveLogEntry | null>(null);
|
||||
const [eventSourceKey, setEventSourceKey] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => setEventSourceKey((current) => current + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
setStreams({ status: "loading" });
|
||||
setEntries([]);
|
||||
setSelected(null);
|
||||
let ready = false;
|
||||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: 200 });
|
||||
events.addEventListener("stream", (event) => {
|
||||
const stream = parseLogStreamEvent(event);
|
||||
if (!stream) return;
|
||||
ready = true;
|
||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||
});
|
||||
events.addEventListener("ready", () => {
|
||||
ready = true;
|
||||
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
||||
});
|
||||
events.addEventListener("log", (event) => {
|
||||
const payload = parseServerLogEvent(event);
|
||||
if (!payload) return;
|
||||
ready = true;
|
||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
|
||||
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], 1000));
|
||||
});
|
||||
events.onerror = () => {
|
||||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||
};
|
||||
return () => events.close();
|
||||
}, [eventSourceKey, serverId]);
|
||||
|
||||
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const keyword = filter.keyword.trim().toLowerCase();
|
||||
const sinceMs = filter.sinceMinutes === "all" ? null : Date.now() - Number(filter.sinceMinutes) * 60_000;
|
||||
return entries.filter((entry) => {
|
||||
if (filter.level !== "all" && (entry.level ?? "info").toLowerCase() !== filter.level) {
|
||||
return false;
|
||||
}
|
||||
if (filter.source !== "all" && entry.source !== filter.source) {
|
||||
return false;
|
||||
}
|
||||
if (keyword && !entry.line.toLowerCase().includes(keyword)) {
|
||||
return false;
|
||||
}
|
||||
if (sinceMs !== null && new Date(entry.timestamp).getTime() < sinceMs) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).sort(compareLogEntriesDesc);
|
||||
}, [entries, filter]);
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="server logs">
|
||||
<div className="panel-header">
|
||||
<h2>日志</h2>
|
||||
<button type="button" className="icon-command" onClick={refresh}>
|
||||
<Sparkles size={14} />
|
||||
<span>重连</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="log-filter-bar">
|
||||
<select value={filter.level} aria-label="按级别过滤" onChange={(event) => setFilter((current) => ({ ...current, level: event.target.value }))}>
|
||||
<option value="all">全部级别</option>
|
||||
<option value="error">error</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="info">info</option>
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
<select value={filter.source} aria-label="按来源过滤" onChange={(event) => setFilter((current) => ({ ...current, source: event.target.value }))}>
|
||||
<option value="all">全部来源</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filter.sinceMinutes}
|
||||
aria-label="按时间过滤"
|
||||
onChange={(event) => setFilter((current) => ({ ...current, sinceMinutes: event.target.value }))}
|
||||
>
|
||||
<option value="all">全部时间</option>
|
||||
<option value="15">最近 15 分钟</option>
|
||||
<option value="60">最近 1 小时</option>
|
||||
<option value="1440">最近 24 小时</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
value={filter.keyword}
|
||||
placeholder="关键字"
|
||||
aria-label="按关键字过滤"
|
||||
onChange={(event) => setFilter((current) => ({ ...current, keyword: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{streams.status === "loading" && <LoadingState label="正在加载日志…" compact />}
|
||||
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={refresh} compact />}
|
||||
{streams.status === "ready" && entries.length === 0 && (
|
||||
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="重连" onAction={refresh} />
|
||||
)}
|
||||
{streams.status === "ready" && entries.length > 0 && visible.length === 0 && (
|
||||
<EmptyState title="没有匹配的日志" description="调整级别、来源、时间范围或关键字后再试。" />
|
||||
)}
|
||||
{visible.length > 0 && (
|
||||
<div className="log-list" role="list">
|
||||
{visible.map((entry) => (
|
||||
<button key={`${entry.streamId}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
|
||||
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
|
||||
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
|
||||
<span>{entry.line}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="drawer-backdrop" role="presentation" onClick={() => setSelected(null)}>
|
||||
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="日志详情" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="panel-header">
|
||||
<h2>日志详情</h2>
|
||||
<button type="button" className="drawer-close" onClick={() => setSelected(null)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>时间</dt>
|
||||
<dd>{new Date(selected.timestamp).toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>级别</dt>
|
||||
<dd>{(selected.level ?? "info").toUpperCase()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>来源</dt>
|
||||
<dd>{selected.source}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>序号</dt>
|
||||
<dd>{selected.seq}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>内容</dt>
|
||||
<dd>{selected.line}</dd>
|
||||
</div>
|
||||
{selected.fields && Object.keys(selected.fields).length > 0 && (
|
||||
<div>
|
||||
<dt>字段</dt>
|
||||
<dd>
|
||||
{Object.entries(selected.fields)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ")}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<DiagnosticSummary diagnosticId={`log:${serverId}:${selected.source}:${selected.seq}`} detail={selected.line} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function levelClass(level?: string): string {
|
||||
const normalized = (level ?? "info").toLowerCase();
|
||||
if (normalized === "error" || normalized === "fatal") {
|
||||
return "log-level-error";
|
||||
}
|
||||
if (normalized === "warn" || normalized === "warning") {
|
||||
return "log-level-warn";
|
||||
}
|
||||
return "log-level-info";
|
||||
}
|
||||
|
||||
function compareLogEntriesDesc(a: LiveLogEntry, b: LiveLogEntry): number {
|
||||
const time = (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0);
|
||||
if (time !== 0) return time;
|
||||
return b.seq - a.seq;
|
||||
}
|
||||
|
||||
interface ConfigSectionProps {
|
||||
serverId: string;
|
||||
instance: ServerInstanceResponse;
|
||||
session: PageComponentProps["session"];
|
||||
operations: PageComponentProps["operations"];
|
||||
}
|
||||
|
||||
function ConfigSection({ serverId, instance, session, operations }: ConfigSectionProps) {
|
||||
const [config, setConfig] = useState<LoadState<{ content: string; source: "api" | "local" }>>({ status: "loading" });
|
||||
const [draft, setDraft] = useState("");
|
||||
const [diff, setDiff] = useState<ConfigDiffView | null>(null);
|
||||
const [previewBusy, setPreviewBusy] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setConfig({ status: "loading" });
|
||||
try {
|
||||
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
|
||||
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
|
||||
setDraft(response.content);
|
||||
} catch (error) {
|
||||
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
|
||||
setDraft("");
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function prepareDiff(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (config.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
setPreviewBusy(true);
|
||||
setPreviewError(null);
|
||||
try {
|
||||
const preview = await platformApiClient.previewServerConfigDiff(serverId, {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
key: defaultConfigKey,
|
||||
proposedContent: draft
|
||||
});
|
||||
setDiff(configDiffViewFromPreview(preview));
|
||||
} catch (error) {
|
||||
setPreviewError(error instanceof Error ? error.message : "配置差异预览失败");
|
||||
} finally {
|
||||
setPreviewBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDiff() {
|
||||
if (!diff || config.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
const operationId = operations.begin({ intent: "写入配置", targetKind: "config", targetId: serverId, requester: session.displayName });
|
||||
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,
|
||||
idempotencyKey: `web:config.write:${serverId}:${Date.now()}`
|
||||
});
|
||||
const job = dispatch.job;
|
||||
operations.succeed(operationId, `配置写入任务 ${job.id} 已派发`, job);
|
||||
setDiff(null);
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "配置写入任务派发失败", operationId);
|
||||
}
|
||||
}
|
||||
|
||||
const writeOperation = operations.operations.find((operation) => operation.intent === "写入配置" && operation.targetId === serverId);
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="server configuration">
|
||||
<div className="panel-header">
|
||||
<h2>配置</h2>
|
||||
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}{instance.configChecksum ? ` · ${instance.configChecksum.slice(0, 18)}` : ""}</span>}
|
||||
</div>
|
||||
{writeOperation && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<ResultBadge
|
||||
status={writeOperation.status}
|
||||
label={
|
||||
writeOperation.status === "pending"
|
||||
? "配置写入中…"
|
||||
: writeOperation.status === "succeeded"
|
||||
? (writeOperation.message ?? "写入任务已派发")
|
||||
: `写入失败:${writeOperation.errorReason}(诊断 ${writeOperation.diagnosticId})`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
|
||||
{config.status === "error" && <ErrorState title="配置读取不可用" reason={config.reason} diagnosticId={`server-config:${serverId}`} onRetry={() => void refresh()} compact />}
|
||||
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
|
||||
{config.status === "ready" && (
|
||||
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
|
||||
<label>
|
||||
配置内容
|
||||
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} rows={10} aria-label="配置编辑器" />
|
||||
</label>
|
||||
<button type="submit" className="primary-command" disabled={previewBusy || draft === config.data.content}>
|
||||
{previewBusy ? "预览中…" : "预览变更"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{diff && (
|
||||
<div className="drawer-backdrop" role="presentation" onClick={() => setDiff(null)}>
|
||||
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="配置变更确认" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="panel-header">
|
||||
<h2>确认配置变更</h2>
|
||||
<span className="page-status">{diff.summary}</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>
|
||||
目标服务器:<strong>{instance.name}</strong>({serverId})。请检查平台返回的差异,确认后才会派发写入任务。
|
||||
</p>
|
||||
<DiffView lines={diff.lines} />
|
||||
<div className="confirm-actions">
|
||||
<button type="button" onClick={() => setDiff(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="confirm-primary" disabled={!diffHasChanges(diff)} onClick={() => void submitDiff()}>
|
||||
确认并派发写入任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface LlmSectionProps {
|
||||
serverId: string;
|
||||
instance: ServerInstanceResponse;
|
||||
@@ -985,21 +591,13 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
|
||||
}
|
||||
const recommendation = response.configRecommendation;
|
||||
const preview = recommendation?.suggestedConfig
|
||||
? await platformApiClient.previewServerConfigDiff(serverId, {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
key: recommendation.key,
|
||||
proposedContent: recommendation.suggestedConfig
|
||||
})
|
||||
: undefined;
|
||||
setSuggestion({
|
||||
serverInstanceId: serverId,
|
||||
source: "api",
|
||||
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
||||
diffId: recommendation?.diffId,
|
||||
expiresAt: recommendation?.expiresAt,
|
||||
diff: preview ? configDiffViewFromPreview(preview) : undefined
|
||||
diffSummary: recommendation?.diffSummary
|
||||
});
|
||||
} catch (caught) {
|
||||
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
||||
@@ -1009,7 +607,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
}
|
||||
|
||||
async function applySuggestion() {
|
||||
if (!suggestion?.diff || !suggestion.diffId || approvalBusy) {
|
||||
if (!suggestion?.diffId || approvalBusy) {
|
||||
return;
|
||||
}
|
||||
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
||||
@@ -1078,15 +676,19 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
<span className="page-status">平台 AI Provider</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
||||
{suggestion.diff ? (
|
||||
{suggestion.diffId ? (
|
||||
<>
|
||||
<DiffView lines={suggestion.diff.lines} />
|
||||
<div className="console-record">
|
||||
<div className="console-record-head"><strong>Reviewable AI diff</strong><span className="status-pill status-active">pending</span></div>
|
||||
<div className="console-record-meta"><span>Diff {suggestion.diffId}</span>{suggestion.expiresAt && <span>到期 {new Date(suggestion.expiresAt).toLocaleString()}</span>}</div>
|
||||
<span className="provider-id">{suggestion.diffSummary ?? "平台已保存可审查配置差异;批准后才会派发写入任务。"}</span>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button type="button" onClick={() => setSuggestion(null)}>
|
||||
放弃建议
|
||||
</button>
|
||||
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
|
||||
确认差异并写入
|
||||
审批 AI 差异
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -1132,207 +734,3 @@ function configDiffLineFromPreviewLine(line: ConfigDiffLineResponse): ConfigDiff
|
||||
text: line.content
|
||||
};
|
||||
}
|
||||
|
||||
interface HistorySectionProps {
|
||||
serverId: string;
|
||||
serverOperations: PageComponentProps["operations"]["operations"];
|
||||
jobs: JobResponse[];
|
||||
artifacts: ArtifactResponse[];
|
||||
metricHistory: MetricSampleResponse[];
|
||||
backups: BackupResponse[];
|
||||
remoteAdapters: RemoteAdapterDeclarationResponse[];
|
||||
}
|
||||
|
||||
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
|
||||
return (
|
||||
<>
|
||||
<ProductionGovernancePanel compact title={`服务器 ${serverId} 的容量与告警`} />
|
||||
<div className="overview-two-col" aria-label="operation history">
|
||||
<article className="console-panel">
|
||||
<div className="panel-header">
|
||||
<h2>本次会话操作</h2>
|
||||
</div>
|
||||
{serverOperations.length === 0 ? (
|
||||
<EmptyState title="暂无操作记录" description="在本页发起启动、停止、插件页面操作或配置写入后,这里会显示完整的操作生命周期。" />
|
||||
) : (
|
||||
<div className="console-record-list">
|
||||
{serverOperations.map((operation) => (
|
||||
<div key={operation.id} className="console-record">
|
||||
<div className="console-record-head">
|
||||
<strong>{operation.intent}</strong>
|
||||
<ResultBadge
|
||||
status={operation.status}
|
||||
label={operation.status === "pending" ? "进行中" : operation.status === "succeeded" ? "成功" : "失败"}
|
||||
/>
|
||||
</div>
|
||||
<div className="console-record-meta">
|
||||
<span>
|
||||
操作 <code>{operation.id}</code>
|
||||
</span>
|
||||
{operation.jobId && (
|
||||
<span>
|
||||
任务 <code>{operation.jobId}</code>
|
||||
{operation.jobState ? `(${operation.jobState})` : ""}
|
||||
</span>
|
||||
)}
|
||||
<span>目标 {operation.targetId}</span>
|
||||
<span>发起人 {operation.requester}</span>
|
||||
<span>{new Date(operation.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
{operation.message && <span className="provider-id">{operation.message}</span>}
|
||||
{operation.status === "failed" && operation.errorReason && (
|
||||
<span className="provider-id">
|
||||
失败原因:{operation.errorReason}
|
||||
{operation.diagnosticId ? `(诊断 ${operation.diagnosticId})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<article className="console-panel">
|
||||
<div className="panel-header">
|
||||
<h2>平台任务记录</h2>
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
<EmptyState title="暂无任务" description="该服务器还没有平台侧任务记录。" />
|
||||
) : (
|
||||
<div className="console-record-list">
|
||||
{jobs.slice(0, 20).map((job) => (
|
||||
<div key={job.id} className="console-record">
|
||||
<div className="console-record-head">
|
||||
<strong>{jobCapabilityLabel(job.capability)}</strong>
|
||||
<span className={cx("status-pill", job.state === "succeeded" ? "status-active" : job.state === "failed" ? "status-error" : "status-disabled")}>
|
||||
{job.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="console-record-meta">
|
||||
<span>
|
||||
任务 <code>{job.id}</code>
|
||||
</span>
|
||||
<span>进度 {job.progress.percent}%</span>
|
||||
{job.progress.phase && <span>阶段:{deploymentProgressLabel(job.progress)}</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="console-record-list">
|
||||
<div className="console-record">
|
||||
<div className="console-record-head"><strong>指标样本</strong><span className="status-pill status-active">{metricHistory.length} 条</span></div>
|
||||
<div className="console-record-meta"><span>最新采集 {metricHistory.length > 0 ? new Date(metricHistory[metricHistory.length - 1].collectedAt).toLocaleString() : "暂无"}</span></div>
|
||||
</div>
|
||||
<div className="console-record">
|
||||
<div className="console-record-head"><strong>备份记录</strong><span className="status-pill status-active">{backups.length} 条</span></div>
|
||||
<div className="console-record-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="console-record">
|
||||
<div className="console-record-head"><strong>远端适配器声明</strong><span className="status-pill status-active">{remoteAdapters.length} 个</span></div>
|
||||
<div className="console-record-meta">{remoteAdapters.slice(0, 4).map((adapter) => <span key={adapter.key}>{adapter.key} · {adapter.kind} · {adapter.targetKeys.join(", ")}</span>)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArtifactDownloadPanelProps {
|
||||
serverId: string;
|
||||
artifacts: ArtifactResponse[];
|
||||
}
|
||||
|
||||
function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<Record<string, { status: "pending" | "succeeded" | "failed"; label: string; progress?: number }>>({});
|
||||
|
||||
async function downloadArtifact(artifact: ArtifactResponse) {
|
||||
setActiveId(artifact.id);
|
||||
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } }));
|
||||
try {
|
||||
const reference = await platformApiClient.openArtifactDownload(artifact.id);
|
||||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit), (progress) => {
|
||||
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } }));
|
||||
});
|
||||
setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } }));
|
||||
} catch (error) {
|
||||
setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } }));
|
||||
} finally {
|
||||
setActiveId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel">
|
||||
<div className="panel-header">
|
||||
<h2>
|
||||
<PackageOpen size={16} />
|
||||
浏览器制品传输
|
||||
</h2>
|
||||
</div>
|
||||
{artifacts.length === 0 ? (
|
||||
<EmptyState title="暂无可下载制品" description="该服务器当前没有已完成的可用制品。" />
|
||||
) : (
|
||||
<div className="console-record-list">
|
||||
{artifacts.slice(0, 12).map((artifact) => {
|
||||
const itemResult = result[artifact.id];
|
||||
return (
|
||||
<div key={artifact.id} className="console-record">
|
||||
<div className="console-record-head">
|
||||
<strong>{artifact.id}</strong>
|
||||
<ResultBadge status={itemResult?.status ?? "pending"} label={itemResult?.label ?? artifact.state} />
|
||||
</div>
|
||||
<div className="console-record-meta">
|
||||
<span>服务器 {serverId}</span>
|
||||
<span>{formatBytes(artifact.sizeBytes)}</span>
|
||||
<span>{artifact.checksum}</span>
|
||||
</div>
|
||||
{itemResult?.progress !== undefined && <UsageMeter label="传输" percent={itemResult.progress} />}
|
||||
<div className="action-strip">
|
||||
<button type="button" className="icon-command" disabled={activeId !== null} onClick={() => void downloadArtifact(artifact)}>
|
||||
<Download size={14} />
|
||||
<span>{activeId === artifact.id ? "传输中" : "打开"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
runtimeDependencyStages,
|
||||
runtimeDownloadStages,
|
||||
runtimeKeyResetStages,
|
||||
runtimeLogStages,
|
||||
runtimeRunBuildStages,
|
||||
runtimeUpdateStages,
|
||||
useRuntimeTaskController
|
||||
@@ -33,7 +32,6 @@ import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStat
|
||||
import {
|
||||
clientManagerBuildRequest,
|
||||
dependencyJobRequest,
|
||||
logBackfillRequest,
|
||||
runDistributionGenerateRequest,
|
||||
runUpdateRequest,
|
||||
serverCreateRequestFromForm,
|
||||
@@ -367,8 +365,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
|
||||
return `依赖安装任务已排队,job ${job.id}`;
|
||||
}
|
||||
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
|
||||
return `历史日志回填任务已排队,job ${job.id}`;
|
||||
throw new Error("该运行操作已下线");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -677,8 +674,7 @@ type ServerQuickRuntimeAction =
|
||||
| "reset-run-key"
|
||||
| "generate-client-manager"
|
||||
| "dependencies-check"
|
||||
| "dependencies-install"
|
||||
| "historical-logs";
|
||||
| "dependencies-install";
|
||||
|
||||
async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> {
|
||||
const runtimeActions = await platformApiClient.getServerRuntimeActions(serverInstanceId);
|
||||
@@ -917,7 +913,7 @@ const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntim
|
||||
},
|
||||
{
|
||||
label: "诊断维护",
|
||||
actions: ["dependencies-check", "dependencies-install", "historical-logs"]
|
||||
actions: ["dependencies-check", "dependencies-install"]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -937,8 +933,6 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||||
return "依赖检查";
|
||||
case "dependencies-install":
|
||||
return "依赖安装";
|
||||
case "historical-logs":
|
||||
return "历史日志";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,7 +955,7 @@ function quickRuntimeStages(action: ServerQuickRuntimeAction) {
|
||||
if (action === "dependencies-check" || action === "dependencies-install") {
|
||||
return runtimeDependencyStages;
|
||||
}
|
||||
return runtimeLogStages;
|
||||
return runtimeDependencyStages;
|
||||
}
|
||||
|
||||
function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number {
|
||||
@@ -990,8 +984,7 @@ function quickRuntimeDefaultsForPlugin(pluginId: string) {
|
||||
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
|
||||
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
|
||||
probeKey: isScum ? "steamcmd" : "java-21",
|
||||
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
||||
logSourceKey: isScum ? "scum-server-events" : "latest-log"
|
||||
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ First-party routes must be declared here before page implementation.
|
||||
|
||||
- `/`: 平台概览(平台管理员默认落地页).
|
||||
- `/servers`: 服务器管理(服主/服务器管理员默认落地页).
|
||||
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、日志、管理终端、配置、AI 助手、操作历史).
|
||||
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、管理、AI 助手;不暴露 raw 日志、管理终端、raw 配置或操作历史页).
|
||||
- `/plugins`: 插件市场.
|
||||
- `/users`: 用户管理.
|
||||
- `/ai-providers`: AI 提供商管理.
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "./sourceRcon";
|
||||
|
||||
describe("Source RCON browser request schemas", () => {
|
||||
it("builds a bounded typed chat request without connection material", () => {
|
||||
expect(sourceRCONChatRequest("server-1", { chatType: 4, message: "hello", targetSteamId: "76561198000000001" }, 12)).toEqual({
|
||||
kind: "chat",
|
||||
chatType: 4,
|
||||
message: "hello",
|
||||
targetSteamId: "76561198000000001",
|
||||
idempotencyKey: "web:source-rcon:chat:server-1:12"
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects framed text and formats raw commands without chat fields", () => {
|
||||
expect(() => sourceRCONChatRequest("server-1", { chatType: 2, message: "line one\nline two" }, 13)).toThrow("受限的单行文本");
|
||||
expect(() => sourceRCONChatRequest("server-1", { chatType: 8, message: "hello" }, 13)).toThrow("聊天类型");
|
||||
expect(sourceRCONRawCommandRequest("server-1", " SetTime 12 ", 14)).toEqual({
|
||||
kind: "command",
|
||||
command: "SetTime 12",
|
||||
idempotencyKey: "web:source-rcon:command:server-1:14"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { SourceRCONCommandRequest } from "../api/types";
|
||||
|
||||
const maxChatBytes = 1024;
|
||||
const maxCommandBytes = 4000;
|
||||
const steamID64 = /^[0-9]{17}$/;
|
||||
|
||||
export interface SourceRCONChatDraft {
|
||||
chatType: number;
|
||||
message: string;
|
||||
targetSteamId?: string;
|
||||
}
|
||||
|
||||
export function sourceRCONChatRequest(serverInstanceId: string, draft: SourceRCONChatDraft, sequence = Date.now()): SourceRCONCommandRequest {
|
||||
const message = validateSourceRCONText(draft.message, maxChatBytes, "聊天内容");
|
||||
if (!Number.isInteger(draft.chatType) || draft.chatType < 0 || draft.chatType > 7) {
|
||||
throw new Error("聊天类型必须在 0 到 7 之间。");
|
||||
}
|
||||
const targetSteamId = draft.targetSteamId?.trim() ?? "";
|
||||
if (targetSteamId && !steamID64.test(targetSteamId)) {
|
||||
throw new Error("目标 SteamID64 必须为 17 位数字。");
|
||||
}
|
||||
return {
|
||||
kind: "chat",
|
||||
chatType: draft.chatType,
|
||||
message,
|
||||
targetSteamId: targetSteamId || undefined,
|
||||
idempotencyKey: sourceRCONIdempotencyKey("chat", serverInstanceId, sequence)
|
||||
};
|
||||
}
|
||||
|
||||
export function sourceRCONRawCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): SourceRCONCommandRequest {
|
||||
return {
|
||||
kind: "command",
|
||||
command: validateSourceRCONText(command, maxCommandBytes, "原始指令"),
|
||||
idempotencyKey: sourceRCONIdempotencyKey("command", serverInstanceId, sequence)
|
||||
};
|
||||
}
|
||||
|
||||
function validateSourceRCONText(value: string, maxBytes: number, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || new TextEncoder().encode(normalized).byteLength > maxBytes || /[\u0000\r\n]/.test(normalized)) {
|
||||
throw new Error(`${label}必须是受限的单行文本。`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sourceRCONIdempotencyKey(kind: "chat" | "command", serverInstanceId: string, sequence: number): string {
|
||||
return `web:source-rcon:${kind}:${serverInstanceId.trim()}:${Math.max(0, Math.floor(sequence))}`;
|
||||
}
|
||||
@@ -756,6 +756,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))}
|
||||
.console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px}
|
||||
.console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850}
|
||||
.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d)}.map-projection-dot{position:absolute;width:9px;height:9px;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%)}
|
||||
.console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px}
|
||||
.console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left}
|
||||
.console-row-button,.operations-job-row{width:100%;cursor:pointer}
|
||||
|
||||
Reference in New Issue
Block a user