diff --git a/platform/README.md b/platform/README.md index b0a90f9..34bf063 100644 --- a/platform/README.md +++ b/platform/README.md @@ -102,4 +102,4 @@ Client Manager installations are durable aggregates separate from Run distributi Component registration uses the current client-manager key generation, a timestamped nonce, and a short-lived hashed component session. It never reuses a Run session or job lease. Key reset revokes old sessions/artifacts and marks the installation for current-generation rebuild/redeploy. Run reports only logical health, phase, and bounded execution evidence; host paths, PIDs, sockets, raw keys, and credential material are not operator or plugin projections. Production KMS/code-signing, private source credentials, and fleet rollout remain explicit non-goals. -Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata. Server creation requires only the plugin type and server name; operators set a declared profile and complete logical bindings after creation, before any gated lifecycle/runtime action. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-controlled per-job input. This change uses controlled secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks. +Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata for advanced logical transports. Server creation requires only the plugin type and server name, and plugin-declared deployment/lifecycle actions must be enough for user-facing start/stop and generated Run package flows without forcing operators through a manual runtime-profile binding screen. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-controlled per-job input. This change uses controlled secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks. diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index b525bf9..d7459fc 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -1601,8 +1601,10 @@ func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) { if unconfigured.Configured || unconfigured.Reason != "runtime profile is not configured" { t.Fatalf("unexpected unconfigured projection: %+v", unconfigured) } - missingStart := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+server.ID+"/start", dto.ServerLifecycleCommandRequest{ExpectedConfigVersion: server.ConfigVersion, IdempotencyKey: "api-start-missing-binding"}, adminSession) - assertErrorResponse(t, missingStart, http.StatusBadRequest, errorCodeValidation) + missingStart := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/"+server.ID+"/start", dto.ServerLifecycleCommandRequest{ExpectedConfigVersion: server.ConfigVersion, IdempotencyKey: "api-start-missing-binding"}, adminSession) + if !missingStart.Accepted || missingStart.Job.TargetKey != "actions/start.json" { + t.Fatalf("expected plugin lifecycle start without manual runtime binding, got %+v", missingStart) + } incompleteRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession) assertStatus(t, incompleteRecorder, http.StatusOK) diff --git a/platform/api/routes.md b/platform/api/routes.md index 98f9f1a..1494fb0 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -163,9 +163,9 @@ Lifecycle workflow responses include accepted status, action, bounded server ins - `GET /api/v1/server-instances/{id}/logs/events`: streams selected server log metadata and entries as `text/event-stream`; the optional `historyLimit` query replays recent stored entries before live push events. - `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results. -Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. +Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. -`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding is set later through `PUT /api/v1/server-instances/{id}/runtime-binding`; until then, lifecycle and runtime-dependent actions return a safe configuration-required reason. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so. +`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so. ## Private Run Dependency And Update Routes diff --git a/platform/service/distributions.go b/platform/service/distributions.go index fc45d76..c8aeb52 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -542,8 +542,10 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.run.distribution", "run.update.denied"); err != nil { return domain.RunUpdateJob{}, err } - if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.update.denied"); err != nil { - return domain.RunUpdateJob{}, err + if deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) { + if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.update.denied"); err != nil { + return domain.RunUpdateJob{}, err + } } artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID) if err != nil { diff --git a/platform/service/runtime_bindings_test.go b/platform/service/runtime_bindings_test.go index db3ab06..5f70e6a 100644 --- a/platform/service/runtime_bindings_test.go +++ b/platform/service/runtime_bindings_test.go @@ -61,16 +61,18 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) { if err := svc.store.RuntimeBindings().Create(domain.RuntimeBinding{ID: "runtime-binding-" + forged.ID, ServerInstanceID: forged.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Bindings: map[string]string{}, Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}); err != nil { t.Fatalf("store forged complete binding: %v", err) } - if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: forged.ID, ExpectedConfigVersion: forged.ConfigVersion, IdempotencyKey: "start-forged-complete"}); err == nil || !strings.Contains(err.Error(), "rcon.password") { - t.Fatalf("expected derived missing keys to override stored complete status, got %v", err) + forgedView, err := svc.GetServerRuntimeBindingForSession(ownerSession, forged.ID) + if err != nil || forgedView.Status != domain.RuntimeBindingStatusIncomplete || len(forgedView.MissingKeys) != 2 || !containsString(forgedView.MissingKeys, "server-root") || !containsString(forgedView.MissingKeys, "rcon.password") { + t.Fatalf("expected derived missing keys to override stored complete status in the readiness view, view=%+v err=%v", forgedView, err) } view, err := svc.GetServerRuntimeBindingForSession(ownerSession, instance.ID) if err != nil || view.Configured || view.Reason != "runtime profile is not configured" { t.Fatalf("unexpected unconfigured view: view=%+v err=%v", view, err) } - if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-without-binding"}); err == nil || !strings.Contains(err.Error(), "runtime profile is not configured") { - t.Fatalf("expected missing binding to block start, got %v", err) + withoutBinding, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-without-binding"}) + if err != nil || withoutBinding.Job.TargetKey != "actions/start.json" || withoutBinding.Job.ExecutionInput.WorkspaceScope != "" { + t.Fatalf("expected plugin lifecycle start without manual runtime binding, result=%+v err=%v", withoutBinding, err) } if _, err := svc.UpdateServerRuntimeBindingForSession(otherSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local"}); err != ErrForbidden { t.Fatalf("expected non-owner update forbidden, got %v", err) @@ -86,8 +88,9 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) { if err != nil || view.Status != domain.RuntimeBindingStatusIncomplete || len(view.MissingKeys) != 1 || view.MissingKeys[0] != "rcon.password" { t.Fatalf("unexpected incomplete binding: view=%+v err=%v", view, err) } - if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-incomplete-binding"}); err == nil || !strings.Contains(err.Error(), "rcon.password") { - t.Fatalf("expected missing logical key to block start, got %v", err) + incompleteStart, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-incomplete-binding"}) + if err != nil || incompleteStart.Job.TargetKey != "actions/start.json" || incompleteStart.Job.ExecutionInput.WorkspaceScope != "local" { + t.Fatalf("expected plugin lifecycle start to tolerate incomplete optional bindings, result=%+v err=%v", incompleteStart, err) } view, err = svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon.password": "secret://runtime-server/rcon"}}) diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 3386487..a12ae29 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -252,11 +252,6 @@ func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLif } } } - if deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) { - if err := svc.requireCompleteRuntimeBindings(instance.OwnerUserID, instance.ID, "server.lifecycle."+string(action)+".denied"); err != nil { - return domain.ServerLifecycleResult{}, err - } - } if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(action)); err != nil { return domain.ServerLifecycleResult{}, err } diff --git a/platform_web/README.md b/platform_web/README.md index c4cbb82..5477f68 100644 --- a/platform_web/README.md +++ b/platform_web/README.md @@ -74,9 +74,9 @@ npm run dev For Docker, the web console is built with `VITE_PLATFORM_API_BASE_URL=/api/v1` and served by Nginx. Nginx proxies `/api/v1` and `/healthz` to the `platform` compose service, so browser code never needs a direct backend container address. -Current UI behavior is a browser-verifiable API-backed management console with the required first-party page routes. Server management now includes platform-mediated lifecycle, config, administrator, runtime distribution, dependency, and log workflows. +Current UI behavior is a browser-verifiable API-backed management console with the required first-party page routes. Server management now includes platform-mediated lifecycle, config, administrator, runtime distribution, dependency, log workflows, and plugin-declared server detail pages. -Server list and server detail surfaces expose runtime actions through platform APIs: +Server list surfaces expose runtime package actions through platform APIs, while server detail keeps day-to-day operations plugin-native and platform-mediated: - generate run packages for selected OS/architecture targets. - download the latest authorized run package. @@ -95,7 +95,7 @@ Manual UI smoke checklist: 2. Open the local Vite URL. 3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths. 4. In 服务器管理, verify the server card action menu contains runtime actions as a compact anchored dropdown/popover. It must not become a tall vertical button tower, reflow the card, cover server metrics/progress bars/status badges, or turn the whole card into an accidental click target. -5. In a server detail route, verify the overview renders the 运行分发 section, action availability reasons, dependency/log controls, and safe redacted refs only. +5. In a server detail route, verify plugin-declared pages render before built-in sections and that no manual 运行配置绑定, generic 运行操作 tab, or generic 插件控制 tab appears. 6. Switch black mecha and magical-girl themes when UI styling changed; runtime controls must keep the shared translucent console surfaces and avoid nested double frames. Automated browser acceptance remains available for deeper local debug verification: @@ -107,6 +107,6 @@ LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/priv This command verifies the API-backed local debug console path, first-party route markers, plugin/server operation proof, fallback rejection, and forbidden-fragment scans. It writes evidence under `/browser-acceptance/`. # Client Manager workspace -Server Detail includes a Client Manager lifecycle workspace backed by the typed Platform installation projection. It shows profile/target, desired-active-previous versions and revisions, artifact/checksum metadata, key/deployment generations, registration/health/last-seen, real job phases/progress, action gating reasons, retry guidance, and confirmed deploy/control/update/rollback/revoke/key-reset/uninstall workflows. The browser polls active jobs and never fabricates later phases. +Client Manager lifecycle remains a typed Platform installation projection, but it is not exposed as a generic server-detail tab. Dedicated package/dependency actions live in server-list runtime actions or plugin-declared pages, and the browser polls active jobs without fabricating later phases. The UI keeps the black-mecha and magical-girl crystal-moonlight console materials and uses shared panel/command tokens. It renders no raw key, token, secret ref/value, host path, PID, socket, credential, DSN, RCON password, or Run endpoint address. 401/403 responses remain platform auth/capability errors, not local fallback success. diff --git a/platform_web/acceptance/browser-acceptance.mjs b/platform_web/acceptance/browser-acceptance.mjs index b134323..cfd0759 100644 --- a/platform_web/acceptance/browser-acceptance.mjs +++ b/platform_web/acceptance/browser-acceptance.mjs @@ -178,15 +178,12 @@ async function main() { markers: [ server.name, `${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`, - "运行分发", - "run 包", - "客户端管理器", - "依赖", + "概览", "启动", "停止", "日志", + "管理终端", "配置", - "插件控制", "AI 助手", "操作历史" ] @@ -206,8 +203,8 @@ async function main() { } } - const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]); - evidence.routes.push({ name: "服务器详情 / 插件控制", url: await chrome.url(), ...pluginControls }); + const pluginPage = await clickAndVerify(chrome, "概览", ["插件概览", "dev-game-plugin 页面 bundle", "server.instances.read"]); + evidence.routes.push({ name: "服务器详情 / 插件声明页面", url: await chrome.url(), ...pluginPage }); evidence.productionInteractions = { alert: await verifyAlertInteraction(chrome, authHeaders, productionSeed.alert), @@ -392,12 +389,12 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) { await chrome.evaluate((serverID) => { window.location.hash = `#/servers/${encodeURIComponent(serverID)}`; }, server.id); - await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`); - const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]); + await chrome.waitForText([server.name, "概览"], `${scenario.name} / server detail tabs`); + const pluginControls = await clickAndVerify(chrome, "概览", ["插件概览", "dev-game-plugin 页面 bundle", "server.instances.read"]); const pluginLayout = await chrome.layoutSnapshot(); assertNoVisibleLayoutIssues(pluginLayout, `${scenario.name} / plugin controls`); routeEvidence.push({ - name: "服务器详情 / 插件控制", + name: "服务器详情 / 插件声明页面", url: await chrome.url(), requiredMarkers: pluginControls.requiredMarkers, fallbackScan: pluginControls.fallbackScan, diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index e88a034..efe2174 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -23,7 +23,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia ## Server Management Workflows - `createServerWorkflow` posts `ServerLifecycleCreateRequest` with the create-wizard deployment definition to `/server-instances/workflows/create`, including deployment mode, plugin create inputs, and custom startup fields when provided. It must not include deployment target, run endpoint, runtime profile, or runtime bindings during creation; those are established only after creation through generated Run registration, runtime binding, or deployment update flows. -- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values. +- `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. diff --git a/platform_web/contracts/pages.md b/platform_web/contracts/pages.md index 003d63b..9374309 100644 --- a/platform_web/contracts/pages.md +++ b/platform_web/contracts/pages.md @@ -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 controls, 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, logs, diffs, plugin-declared pages, and operation history 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. @@ -18,12 +18,12 @@ Platform-administrator-only first screen. Shows online/offline server counts, ab Default landing page for server owners and server administrators. Shows searchable/status-filterable server cards with online/offline state, player count, TPS, latency, CPU, memory, and disk usage; pending metric values render as stable placeholders. Provides create server workflow, refresh, and routed server detail pages. Empty states cover no servers, no matching filters, and role-scoped no-access. -- Create uses installed plugin and run endpoint selections, then renders the returned instance and install job through one visible operation lifecycle. +- Create uses the installed plugin plus plugin-declared deployment/startup inputs, then opens the returned instance through one visible operation lifecycle. - The page must not display raw run credentials, host paths, direct socket details, or AI provider keys. ## 服务器详情 -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. Sections: 日志 (level/keyword/time/source filters + log detail drawer with diagnostics), 管理终端 (platform-mediated plugin command surface), 运行操作 (run binding, read-only deployment status, distribution, lifecycle, members), 配置 (edit with reviewable diff before any write job), 插件控制 (controls grouped by plugin, scoped to this server instance, confirmation + lifecycle feedback per action), 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). +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. ## 插件市场 diff --git a/platform_web/contracts/workspace.ts b/platform_web/contracts/workspace.ts index fca7c7f..8d3e77c 100644 --- a/platform_web/contracts/workspace.ts +++ b/platform_web/contracts/workspace.ts @@ -144,35 +144,16 @@ export interface PlatformOverviewSignal { at: string; } -export type ServerDetailSection = "logs" | "terminal" | "runtime" | "config" | "plugins" | "llm" | "history"; +export type ServerDetailSection = "logs" | "terminal" | "config" | "llm" | "history" | `plugin:${string}`; export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [ { id: "logs", label: "日志" }, { id: "terminal", label: "管理终端" }, - { id: "runtime", label: "运行操作" }, { id: "config", label: "配置" }, - { id: "plugins", label: "插件控制" }, { id: "llm", label: "AI 助手" }, { id: "history", label: "操作历史" } ]; -export interface PluginControlDescriptor { - key: string; - label: string; - description: string; - capability: string; - lifecycleAction?: "start" | "stop" | "status"; - dangerous: boolean; -} - -export interface PluginControlGroupView { - pluginId: string; - pluginName: string; - version: string; - status: string; - controls: PluginControlDescriptor[]; -} - export interface DiffLine { kind: "same" | "added" | "removed"; text: string; diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index f0dcb68..39a0916 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -199,9 +199,9 @@ describe("first-party console pages", () => { expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务"); expect(serverDeploymentWorkflowSource).toContain("已绑定服务器编辑时会直接进入相关配置"); expect(serverDeploymentWorkflowSource).toContain("可在此调整部署方式;不会重复要求选择已绑定的运行节点"); - expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" })'); - expect(serverDetailPageSource).toContain("运行配置绑定"); - expect(serverDetailPageSource).toContain('type={field.sensitive ? "password" : "text"}'); + expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })'); + expect(serverDetailPageSource).not.toContain("运行配置绑定"); + expect(serverDetailPageSource).not.toContain('type={field.sensitive ? "password" : "text"}'); expect(serverDeploymentWorkflowSource).toContain("显示已保存配置"); expect(serverDeploymentWorkflowSource).toContain("revealSavedInputs"); expect(serversPageSource).toContain("revealServerDeployment"); @@ -286,8 +286,8 @@ describe("first-party console pages", () => { expect(serversPageSource).toContain("requireQuickRuntimeActionAvailable(instance.id, action)"); expect(serversPageSource).toContain("该操作不可用"); expect(generateRunSource.indexOf("runtimeTask.runTrackedTask")).toBeLessThan(generateRunSource.indexOf('await requireQuickRuntimeActionAvailable(instance.id, "generate-run")')); - expect(serverDetailPageSource).toContain("RuntimeTaskProgressDialog"); - expect(serverDetailPageSource).toContain("runtimeRunBuildStages"); + expect(serverDetailPageSource).not.toContain("RuntimeTaskProgressDialog"); + expect(serverDetailPageSource).not.toContain("runtimeRunBuildStages"); expect(runtimeTaskProgressSource).toContain("runtimeBuildStages"); expect(runtimeTaskProgressSource).toContain("runtimeRunBuildStages"); expect(runtimeTaskProgressSource).toContain("进度展示"); @@ -311,9 +311,9 @@ describe("first-party console pages", () => { expect(html).not.toContain("概览"); expect(html).toContain("日志"); expect(html).toContain("管理终端"); - expect(html).toContain("运行操作"); expect(html).toContain("配置"); - expect(html).toContain("插件控制"); + expect(html).not.toContain("运行操作"); + expect(html).not.toContain("插件控制"); expect(html).toContain("AI 助手"); expect(html).toContain("操作历史"); }); @@ -353,7 +353,6 @@ describe("first-party console pages", () => { it("uses declared SCUM log source keys for log backfill defaults", () => { expect(serversPageSource).toContain("scum-server-events"); - expect(serverDetailPageSource).toContain("scum-server-events"); expect(serversPageSource).not.toContain("server-log"); expect(serverDetailPageSource).not.toContain("server-log"); }); diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index e4b316b..1e70dd3 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; import { configDiffViewFromPreview } from "./ServerDetailPage"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; -import clientManagerLifecyclePanelSource from "../components/ClientManagerLifecyclePanel.tsx?raw"; -import runtimeDLLExtensionsPanelSource from "../components/RuntimeDLLExtensionsPanel.tsx?raw"; import sourceRCONCommandPanelSource from "../components/SourceRCONCommandPanel.tsx?raw"; import artifactTransferSource from "../utils/artifactTransfer.ts?raw"; import type { ServerConfigDiffPreviewResponse } from "../api/types"; @@ -67,39 +65,21 @@ describe("ServerDetailPage config write approval", () => { it("uses platform-mediated artifact download and bridge references without backend internals", () => { expect(serverDetailPageSource).toContain("openArtifactDownload"); expect(serverDetailPageSource).toContain("readArtifactContent"); - expect(serverDetailPageSource).toContain("parsePluginArtifactReference"); expect(serverDetailPageSource).toContain("浏览器制品传输"); expect(serverDetailPageSource).not.toContain("storage://"); expect(serverDetailPageSource).not.toContain("unix://"); expect(serverDetailPageSource).not.toContain("Bearer "); }); - it("surfaces run distribution and client-manager workflows through platform-mediated APIs", () => { - expect(serverDetailPageSource).toContain('params.routeKey !== "run-builder"'); - expect(serverDetailPageSource).toContain('id="run-builder"'); - expect(serverDetailPageSource).toContain("scrollIntoView"); - expect(serverDetailPageSource).toContain("getServerRuntimeActions"); - expect(serverDetailPageSource).toContain("generateRunDistribution"); - expect(serverDetailPageSource).toContain("downloadLatestRunDistribution"); - expect(serverDetailPageSource).toContain("pushRunUpdate"); - expect(serverDetailPageSource).toContain("generateClientManager"); - expect(serverDetailPageSource).toContain("resetClientManagerKey"); - expect(serverDetailPageSource).toContain("checkDependencies"); - expect(serverDetailPageSource).toContain("installDependencies"); + it("keeps run distribution and client-manager workflows out of server detail tabs", () => { + expect(serverDetailPageSource).not.toContain('id="run-builder"'); + expect(serverDetailPageSource).not.toContain("getServerRuntimeActions"); + expect(serverDetailPageSource).not.toContain("generateRunDistribution"); + expect(serverDetailPageSource).not.toContain("downloadLatestRunDistribution"); + expect(serverDetailPageSource).not.toContain("pushRunUpdate"); + expect(serverDetailPageSource).not.toContain("generateClientManager"); + expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel"); expect(serverDetailPageSource).toContain("openServerLogEvents"); - expect(serverDetailPageSource).toContain("requestLogBackfill"); - expect(serverDetailPageSource).toContain("ClientManagerLifecyclePanel"); - expect(clientManagerLifecyclePanelSource).toContain("listClientManagerLifecycles"); - expect(clientManagerLifecyclePanelSource).toContain("deployClientManager"); - expect(clientManagerLifecyclePanelSource).toContain("controlClientManager"); - expect(clientManagerLifecyclePanelSource).toContain("updateClientManager"); - expect(clientManagerLifecyclePanelSource).toContain("retryClientManagerLifecycle"); - expect(clientManagerLifecyclePanelSource).toContain("revokeClientManagerSession"); - expect(clientManagerLifecyclePanelSource).toContain("uninstallClientManager"); - expect(clientManagerLifecyclePanelSource).toContain("expectedDeploymentGeneration"); - expect(clientManagerLifecyclePanelSource).not.toContain("secretRef"); - expect(clientManagerLifecyclePanelSource).not.toContain("hostPath"); - expect(clientManagerLifecyclePanelSource).not.toContain("process.pid"); expect(serverDetailPageSource).not.toContain("authKey"); expect(serverDetailPageSource).not.toContain("password="); expect(serverDetailPageSource).not.toContain("unix://"); @@ -108,15 +88,11 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain("sqlite://"); }); - it("renders declared UE4SS DLL update policy without local paths or RCON secrets", () => { - expect(serverDetailPageSource).toContain("RuntimeDLLExtensionsPanel"); - expect(runtimeDLLExtensionsPanelSource).toContain("启动前自动校验和更新"); - expect(runtimeDLLExtensionsPanelSource).toContain("运行:SCUM 服务器受监管启动"); - expect(runtimeDLLExtensionsPanelSource).toContain("由 UE4SS 正常加载"); - expect(runtimeDLLExtensionsPanelSource).toContain("Linux 启动前拒绝"); - for (const forbidden of ["dllRef", "targetKey", "rconPort", "password="]) { - expect(runtimeDLLExtensionsPanelSource).not.toContain(forbidden); - } + it("does not expose manual runtime configuration surfaces", () => { + expect(serverDetailPageSource).not.toContain("getServerRuntimeBinding"); + expect(serverDetailPageSource).not.toContain("updateServerRuntimeBinding"); + expect(serverDetailPageSource).not.toContain("运行配置绑定"); + expect(serverDetailPageSource).not.toContain("RuntimeDLLExtensionsPanel"); }); it("adds SCUM announcements and raw commands through protected RCON bridge jobs", () => { @@ -132,33 +108,15 @@ describe("ServerDetailPage config write approval", () => { } }); - it("loads the dependency catalog only after runtime actions expose dependency operations", () => { - const runtimeDistributionSectionSource = serverDetailPageSource.split("function RuntimeDistributionSection")[1]?.split("function RuntimeBindingFields")[0] ?? ""; - expect(runtimeDistributionSectionSource).toContain('action.key === "dependencies-check" || action.key === "dependencies-install"'); - expect(runtimeDistributionSectionSource).toContain("dependencyActions.some((action) => action.available)"); - expect(runtimeDistributionSectionSource).toContain("getDependencyCatalog(instance.id)"); - }); - - it("reviews and updates only redacted runtime binding metadata", () => { - const runtimeBindingSectionSource = serverDetailPageSource.split("function RuntimeBindingSection")[1]?.split("function RuntimeDistributionSection")[0] ?? ""; - expect(serverDetailPageSource).toContain("getServerRuntimeBinding"); - expect(serverDetailPageSource).toContain("updateServerRuntimeBinding"); - expect(runtimeBindingSectionSource).toContain("missingKeys"); - expect(runtimeBindingSectionSource).toContain('type={field.sensitive ? "password" : "text"}'); - for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://", "mysql://", "sqlite://"]) { - expect(runtimeBindingSectionSource).not.toContain(forbidden); - } - }); - - it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => { - expect(serverDetailPageSource).toContain('action === "install" || action === "restart"'); - expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop" && action !== "status"'); - expect(serverDetailPageSource).toContain('control.lifecycleAction === "start" || control.lifecycleAction === "stop" || control.lifecycleAction === "status"'); - expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(instance.id"); - expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(instance.id"); - expect(serverDetailPageSource).toContain("platformApiClient.queryServerProcessStatus(instance.id"); - expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"start\")"); - expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"stop\")"); + it("removes generic plugin controls from server detail", () => { + expect(serverDetailPageSource).not.toContain("PluginControlsSection"); + expect(serverDetailPageSource).not.toContain("PluginBridgeExecutionPanel"); + expect(serverDetailPageSource).not.toContain("platformApiClient.createJob({"); + expect(serverDetailPageSource).not.toContain("插件控制"); + expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(current.id"); + expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(current.id"); + expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, \"start\")"); + expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, \"stop\")"); expect(serverDetailPageSource).not.toContain('capability: "process.start"'); expect(serverDetailPageSource).not.toContain('capability: "process.stop"'); }); @@ -170,17 +128,15 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain('capability: "process.install"'); }); - it("routes the SCUM logs section to the declared file management workbench", () => { - expect(serverDetailPageSource).toContain("serverDetailSectionLabel(entry, instance.data.pluginId)"); - expect(serverDetailPageSource).toContain('"文件管理"'); - expect(serverDetailPageSource).toContain("ScumFileManagementSection"); + it("routes plugin-declared pages into server detail tabs", () => { + expect(serverDetailPageSource).toContain("serverDetailSectionEntries(readyPlugin)"); + expect(serverDetailPageSource).toContain("plugin:${page.key}"); + expect(serverDetailPageSource).toContain("PluginPageSection"); expect(serverDetailPageSource).toContain("PluginPageHostPage"); - expect(serverDetailPageSource).toContain('routeKey: "files-config"'); - expect(serverDetailPageSource).toContain('file.kind === "config"'); + expect(serverDetailPageSource).not.toContain("ScumFileManagementSection"); }); it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => { - expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)"); expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)"); expect(serverDetailPageSource).toContain("downloadArtifactReference(reference"); expect(artifactTransferSource).toContain("readContent(reference.artifactId"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index fa72b9a..5cfd757 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -1,53 +1,30 @@ -import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ScrollText, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; -import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { Download, MoonStar, PackageOpen, Pencil, ScrollText, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; +import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; import type { ConfigDiffLineResponse, - ArtifactDownloadReferenceResponse, ArtifactResponse, BackupResponse, - ClientManagerDistributionResponse, - DependencyCatalogResponse, GamePluginResponse, JobResponse, LogStreamResponse, - RunDistributionResponse, - RunUpdateJobResponse, ServerConfigDiffPreviewResponse, ServerConfigResponse, ServerInstanceResponse, ServerMemberResponse, ServerMetricsResponse, - RuntimeBindingResponse, ServerDeploymentResponse, - ServerRuntimeActionsResponse, MetricSampleResponse, RemoteAdapterDeclarationResponse } from "../api/types"; import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls"; -import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel"; import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; -import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench"; -import { RuntimeDLLExtensionsPanel } from "../components/RuntimeDLLExtensionsPanel"; import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel"; -import { - RuntimeTaskProgressDialog, - runtimeBuildStages, - runtimeDependencyStages, - runtimeDownloadStages, - runtimeLogStages, - runtimeRunBuildStages, - runtimeUpdateStages, - type RuntimeTaskDialogAction, - type RuntimeTaskStage, - useRuntimeTaskController -} from "../components/RuntimeTaskProgress"; import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; import { jobCapabilityLabel } from "../contracts/jobPresentation"; -import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; -import { canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; +import { canStartServer, canStopServer, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations"; import { serverDetailSections, @@ -55,21 +32,13 @@ import { isPlatformAdmin, type ConfigDiffView, type LlmSuggestionView, - type PluginControlDescriptor, - type PluginControlGroupView, type ServerDetailSection } from "../contracts/workspace"; import { - clientManagerBuildRequest, - dependencyJobRequest, - logBackfillRequest, - runDistributionGenerateRequest, - runUpdateRequest, serverLifecycleCommandRequest, serverMetadataUpdateRequestFromForm } from "../schemas/serverManagement"; import { diffHasChanges } from "../utils/diff"; -import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost"; import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer"; import { cx } from "../utils/classes"; import { stateLabel, statusClass } from "./ServersPage"; @@ -94,8 +63,6 @@ export function ServerDetailPage(props: PageComponentProps) { const [metricHistory, setMetricHistory] = useState([]); const [backups, setBackups] = useState([]); const [remoteAdapters, setRemoteAdapters] = useState([]); - const [runtimeActions, setRuntimeActions] = useState>({ status: "loading" }); - const [runtimeBinding, setRuntimeBinding] = useState>({ status: "loading" }); const [deployment, setDeployment] = useState>({ status: "loading" }); const [liveLogOpen, setLiveLogOpen] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false); @@ -109,18 +76,10 @@ export function ServerDetailPage(props: PageComponentProps) { } setInstance({ status: "loading" }); try { - const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ + const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ platformApiClient.getServerInstance(serverId), platformApiClient.listGamePlugins(), platformApiClient.listJobs(serverId), - platformApiClient - .getServerRuntimeActions(serverId) - .then((data): LoadState => ({ status: "ready", data })) - .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" })), - platformApiClient - .getServerRuntimeBinding(serverId) - .then((data): LoadState => ({ status: "ready", data })) - .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })), platformApiClient .getServerDeployment(serverId) .then((data): LoadState => ({ status: "ready", data })) @@ -132,8 +91,6 @@ export function ServerDetailPage(props: PageComponentProps) { setInstance({ status: "ready", data: detail }); setPlugins(pluginResponse.items); setJobs(jobResponse.items); - setRuntimeActions(runtimeResponse); - setRuntimeBinding(bindingResponse); setDeployment(deploymentResponse); setMetricHistory(metricHistoryResponse.items); setBackups(backupResponse.items); @@ -150,8 +107,6 @@ export function ServerDetailPage(props: PageComponentProps) { } catch (error) { setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" }); setArtifacts([]); - setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" }); - setRuntimeBinding({ status: "error", reason: "运行配置加载失败" }); setDeployment({ status: "error", reason: "部署定义加载失败" }); setMetricHistory([]); setBackups([]); @@ -190,21 +145,23 @@ export function ServerDetailPage(props: PageComponentProps) { return () => window.clearInterval(timer); }, [refreshOperationalState]); - useEffect(() => { - if (params.routeKey !== "run-builder" || instance.status !== "ready" || typeof document === "undefined") return; - const frame = window.requestAnimationFrame(() => { - const target = document.getElementById("run-builder"); - target?.scrollIntoView({ behavior: "smooth", block: "start" }); - target?.focus({ preventScroll: true }); - }); - return () => window.cancelAnimationFrame(frame); - }, [instance.status, params.routeKey]); - 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 detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]); + + useEffect(() => { + if (!params.routeKey || !readyPlugin?.pages.some((page) => page.key === params.routeKey)) return; + setSection(`plugin:${params.routeKey}`); + }, [params.routeKey, readyPlugin]); + + useEffect(() => { + if (detailSections.some((entry) => entry.id === section)) return; + setSection(detailSections[0]?.id ?? "logs"); + }, [detailSections, section]); function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") { setConfirm({ @@ -261,7 +218,7 @@ export function ServerDetailPage(props: PageComponentProps) { {instance.status !== "ready" && ( @@ -290,7 +247,7 @@ export function ServerDetailPage(props: PageComponentProps) { - +
@@ -324,7 +281,7 @@ export function ServerDetailPage(props: PageComponentProps) { - {section === "logs" && (instance.data.pluginId === "game.scum" ? plugin.id === instance.data.pluginId)} /> : )} + {section === "logs" && } {section === "terminal" && } - {section === "runtime" && ( - plugin.id === instance.data.pluginId)} - binding={runtimeBinding} - session={session} - operations={operations} - onChanged={() => void refresh()} - /> - )} - {section === "runtime" && } - {section === "runtime" && plugin.id === instance.data.pluginId)?.runtimeProfiles} />} - {section === "runtime" && ( - setSection("logs")} - onChanged={() => void refresh()} - /> - )} - {section === "runtime" && } - {section === "runtime" && ( + {pluginPageKeyFromSection(section) && readyPlugin && } + {section === "config" && } + {section === "config" && ( setInstance({ status: "ready", data: next })} /> )} - {section === "runtime" && setInstance({ status: "ready", data: next })} />} + {section === "config" && setInstance({ status: "ready", data: next })} />} {section === "config" && } - {section === "plugins" && } {section === "llm" && } {section === "history" && } setLiveLogOpen(false)} /> @@ -411,21 +346,24 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] { return [...byID.values()]; } -function serverDetailSectionLabel(entry: { id: ServerDetailSection; label: string }, pluginId?: string): string { - return pluginId === "game.scum" && entry.id === "logs" ? "文件管理" : entry.label; +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]; } -interface ScumFileManagementSectionProps { +function pluginPageKeyFromSection(section: ServerDetailSection): string | null { + return section.startsWith("plugin:") ? section.slice("plugin:".length) : null; +} + +interface PluginPageSectionProps { pageProps: PageComponentProps; serverId: string; - plugin?: GamePluginResponse; + plugin: GamePluginResponse; + routeKey: string; } -function ScumFileManagementSection({ pageProps, serverId, plugin }: ScumFileManagementSectionProps) { - const params = useMemo(() => ({ ...pageProps.params, pluginId: plugin?.id ?? "", routeKey: "files-config", serverId }), [pageProps.params.pluginId, pageProps.params.routeKey, pageProps.params.serverId, plugin?.id, serverId]); - if (!plugin) { - return ; - } +function PluginPageSection({ pageProps, serverId, plugin, routeKey }: PluginPageSectionProps) { + const params = useMemo(() => ({ ...pageProps.params, pluginId: plugin.id, routeKey, serverId }), [pageProps.params.pluginId, pageProps.params.routeKey, pageProps.params.serverId, plugin.id, routeKey, serverId]); return ; } @@ -675,732 +613,6 @@ function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string { return new Date(metrics.collectedAt).toLocaleTimeString(); } -interface RuntimeDistributionSectionProps { - instance: ServerInstanceResponse; - runtimeActions: LoadState; - session: PageComponentProps["session"]; - operations: PageComponentProps["operations"]; - onOpenLogs: () => void; - onChanged: () => void; -} - -interface RuntimeBindingSectionProps { - instance: ServerInstanceResponse; - plugin?: GamePluginResponse; - binding: LoadState; - session: PageComponentProps["session"]; - operations: PageComponentProps["operations"]; - onChanged: () => void; -} - -function RuntimeBindingSection({ instance, plugin, binding, session, operations, onChanged }: RuntimeBindingSectionProps) { - const bindingData = binding.status === "ready" ? binding.data : null; - const [profileKey, setProfileKey] = useState(bindingData?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? ""); - const [values, setValues] = useState>({}); - const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null); - const canManage = isPlatformAdmin(session) || instance.ownerUserId === session.id; - const activeExistingBinding = bindingData?.configured === true && (instance.state === "installing" || instance.state === "running"); - const fields = runtimeBindingFields(plugin, profileKey); - - useEffect(() => { - setProfileKey(bindingData?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? ""); - setValues({}); - }, [bindingData?.profileKey, bindingData?.updatedAt, plugin?.id]); - - async function saveBinding(event: FormEvent) { - event.preventDefault(); - const operationId = operations.begin({ intent: "更新运行配置", targetKind: "server", targetId: `${instance.id}:runtime-binding`, requester: session.displayName }); - setResult({ status: "pending", label: "正在保存运行配置" }); - try { - const updated = await platformApiClient.updateServerRuntimeBinding(instance.id, { - profileKey, - bindings: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")) - }); - operations.succeed(operationId, updated.status === "complete" ? "运行配置已就绪" : "运行配置已保存,仍有缺失项"); - setResult({ status: "succeeded", label: updated.status === "complete" ? "运行配置已就绪" : `仍缺少:${updated.missingKeys.join("、")}` }); - setValues({}); - onChanged(); - } catch (error) { - const reason = error instanceof Error ? error.message : "运行配置保存失败"; - operations.fail(operationId, reason, operationId); - setResult({ status: "failed", label: reason }); - } - } - - return ( -
-
-

- 运行配置绑定 -

- {result && } -
- {binding.status === "loading" && } - {binding.status === "error" && } - {bindingData && ( - <> -
- - - key.configured).length}/${bindingData.keys.length}`} /> -
- {bindingData.reason &&

{bindingData.reason}

} - {bindingData.missingKeys.length > 0 &&

缺少逻辑绑定:{bindingData.missingKeys.join("、")}

} - {bindingData.keys.length > 0 && ( -
- {bindingData.keys.map((key) => ( - - {key.key} · {key.configured ? (key.secret ? "受保护" : "已配置") : "缺失"} - - ))} -
- )} -
void saveBinding(event)}> - -
- {fields.map((field) => { - const existing = bindingData.keys.find((key) => key.key === field.key); - return ( - - ); - })} -
- -
- - )} -
- ); -} - -function RuntimeDistributionSection({ instance, runtimeActions, session, operations, onOpenLogs, onChanged }: RuntimeDistributionSectionProps) { - const defaults = runtimeDefaultsForPlugin(instance.pluginId); - const [targetOs, setTargetOs] = useState(defaults.runOs); - const [targetArch, setTargetArch] = useState("amd64"); - const [profileKey, setProfileKey] = useState(defaults.clientProfileKey); - const [repositoryUrl, setRepositoryUrl] = useState(defaults.repositoryUrl); - const [sourceRevision, setSourceRevision] = useState(defaults.sourceRevision); - const [probeKey, setProbeKey] = useState(defaults.probeKey); - const [installPlanKey, setInstallPlanKey] = useState(defaults.installPlanKey); - const [logSourceKey, setLogSourceKey] = useState(defaults.logSourceKey); - const [checkpointRef, setCheckpointRef] = useState(""); - const [lastRun, setLastRun] = useState(null); - const [lastClient, setLastClient] = useState(null); - const [lastDownload, setLastDownload] = useState(null); - const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null); - const [dependencyCatalog, setDependencyCatalog] = useState>({ status: "loading" }); - const [runUpdates, setRunUpdates] = useState>({ status: "loading" }); - const runtimeTask = useRuntimeTaskController(); - const [runtimeTaskActions, setRuntimeTaskActions] = useState([]); - - const refreshRuntimeProjections = useCallback(async () => { - const dependencyActions = runtimeActions.status === "ready" - ? runtimeActions.data.actions.filter((action) => action.key === "dependencies-check" || action.key === "dependencies-install") - : []; - const dependencyActionReason = dependencyActions.find((action) => action.reason)?.reason ?? "依赖操作未开放"; - const catalogRequest: Promise> = - runtimeActions.status === "ready" && dependencyActions.some((action) => action.available) - ? platformApiClient - .getDependencyCatalog(instance.id) - .then((data): LoadState => ({ status: "ready", data })) - .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" })) - : Promise.resolve( - runtimeActions.status === "error" - ? { status: "error", reason: runtimeActions.reason } - : runtimeActions.status === "ready" - ? { status: "error", reason: dependencyActionReason } - : { status: "loading" } - ); - const [catalog, updates] = await Promise.all([ - catalogRequest, - platformApiClient - .listRunUpdates(instance.id) - .then((data): LoadState => ({ status: "ready", data: data.items })) - .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "Run 更新状态加载失败" })) - ]); - setDependencyCatalog(catalog); - setRunUpdates(updates); - }, [instance.id, runtimeActions]); - - useEffect(() => { - void refreshRuntimeProjections(); - }, [refreshRuntimeProjections]); - - useEffect(() => { - if (dependencyCatalog.status !== "ready") return; - const selectedProbe = dependencyCatalog.data.probes.find((probe) => probe.key === probeKey) ?? dependencyCatalog.data.probes[0]; - if (selectedProbe && selectedProbe.key !== probeKey) setProbeKey(selectedProbe.key); - const matchingPlan = dependencyCatalog.data.plans.find((plan) => plan.key === installPlanKey) - ?? dependencyCatalog.data.plans.find((plan) => plan.key === selectedProbe?.installPlanKey) - ?? dependencyCatalog.data.plans[0]; - if (matchingPlan && matchingPlan.key !== installPlanKey) setInstallPlanKey(matchingPlan.key); - }, [dependencyCatalog, installPlanKey, probeKey]); - - const selectedDependencyProbe = dependencyCatalog.status === "ready" ? dependencyCatalog.data.probes.find((probe) => probe.key === probeKey) : undefined; - const selectedDependencyPlan = dependencyCatalog.status === "ready" ? dependencyCatalog.data.plans.find((plan) => plan.key === installPlanKey) : undefined; - const latestRunUpdate = runUpdates.status === "ready" ? runUpdates.data[0] : undefined; - - const actionByKey = useMemo(() => { - if (runtimeActions.status !== "ready") { - return new Map(); - } - return new Map(runtimeActions.data.actions.map((action) => [action.key, { available: action.available, reason: action.reason }])); - }, [runtimeActions]); - - function canUse(key: string): boolean { - return actionByKey.get(key)?.available ?? false; - } - - function reasonFor(key: string): string { - return actionByKey.get(key)?.reason ?? "平台暂未开放该操作"; - } - - async function runOperation( - intent: string, - execute: () => Promise, - summarize: (value: T) => string, - taskOptions?: { - description: string; - stages: RuntimeTaskStage[]; - executeStageIndex?: number; - trackedJobId?: (value: T) => string; - afterSuccess?: (value: T) => void; - } - ) { - const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName }); - setRuntimeTaskActions([]); - setResult({ status: "pending", label: `${intent} 执行中` }); - try { - const value = taskOptions?.trackedJobId - ? await runtimeTask.runTrackedTask({ - title: intent, - description: taskOptions.description, - stages: taskOptions.stages, - start: async () => { - const value = await execute(); - return { value, jobId: taskOptions.trackedJobId?.(value) ?? "" }; - }, - poll: (jobId) => platformApiClient.getJob(jobId) - }) - : taskOptions - ? await runtimeTask.runTask({ - title: intent, - description: taskOptions.description, - stages: taskOptions.stages, - executeStageIndex: taskOptions.executeStageIndex, - execute - }) - : await execute(); - const label = summarize(value); - operations.succeed(operationId, label); - setResult({ status: "succeeded", label }); - runtimeTask.succeedTask(label); - taskOptions?.afterSuccess?.(value); - void refreshRuntimeProjections(); - onChanged(); - } catch (error) { - const reason = error instanceof Error ? error.message : `${intent} 失败`; - operations.fail(operationId, reason, operationId); - setResult({ status: "failed", label: reason }); - runtimeTask.failTask(reason); - } - } - - function latestRunArtifact(): { artifactId: string; checksum?: string } | null { - if (lastRun) { - return { artifactId: lastRun.artifactId, checksum: lastRun.checksum }; - } - if (lastDownload) { - return { artifactId: lastDownload.artifactId, checksum: lastDownload.checksum }; - } - return null; - } - - async function downloadRunArtifact(artifact: { artifactId: string; checksum?: string }) { - setRuntimeTaskActions([]); - try { - const label = await runtimeTask.runTask({ - title: "下载 run", - description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}。`, - stages: runtimeDownloadStages, - executeStageIndex: 1, - execute: async () => { - const reference = await platformApiClient.openArtifactDownload(artifact.artifactId); - setLastDownload(reference); - await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit)); - return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`; - } - }); - runtimeTask.succeedTask(label); - } catch (error) { - runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败"); - } - } - - async function pushRunArtifact(artifact: { artifactId: string; checksum?: string }) { - setRuntimeTaskActions([]); - await runOperation( - "更新 run", - () => platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)), - (update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`, - { - description: `将 artifact ${artifact.artifactId} 推送到 ${instance.runEndpointId},并等待平台 job 确认。`, - stages: runtimeUpdateStages, - executeStageIndex: 2 - } - ); - } - - async function downloadClientArtifact(profileKeyForDownload: string) { - setRuntimeTaskActions([]); - try { - const label = await runtimeTask.runTask({ - title: "下载客户端", - description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`, - stages: runtimeDownloadStages, - executeStageIndex: 1, - execute: async () => { - const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey: profileKeyForDownload }); - await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit)); - return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`; - } - }); - runtimeTask.succeedTask(label); - } catch (error) { - runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败"); - } - } - - return ( -
-
-

- 运行分发 -

- {runtimeActions.status === "ready" ? ( - - run {runtimeActions.data.runStatus} - - ) : runtimeActions.status === "error" ? ( - - ) : ( - 读取中 - )} -
- {result && ( -
- -
- )} -
-
- - - - - - - - -
-
-
- - void runOperation( - "生成 run", - async () => { - const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch)); - setLastRun(distribution); - return distribution; - }, - (distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 二进制已构建,artifact ${distribution.artifactId},generation ${distribution.keyGeneration}`, - { - description: `为 ${instance.name} 构建 ${targetOs}/${targetArch} run 包,展示拉取 run 更新、检测构建环境、构建中和构建完成进度。`, - stages: runtimeRunBuildStages, - trackedJobId: (distribution) => distribution.buildJobId, - afterSuccess: (distribution) => { - const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum }; - setRuntimeTaskActions([ - { label: "下载 run", kind: "primary", onClick: () => void downloadRunArtifact(artifact) }, - { label: "更新 run", disabled: !serverIsOnline(instance.state), title: serverIsOnline(instance.state) ? "更新 run" : "run 未运行,无法在线更新", onClick: () => void pushRunArtifact(artifact) } - ]); - } - } - ) - } - /> - - void runOperation( - "下载 run", - async () => { - const reference = await platformApiClient.downloadLatestRunDistribution(instance.id); - setLastDownload(reference); - await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit)); - return reference; - }, - (reference) => `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`, - { - description: `为 ${instance.name} 创建最新 run 包下载引用,并展示 artifact 定位进度。`, - stages: runtimeDownloadStages, - executeStageIndex: 1 - } - ) - } - secondaryLabel="更新 run" - secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null || !serverIsOnline(instance.state)} - secondaryReason={!serverIsOnline(instance.state) ? "run 未运行,无法在线更新" : latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")} - onSecondary={() => - void runOperation( - "更新 run", - async () => { - const artifact = latestRunArtifact(); - if (!artifact) { - throw new Error("请先生成或下载 run 包"); - } - return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)); - }, - (update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`, - { - description: `将最近 run artifact 推送到 ${instance.runEndpointId},并等待平台 job 确认。`, - stages: runtimeUpdateStages, - executeStageIndex: 2 - } - ) - } - > - {latestRunUpdate && ( -
- - phase {latestRunUpdate.phase} - - checksum {shortChecksum(latestRunUpdate.checksum)} - release {latestRunUpdate.targetRelease ?? "pending"} - rollback {latestRunUpdate.rollback ? "yes" : "no"} - {latestRunUpdate.message && audit {latestRunUpdate.message}} -
- )} - {runUpdates.status === "error" && } -
- - void runOperation( - "重置 run 密钥", - () => platformApiClient.resetRunKey(instance.id), - (key) => `run 密钥已重置,generation ${key.generation},fingerprint ${key.fingerprint}` - ) - } - /> - - void runOperation( - "生成客户端管理器", - async () => { - const distribution = await platformApiClient.generateClientManager( - instance.id, - clientManagerBuildRequest({ serverInstanceId: instance.id, profileKey, targetOs, targetArch, repositoryUrl, sourceRevision }) - ); - setLastClient(distribution); - return distribution; - }, - (distribution) => `客户端管理器二进制已构建,artifact ${distribution.artifactId},组件密钥仅由 Platform/Run 受控使用`, - { - description: `按 ${profileKey} profile 拉取客户端代码、安装环境、编译并生成可下载 artifact。`, - stages: runtimeBuildStages, - trackedJobId: (distribution) => distribution.buildJobId, - afterSuccess: () => { - setRuntimeTaskActions([{ label: "下载客户端", kind: "primary", onClick: () => void downloadClientArtifact(profileKey) }]); - } - } - ) - } - secondaryLabel="下载客户端" - secondaryDisabled={!canUse("download-client-manager")} - secondaryReason={reasonFor("download-client-manager")} - onSecondary={() => - void runOperation( - "下载客户端管理器", - async () => { - const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey }); - await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit)); - return reference; - }, - (reference) => `客户端下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}` - ) - } - /> - - void runOperation( - "重置客户端密钥", - () => platformApiClient.resetClientManagerKey(instance.id, { componentKind: "client-manager", componentKey: profileKey }), - (key) => `客户端密钥已重置,generation ${key.generation},fingerprint ${key.fingerprint}` - ) - } - /> - - void runOperation( - "依赖检查", - () => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)), - (job) => `依赖检查任务已排队,job ${job.id}`, - { - description: `使用 ${probeKey} probe 检查 ${instance.name} 的运行依赖。`, - stages: runtimeDependencyStages, - executeStageIndex: 1 - } - ) - } - secondaryLabel="依赖安装" - secondaryDisabled={!canUse("dependencies-install") || !selectedDependencyPlan || selectedDependencyProbe?.installPlanKey !== selectedDependencyPlan.key} - secondaryReason={!selectedDependencyPlan ? "请选择 Platform 返回的审核计划" : selectedDependencyProbe?.installPlanKey !== selectedDependencyPlan.key ? "所选计划不属于当前 probe" : reasonFor("dependencies-install")} - onSecondary={() => - void runOperation( - "依赖安装", - () => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey, selectedDependencyPlan?.digest ?? "")), - (job) => `依赖安装任务已排队,job ${job.id}`, - { - description: `审批 ${installPlanKey} 的 immutable digest ${shortChecksum(selectedDependencyPlan?.digest ?? "")} 后派发依赖安装任务。`, - stages: runtimeDependencyStages, - executeStageIndex: 2 - } - ) - } - > - {selectedDependencyProbe && ( -
- - {selectedDependencyProbe.key} · {selectedDependencyProbe.state} - - required {selectedDependencyProbe.required ? "yes" : "no"} - {selectedDependencyProbe.evidence && evidence {selectedDependencyProbe.evidence}} - {selectedDependencyPlan && digest {shortChecksum(selectedDependencyPlan.digest)}} - {selectedDependencyPlan && steps {selectedDependencyPlan.steps.map((step) => `${step.type}:${step.packageManager ?? step.downloadHost ?? step.targetKey}`).join(" → ")}} -
- )} - {dependencyCatalog.status === "error" && } -
- - void runOperation( - "实时日志", - async () => { - onOpenLogs(); - return true; - }, - () => "已打开实时日志视图", - { - description: `读取 ${instance.name} 的平台日志源并打开实时日志视图。`, - stages: runtimeLogStages, - executeStageIndex: 1 - } - ) - } - secondaryLabel="历史回填" - secondaryDisabled={!canUse("historical-logs")} - secondaryReason={reasonFor("historical-logs")} - onSecondary={() => - void runOperation( - "历史日志回填", - () => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)), - (job) => `历史日志回填任务已排队,job ${job.id}`, - { - description: `从 ${logSourceKey} 日志源准备历史回填游标并派发后台 job。`, - stages: runtimeLogStages, - executeStageIndex: 1 - } - ) - } - > - - -
- -
- ); -} - -interface RuntimeActionRowProps { - title: string; - description: string; - disabled: boolean; - reason: string; - actionLabel: string; - danger?: boolean; - onAction: () => void; - secondaryLabel?: string; - secondaryDisabled?: boolean; - secondaryReason?: string; - onSecondary?: () => void; - children?: ReactNode; -} - -function RuntimeActionRow({ title, description, disabled, reason, actionLabel, danger, onAction, secondaryLabel, secondaryDisabled, secondaryReason, onSecondary, children }: RuntimeActionRowProps) { - return ( -
- - {title} -

{description}

- {disabled && 不可用:{reason}} - {children} -
-
- - {secondaryLabel && onSecondary && ( - - )} -
-
- ); -} - -function runtimeDefaultsForPlugin(pluginId: string) { - const isScum = pluginId.toLowerCase().includes("scum"); - return { - runOs: isScum ? "windows" : "linux", - clientProfileKey: isScum ? "scum-client-manager" : "client-manager", - repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git", - sourceRevision: "main", - probeKey: isScum ? "steamcmd" : "java-21", - installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux", - logSourceKey: isScum ? "scum-server-events" : "latest-log" - }; -} - -function shortChecksum(value: string): string { - if (!value) return "unavailable"; - return value.length > 22 ? `${value.slice(0, 22)}…` : value; -} - -function runUpdatePhaseLabel(phase: RunUpdateJobResponse["phase"]): string { - switch (phase) { - case "queued": return "等待下载"; - case "downloading": return "分块下载与校验"; - case "staged": return "已安全暂存"; - case "restart-requested": return "等待重启激活"; - case "activating": return "激活与健康确认"; - case "succeeded": return "更新成功"; - case "rolled-back": return "已回滚"; - case "failed": return "更新失败"; - } -} - interface LogsSectionProps { serverId: string; } @@ -1738,371 +950,6 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio ); } -interface PluginControlsSectionProps { - serverId: string; - instance: ServerInstanceResponse; - plugins: GamePluginResponse[]; - artifacts: ArtifactResponse[]; - session: PageComponentProps["session"]; - operations: PageComponentProps["operations"]; - onNavigate: PageComponentProps["onNavigate"]; -} - -function controlsForPlugin(plugin: GamePluginResponse): PluginControlDescriptor[] { - const controls: PluginControlDescriptor[] = []; - for (const [action] of Object.entries(plugin.lifecycleActions)) { - if (action === "install" || action === "restart") { - continue; - } - if (action !== "start" && action !== "stop" && action !== "status") { - continue; - } - controls.push({ - key: `lifecycle:${action}`, - label: lifecycleControlLabel(action), - description: `通过平台生命周期 API 执行插件声明的 ${action} 动作`, - capability: `process.${action}`, - lifecycleAction: action, - dangerous: action === "stop" - }); - } - for (const bridgeAction of plugin.bridgeActions) { - if (bridgeAction === "jobs.dispatch") { - controls.push({ - key: "bridge:gift", - label: "发送礼物", - description: "通过插件任务向在线玩家发放礼物", - capability: "plugin.gift.send", - dangerous: false - }); - controls.push({ - key: "bridge:activity", - label: "调整活动", - description: "修改插件当前的活动配置", - capability: "plugin.activity.update", - dangerous: false - }); - } - if (bridgeAction === "logs.query") { - controls.push({ - key: "bridge:module-restart", - label: "重启插件模块", - description: "重启该插件在此服务器上的运行模块", - capability: "plugin.module.restart", - dangerous: true - }); - } - } - const unique = new Map(controls.map((control) => [control.key, control])); - return [...unique.values()]; -} - -function lifecycleControlLabel(action: string): string { - switch (action) { - case "start": - return "启动进程"; - case "stop": - return "停止进程"; - case "status": - return "查询进程"; - case "restart": - return "重启进程"; - default: - return action; - } -} - -function PluginControlsSection({ serverId, instance, plugins, artifacts, session, operations, onNavigate }: PluginControlsSectionProps) { - const [collapsed, setCollapsed] = useState>(new Set()); - const [confirmControl, setConfirmControl] = useState(null); - const [confirmBusy, setConfirmBusy] = useState(false); - - const groups = useMemo(() => { - const installed = plugins.filter((plugin) => plugin.id === instance.pluginId || plugin.status === "installed"); - const relevant = installed.some((plugin) => plugin.id === instance.pluginId) - ? installed.filter((plugin) => plugin.id === instance.pluginId) - : installed; - return relevant.map((plugin) => ({ - pluginId: plugin.id, - pluginName: pluginLabel(plugin, plugin.id), - version: plugin.version, - status: plugin.status, - controls: controlsForPlugin(plugin) - })); - }, [plugins, instance.pluginId]); - - function toggleGroup(pluginId: string) { - setCollapsed((current) => { - const next = new Set(current); - if (next.has(pluginId)) { - next.delete(pluginId); - } else { - next.add(pluginId); - } - return next; - }); - } - - async function dispatchControl(group: PluginControlGroupView, control: PluginControlDescriptor) { - const operationId = operations.begin({ - intent: control.label, - targetKind: "plugin", - targetId: `${serverId}:${group.pluginId}`, - requester: session.displayName - }); - try { - if (control.lifecycleAction === "start" || control.lifecycleAction === "stop" || control.lifecycleAction === "status") { - const result = - control.lifecycleAction === "start" - ? await platformApiClient.startServerInstance(instance.id, serverLifecycleCommandRequest(instance, "start")) - : control.lifecycleAction === "stop" - ? await platformApiClient.stopServerInstance(instance.id, serverLifecycleCommandRequest(instance, "stop")) - : await platformApiClient.queryServerProcessStatus(instance.id, serverLifecycleCommandRequest(instance, "status")); - operations.succeed(operationId, `平台生命周期任务 ${result.job.id} 已派发(${result.job.capability})`, result.job); - return; - } - const job = await platformApiClient.createJob({ - id: `job-${control.capability.replaceAll(".", "-")}-${serverId}-${Date.now()}`, - serverInstanceId: serverId, - runEndpointId: instance.runEndpointId, - capability: control.capability, - idempotencyKey: `web:${control.capability}:${serverId}:${group.pluginId}:${Date.now()}` - }); - operations.succeed(operationId, `任务 ${job.id} 已派发(${control.capability})`, job); - } catch (error) { - operations.fail(operationId, error instanceof Error ? error.message : "插件操作派发失败", operationId); - } - } - - return ( -
- {groups.length === 0 && ( - - )} - {groups.map((group) => { - const isCollapsed = collapsed.has(group.pluginId); - return ( -
- - {!isCollapsed && ( -
- {plugins.find((plugin) => plugin.id === group.pluginId) && ( - <> - plugin.id === group.pluginId)?.productionLifecycle?.operations} serverId={serverId} /> - plugin.id === group.pluginId)!} - serverId={serverId} - serverInstance={instance} - artifacts={artifacts} - onNavigate={onNavigate} - /> - - )} - {group.controls.length === 0 && 该插件未声明可用控制项。} - {group.controls.map((control) => { - const targetId = `${serverId}:${group.pluginId}`; - const latest = operations.operations.find((operation) => operation.targetId === targetId && operation.intent === control.label); - const pending = latest?.status === "pending"; - return ( -
- - {control.label} -

{control.description}

- {latest && ( - - )} -
- -
- ); - })} -
- )} -
- ); - })} - - setConfirmControl(null)} - onConfirm={() => { - if (!confirmControl) { - return; - } - setConfirmBusy(true); - void dispatchControl(confirmControl.plugin, confirmControl.control).finally(() => { - setConfirmBusy(false); - setConfirmControl(null); - }); - }} - /> -
- ); -} - -interface PluginBridgeExecutionPanelProps { - plugin: GamePluginResponse; - serverId: string; - serverInstance: ServerInstanceResponse; - artifacts: ArtifactResponse[]; - onNavigate: PageComponentProps["onNavigate"]; -} - -function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifacts, onNavigate }: PluginBridgeExecutionPanelProps) { - const [pendingAction, setPendingAction] = useState(null); - const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null); - const declaredPageKey = plugin.gameClientBridge?.pages?.[0]?.pageKey; - const page = plugin.pages.find((candidate) => candidate.key === declaredPageKey) ?? plugin.pages[0]; - if (!page || plugin.bridgeActions.length === 0) { - return null; - } - const contract: PluginBridgeManifestContract = { - id: plugin.id, - declaredPermissions: plugin.declaredPermissions as PluginBridgeManifestContract["declaredPermissions"], - bridgeActions: plugin.bridgeActions as PluginBridgeAction[], - pages: plugin.pages.map((item) => ({ - key: item.key, - title: item.title, - path: item.path, - bundleKey: item.bundleKey, - bundleVersion: item.bundleVersion, - bundleIntegritySha256: item.bundleIntegritySha256, - permissions: item.permissions as PluginBridgeManifestContract["declaredPermissions"], - bridgeActions: item.bridgeActions as PluginBridgeAction[] | undefined - })), - aiPurposes: plugin.aiPurposes - }; - const context = createPluginBridgeHostContext({ - plugin: contract, - routeKey: page.key, - serverInstanceId: serverId, - themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" } - }); - const executableActions = context.bridgeActions.filter( - (action) => action === "server.instances.read" || action === "files.request" || action === "artifacts.open" || action === "ai.invoke" - ); - - async function execute(action: PluginBridgeAction) { - setPendingAction(action); - setResult({ status: "pending", label: "桥接请求执行中" }); - const dispatch = createPluginBridgeDispatcher(context, platformApiClient); - const response = await dispatch({ - requestId: `web:bridge:${serverId}:${plugin.id}:${action}:${Date.now()}`, - action, - aiPurpose: action === "ai.invoke" ? plugin.aiPurposes[0] : undefined, - payload: bridgePayloadForAction(action, plugin, serverInstance, artifacts[0]) - }); - setPendingAction(null); - if (response.status === "ok" || response.status === "queued") { - setResult({ status: "succeeded", label: bridgeResultLabel(response.status, response.result) }); - return; - } - setResult({ status: "failed", label: response.error?.message ?? "桥接执行被拒绝" }); - } - - return ( -
- - {page.title} 桥接执行 -

{context.permissions.join(" / ") || "无可用权限"}

- {result && } -
-
- - {executableActions.slice(0, 3).map((action) => ( - - ))} -
-
- ); -} - -function bridgePayloadForAction(action: PluginBridgeAction, plugin: GamePluginResponse, serverInstance: ServerInstanceResponse, artifact?: ArtifactResponse): Record | undefined { - if (action === "files.request") { - const declaredFileKey = plugin.fileWorkspace?.files.find((file) => file.kind === "config")?.key ?? plugin.fileWorkspace?.files[0]?.key ?? "logs/latest.log"; - return { operation: "read", key: declaredFileKey, expectedConfigVersion: String(serverInstance.configVersion) }; - } - if (action === "artifacts.open" && artifact) { - return { artifactId: artifact.id }; - } - return undefined; -} - -function bridgeActionLabel(action: PluginBridgeAction): string { - switch (action) { - case "server.instances.read": - return "读取上下文"; - case "files.request": - return "请求文件"; - case "ai.invoke": - return "AI 调用"; - case "artifacts.open": - return "打开制品"; - default: - return action; - } -} - -function bridgeResultLabel(status: string, result?: Record): string { - if (status === "queued") { - return `已派发任务 ${result?.jobId ?? ""}`.trim(); - } - if (result?.recommendation) { - return "AI 建议已返回"; - } - if (parsePluginArtifactReference(result)) { - return "制品引用已返回"; - } - return result?.serverInstanceId ? `服务器上下文 ${result.serverInstanceId} 已返回` : "桥接请求已完成"; -} - interface LlmSectionProps { serverId: string; instance: ServerInstanceResponse; @@ -2300,7 +1147,7 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHis

本次会话操作

{serverOperations.length === 0 ? ( - + ) : (
{serverOperations.map((operation) => ( diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index c9d8452..5af2fce 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -176,7 +176,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr setForm(defaultServerCreateForm(plugins, endpoints)); setShowCreate(false); await refresh(); - onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" }); + onNavigate("serverDetail", { serverId: result.instance.id }); } catch (error) { operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId); } diff --git a/platform_web/routes/routes.md b/platform_web/routes/routes.md index 3deb002..c19c6cf 100644 --- a/platform_web/routes/routes.md +++ b/platform_web/routes/routes.md @@ -6,7 +6,7 @@ First-party routes must be declared here before page implementation. - `/`: 平台概览(平台管理员默认落地页). - `/servers`: 服务器管理(服主/服务器管理员默认落地页). -- `/servers/:serverId`: 服务器详情 route(日常运维工作台:日志、管理终端、运行操作、配置、插件控制、AI 助手、操作历史). +- `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、日志、管理终端、配置、AI 助手、操作历史). - `/plugins`: 插件市场. - `/users`: 用户管理. - `/ai-providers`: AI 提供商管理. diff --git a/platform_web/routes/routes.test.ts b/platform_web/routes/routes.test.ts index ed0267a..8d6e44c 100644 --- a/platform_web/routes/routes.test.ts +++ b/platform_web/routes/routes.test.ts @@ -39,8 +39,8 @@ describe("console shell routes", () => { expect(resolved.route.id).toBe("serverDetail"); expect(resolved.params.serverId).toBe("server-example-1"); expect(hashForPage("serverDetail", { serverId: "server-example-1" })).toBe("#/servers/server-example-1"); - expect(hashForPage("serverDetail", { serverId: "server-example-1", routeKey: "run-builder" })).toBe("#/servers/server-example-1?focus=run-builder"); - expect(resolveRouteHash("#/servers/server-example-1?focus=run-builder", platformAdmin).params).toEqual({ serverId: "server-example-1", routeKey: "run-builder" }); + expect(hashForPage("serverDetail", { serverId: "server-example-1", routeKey: "overview" })).toBe("#/servers/server-example-1?focus=overview"); + expect(resolveRouteHash("#/servers/server-example-1?focus=overview", platformAdmin).params).toEqual({ serverId: "server-example-1", routeKey: "overview" }); }); it("round-trips hosted plugin page hashes with server context", () => { diff --git a/plugins/examples/dev-game-plugin/page-bundle/index.ts b/plugins/examples/dev-game-plugin/page-bundle/index.ts new file mode 100644 index 0000000..be8889e --- /dev/null +++ b/plugins/examples/dev-game-plugin/page-bundle/index.ts @@ -0,0 +1,45 @@ +export const pluginPageBundle = { key: "dev-game-plugin", version: "1.0.0", integritySha256: "sha256:1111111111111111111111111111111111111111111111111111111111111111" }; + +type ReactLike = { createElement: (...args: any[]) => any }; + +export function renderPluginPage(react: ReactLike, input: any) { + const e = react.createElement; + const pageKey = input.page?.key ?? "overview"; + if (pageKey === "config") return renderConfigPage(e, input); + if (pageKey === "logs") return renderLogsPage(e, input); + return renderOverviewPage(e, input); +} + +function renderOverviewPage(e: ReactLike["createElement"], input: any) { + return renderPanel(e, "插件概览", "Example 插件声明的服务器概览页面。", input, [ + ["服务器", input.context?.serverInstanceId ?? "未绑定"], + ["桥接动作", (input.context?.bridgeActions ?? []).join(" / ") || "未声明"], + ["页面来源", "dev-game-plugin 页面 bundle"] + ]); +} + +function renderConfigPage(e: ReactLike["createElement"], input: any) { + return renderPanel(e, "配置工作台", "配置入口由插件页面声明,平台只提供受控上下文。", input, [ + ["文件权限", (input.context?.permissions ?? []).filter((value: string) => value.includes("files")).join(" / ") || "未声明"], + ["AI 能力", (input.context?.permissions ?? []).includes("ai.invoke") ? "可请求平台 AI" : "未声明"], + ["写入策略", "平台审查后派发"] + ]); +} + +function renderLogsPage(e: ReactLike["createElement"], input: any) { + return renderPanel(e, "日志视图", "日志入口由插件声明并通过平台日志通道读取。", input, [ + ["日志权限", (input.context?.permissions ?? []).includes("server.logs.read") ? "可读" : "未声明"], + ["Companion", input.availability?.available ? "可用" : input.availability?.reason ?? "等待运行端"], + ["桥接", (input.context?.bridgeActions ?? []).includes("logs.query") ? "logs.query" : "未声明"] + ]); +} + +function renderPanel(e: ReactLike["createElement"], title: string, summary: string, input: any, rows: Array<[string, string]>) { + return e("section", { className: "console-panel", "aria-label": title }, + e("div", { className: "panel-header" }, + e("div", null, e("h2", null, title), e("p", { className: "provider-id" }, summary)), + e("span", { className: "page-status" }, input.context?.serverInstanceId ? "已绑定服务器" : "未绑定服务器") + ), + e("div", { className: "console-row-list" }, rows.map(([label, value]) => e("div", { key: label, className: "console-row" }, e("span", null, label), e("strong", null, value)))) + ); +} diff --git a/plugins/examples/minecraft-server-plugin/manifest.json b/plugins/examples/minecraft-server-plugin/manifest.json index 8cf6835..e9cc7af 100644 --- a/plugins/examples/minecraft-server-plugin/manifest.json +++ b/plugins/examples/minecraft-server-plugin/manifest.json @@ -114,55 +114,61 @@ }, "pages": [ { - "key": "overview", - "title": "MC 概览", - "path": "/overview", + "key": "files", + "title": "文件管理", + "path": "/files", "bundleKey": "minecraft-server-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "permissions": [ "server.read", - "server.lifecycle" - ], - "bridgeActions": [ - "server.instances.read", - "jobs.dispatch" - ] - }, - { - "key": "remote", - "title": "MC 远程", - "path": "/remote", - "bundleKey": "minecraft-server-plugin", - "bundleVersion": "1.0.0", - "bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "permissions": [ - "server.remote.access", "server.files.read", "server.files.write", "server.logs.read" ], "bridgeActions": [ - "remote.access.request", + "server.instances.read", "files.request", "logs.query" ] }, { - "key": "rcon", - "title": "MC RCON", - "path": "/rcon", + "key": "mods-market", + "title": "mod/插件市场", + "path": "/mods-market", "bundleKey": "minecraft-server-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "permissions": [ - "server.remote.access" + "server.read", + "server.artifacts.read", + "server.files.read" ], "bridgeActions": [ - "remote.access.request" + "server.instances.read", + "artifacts.open" ] } ], + "fileWorkspace": { + "defaultDirectoryKey": "minecraft-config", + "directories": [ + { "key": "minecraft-config", "label": "服务器配置", "scope": "config" }, + { "key": "minecraft-logs", "label": "日志文件", "scope": "logs" } + ], + "files": [ + { "key": "server-properties", "directoryKey": "minecraft-config", "label": "server.properties", "kind": "config", "editable": true }, + { "key": "ops-json", "directoryKey": "minecraft-config", "label": "ops.json", "kind": "config", "editable": true }, + { "key": "whitelist-json", "directoryKey": "minecraft-config", "label": "whitelist.json", "kind": "config", "editable": true }, + { "key": "minecraft-latest-log", "directoryKey": "minecraft-logs", "label": "latest.log", "kind": "log", "streamKey": "minecraft.latest" } + ], + "configFields": [ + { "key": "motd", "fileKey": "server-properties", "configKey": "motd", "label": "服务器名称", "description": "显示在 Minecraft 服务器列表中的 MOTD。", "control": "text", "defaultValue": "Minecraft Server", "restartImpact": "restart-required" }, + { "key": "server-port", "fileKey": "server-properties", "configKey": "server-port", "label": "游戏端口", "description": "Minecraft Java 客户端连接端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "25565", "restartImpact": "restart-required" }, + { "key": "max-players", "fileKey": "server-properties", "configKey": "max-players", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 200, "defaultValue": "20", "restartImpact": "restart-required" }, + { "key": "online-mode", "fileKey": "server-properties", "configKey": "online-mode", "label": "正版验证", "description": "是否启用 Mojang 在线身份验证。", "control": "boolean", "defaultValue": "true", "restartImpact": "restart-required" } + ] + }, "ai": { "purposes": [ "config.suggest", diff --git a/plugins/examples/minecraft-server-plugin/page-bundle/index.ts b/plugins/examples/minecraft-server-plugin/page-bundle/index.ts new file mode 100644 index 0000000..1be43b0 --- /dev/null +++ b/plugins/examples/minecraft-server-plugin/page-bundle/index.ts @@ -0,0 +1,38 @@ +export const pluginPageBundle = { key: "minecraft-server-plugin", version: "1.0.0", integritySha256: "sha256:2222222222222222222222222222222222222222222222222222222222222222" }; + +type ReactLike = { createElement: (...args: any[]) => any }; + +export function renderPluginPage(react: ReactLike, input: any) { + const e = react.createElement; + const pageKey = input.page?.key ?? "files"; + const workspace = input.workspace; + if (pageKey === "mods-market") return renderModsMarket(e, input); + return renderFiles(e, input, workspace); +} + +function renderFiles(e: ReactLike["createElement"], input: any, workspace: any) { + const files = workspace?.files ?? []; + return e("section", { className: "console-panel", "aria-label": "Minecraft 文件管理" }, + e("div", { className: "panel-header" }, + e("div", null, e("h2", null, "文件管理"), e("p", { className: "provider-id" }, "server.properties、白名单、OP 列表和日志由 Minecraft 插件声明。")), + e("span", { className: "page-status" }, input.context?.serverInstanceId ? "已绑定服务器" : "未绑定服务器") + ), + e("div", { className: "console-row-list" }, + files.length ? files.map((file: any) => e("div", { key: file.key, className: "console-row" }, e("span", null, file.label), e("strong", null, file.kind === "config" ? file.editable === false ? "配置只读" : "配置可写" : "日志只读"))) : e("div", { className: "console-row" }, e("span", null, "声明文件"), e("strong", null, "等待插件工作区")) + ) + ); +} + +function renderModsMarket(e: ReactLike["createElement"], input: any) { + return e("section", { className: "console-panel", "aria-label": "Minecraft mod 插件市场" }, + e("div", { className: "panel-header" }, + e("div", null, e("h2", null, "mod/插件市场"), e("p", { className: "provider-id" }, "Minecraft 插件拥有自己的 mod/插件入口;平台只提供安全托管上下文。")), + e("span", { className: "page-status" }, input.availability?.available ? "运行端可用" : input.availability?.reason ?? "等待运行端") + ), + e("div", { className: "console-row-list" }, + e("div", { className: "console-row" }, e("span", null, "服务器"), e("strong", null, input.context?.serverInstanceId ?? "未绑定")), + e("div", { className: "console-row" }, e("span", null, "权限"), e("strong", null, (input.context?.permissions ?? []).join(" / ") || "未声明")), + e("div", { className: "console-row" }, e("span", null, "安装策略"), e("strong", null, "由 Minecraft 插件声明和审核")) + ) + ); +} diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 71a2e78..c20dafb 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -22,6 +22,8 @@ export type SCUMFileReadSnapshot = { }; export type SCUMPageContext = { + pageKey?: string; + pageTitle?: string; serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; @@ -45,6 +47,7 @@ type DiffLine = { kind: "same" | "added" | "removed"; text: string }; export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { const e = react.createElement; + if (input.pageKey && input.pageKey !== "files-config") return renderSCUMFeatureSurface(e, input); const workspace = normalizeWorkspace(input.workspace); const [selectedDirectoryKey, setSelectedDirectoryKey] = usePluginState(react, workspace.defaultDirectoryKey); const effectiveDirectoryKey = workspace.directories.some((directory) => directory.key === selectedDirectoryKey) ? selectedDirectoryKey : workspace.defaultDirectoryKey; @@ -199,6 +202,34 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) ); } +function renderSCUMFeatureSurface(e: ReactLike["createElement"], input: SCUMPageContext) { + const meta = scumSurfaceMeta(input.pageKey ?? ""); + const featureRows = (input.featureAvailability ?? []) + .filter((feature) => meta.features.includes(feature.key)) + .map((feature) => e("div", { key: feature.key, className: "console-row" }, e("span", null, feature.key), e("strong", null, feature.available ? "可用" : feature.reason ?? "等待 Companion"))); + return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? meta.title }, + e("div", { className: "panel-header" }, + e("div", null, e("h2", null, input.pageTitle ?? meta.title), e("p", { className: "provider-id" }, meta.summary)), + e("span", { className: "page-status" }, input.availability.available ? "Companion 在线" : input.availability.reason ?? "等待 Companion") + ), + e("div", { className: "console-row-list" }, + e("div", { className: "console-row" }, e("span", null, "服务器"), e("strong", null, input.serverInstanceId ?? "未绑定")), + e("div", { className: "console-row" }, e("span", null, "权限"), e("strong", null, input.permissions.join(" / ") || "未声明")), + featureRows.length ? featureRows : e("div", { className: "console-row" }, e("span", null, "插件能力"), e("strong", null, meta.features.join(" / ") || "由插件声明")) + ) + ); +} + +function scumSurfaceMeta(pageKey: string): { title: string; summary: string; features: string[] } { + switch (pageKey) { + case "players": return { title: "用户管理", summary: "玩家查询、在线状态、维护窗口和状态修正由 SCUM 插件 Companion 提供。", features: ["player.intelligence", "state.patch"] }; + case "squads": return { title: "队伍管理", summary: "队伍列表、成员关系和风险上下文来自插件声明的 squads 快照。", features: ["player.intelligence"] }; + case "live-map": return { title: "实时地图", summary: "玩家、载具和轨迹采样由插件事件流驱动。", features: ["trajectory.collect", "vehicle.spawn"] }; + case "gifts": return { title: "礼包管理", summary: "礼包目录、发放和玩家通知通过受保护插件命令执行。", features: ["reward.delivery"] }; + default: return { title: "SCUM 插件页面", summary: "该页面由 SCUM 插件声明。", features: [] }; + } +} + function navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, refreshState: WorkspaceRefreshState, canRefreshWorkspace: boolean, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void, onRefreshWorkspace: () => void) { const activeDirectory = workspace.directories.find((directory) => directory.key === directoryKey); const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0]; diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 27c112a..5e0d485 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -373,7 +373,13 @@ "dependencyPolicy": "required", "approvalRequired": ["disable", "rollback", "retire"] }, - "pages": [{ "key": "files-config", "title": "文件管理", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read"], "bridgeActions": ["server.instances.read", "files.request", "logs.query"], "featureKeys": ["config.manage"] }], + "pages": [ + { "key": "files-config", "title": "文件管理", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read"], "bridgeActions": ["server.instances.read", "files.request", "logs.query"], "featureKeys": ["config.manage"] }, + { "key": "players", "title": "用户管理", "path": "/players", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read", "server.game-client.command"], "bridgeActions": ["server.instances.read"], "featureKeys": ["player.intelligence", "state.patch"] }, + { "key": "squads", "title": "队伍管理", "path": "/squads", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read"], "bridgeActions": ["server.instances.read"], "featureKeys": ["player.intelligence"] }, + { "key": "live-map", "title": "实时地图", "path": "/live-map", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read"], "bridgeActions": ["server.instances.read"], "featureKeys": ["trajectory.collect"] }, + { "key": "gifts", "title": "礼包管理", "path": "/gifts", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read", "server.game-client.command"], "bridgeActions": ["server.instances.read"], "featureKeys": ["reward.delivery"] } + ], "fileWorkspace": { "defaultDirectoryKey": "scum-config", "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index 9cb6788..ec555df 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -3,4 +3,4 @@ import type { SCUMFeatureWorkspace } from "../features/contracts.js"; export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2" }; -export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, workspaceActions: input.workspaceActions }); } +export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "files-config", pageTitle: input.page?.title ?? "文件管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, workspaceActions: input.workspaceActions }); } diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 7687fe6..4df0747 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -486,7 +486,7 @@ describe("plugin manifest validation", () => { "restart.prepare", "maintenance.prepare" ])); - expect(manifest.pages.map((page) => page.key)).toContain("files-config"); + expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["files-config", "players", "squads", "live-map", "gifts"])); expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config"); expect(manifest.fileWorkspace?.directories.map((directory) => directory.key)).toEqual(["scum-config", "scum-logs"]); expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-game-config", "scum-engine-config", "scum-game-user-settings", "scum-admin-log", "scum-chat-log", "scum-kill-log", "scum-login-log", "scum-server-log"])); @@ -741,6 +741,9 @@ describe("plugin manifest validation", () => { it("accepts the Minecraft server plugin manifest", () => { expect(validateManifestFile("examples/minecraft-server-plugin/manifest.json")).toEqual([]); + const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/minecraft-server-plugin/manifest.json"), "utf8")) as { pages: Array<{ key: string; title: string }>; fileWorkspace?: { files?: Array<{ key: string }> } }; + expect(manifest.pages.map((page) => `${page.key}:${page.title}`)).toEqual(["files:文件管理", "mods-market:mod/插件市场"]); + expect(manifest.fileWorkspace?.files?.map((file) => file.key)).toEqual(expect.arrayContaining(["server-properties", "ops-json", "whitelist-json", "minecraft-latest-log"])); }); it("validates typed game-client bridge catalogs", () => {