Remove pre-1.0 production governance surfaces

This commit is contained in:
npc0-hue
2026-08-21 00:06:00 +08:00
parent a7e2e4c6c0
commit da6c8d607e
30 changed files with 89 additions and 429 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
This repository is the game server management platform workspace. It replaces the old SCUM-specific coupling with three browser-owned project roots: This repository is the game server management platform workspace. It replaces the old SCUM-specific coupling with three browser-owned project roots:
- `platform/`: backend control plane for users, game management plugins, server instances, AI providers, jobs, artifacts, logs, and audit. - `platform/`: backend control plane for users, game management plugins, server instances, AI providers, jobs, artifacts, and logs.
- `platform_web/`: management console frontend. - `platform_web/`: management console frontend.
- `plugins/`: game management plugin workspace. A plugin defines how to create and manage one server type, and one installed plugin can create many server instances. - `plugins/`: game management plugin workspace. A plugin defines how to create and manage one server type, and one installed plugin can create many server instances.
@@ -134,7 +134,7 @@ PLATFORM_LOG_BODY_BACKEND: file
Change the existing `PLATFORM_STORAGE_BACKEND: file` line to `mysql`, uncomment/add the `PLATFORM_MYSQL_DSN` line, then uncomment the `mysql` service and the `platform.depends_on.mysql` block in `docker-compose.yml`. Change the existing `PLATFORM_STORAGE_BACKEND: file` line to `mysql`, uncomment/add the `PLATFORM_MYSQL_DSN` line, then uncomment the `mysql` service and the `platform.depends_on.mysql` block in `docker-compose.yml`.
MySQL stores platform metadata only: users, plugins, servers, jobs, audit events, log stream cursors, and indexes. Log bodies stay in `PLATFORM_LOG_DIR` as segmented files unless a future `LogBodyStore` adapter such as ClickHouse/Loki/OpenSearch is configured. Do not store hundreds or thousands of servers' log lines as one MySQL row per line. MySQL stores platform metadata only: users, plugins, servers, jobs, operational records, log stream cursors, and indexes. Log bodies stay in `PLATFORM_LOG_DIR` as segmented files unless a future `LogBodyStore` adapter such as ClickHouse/Loki/OpenSearch is configured. Do not store hundreds or thousands of servers' log lines as one MySQL row per line.
To change Docker ports, storage paths, MySQL DSN, run identity, or the external run checkout path, edit `docker-compose.yml` or set `RUN_REPO_DIR`. Do not put real secrets in committed compose files; use a local untracked `.env` or shell environment for machine-specific values. To change Docker ports, storage paths, MySQL DSN, run identity, or the external run checkout path, edit `docker-compose.yml` or set `RUN_REPO_DIR`. Do not put real secrets in committed compose files; use a local untracked `.env` or shell environment for machine-specific values.
@@ -174,7 +174,7 @@ Most common edits:
- Frontend API: `VITE_PLATFORM_API_BASE_URL=/api/v1`. - Frontend API: `VITE_PLATFORM_API_BASE_URL=/api/v1`.
- Vite dev proxy: `PLATFORM_API_PROXY=http://127.0.0.1:8080`. - Vite dev proxy: `PLATFORM_API_PROXY=http://127.0.0.1:8080`.
For hundreds or thousands of servers, keep relational databases for platform metadata, stream state, indexes, retention policy, and audit. Do not store high-volume log bodies as one MySQL row per line; use a future `LogBodyStore` adapter for ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments. For hundreds or thousands of servers, keep relational databases for platform metadata, stream state, indexes, retention policy, and operational records. Do not store high-volume log bodies as one MySQL row per line; use a future `LogBodyStore` adapter for ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
The frontend shell touches first-party pages, so UI changes require a browser walkthrough at desktop and mobile widths. The frontend shell touches first-party pages, so UI changes require a browser walkthrough at desktop and mobile widths.
+1 -1
View File
@@ -103,4 +103,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. 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 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-held per-job input. This change uses scoped 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-held per-job input. This change uses scoped 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, plugin lifecycle dispatch, and transactional Run self-update are implemented. Production signing/fleet rollout, external provider/storage adapters, and real AI-provider integration remain separate tasks.
+5 -5
View File
@@ -145,7 +145,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
// pluginLifecycles godoc // pluginLifecycles godoc
// @Summary List server-bound plugin lifecycle state // @Summary List server-bound plugin lifecycle state
// @Description Lists durable plugin installation, desired/current state, compatibility, dependency, and job metadata. // @Description Lists durable plugin installation, desired/current state, compatibility, dependency, and job metadata.
// @Tags production-operations // @Tags plugin-operations
// @Produce json // @Produce json
// @Success 200 {object} dto.PluginLifecycleListResponse // @Success 200 {object} dto.PluginLifecycleListResponse
// @Failure 401 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse
@@ -166,8 +166,8 @@ func (h *coreHandlers) pluginLifecycles(w http.ResponseWriter, r *http.Request)
// pluginLifecycleAction godoc // pluginLifecycleAction godoc
// @Summary Dispatch a platform-mediated plugin lifecycle action // @Summary Dispatch a platform-mediated plugin lifecycle action
// @Description Runs compatibility and capacity gates before creating one durable bounded Run job. // @Description Validates manifest compatibility and creates one durable bounded Run job.
// @Tags production-operations // @Tags plugin-operations
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param pluginId path string true "Plugin ID" // @Param pluginId path string true "Plugin ID"
@@ -199,7 +199,7 @@ func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Requ
// aiConfigDiffs godoc // aiConfigDiffs godoc
// @Summary List reviewable AI config diffs // @Summary List reviewable AI config diffs
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration. // @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
// @Tags production-operations // @Tags plugin-operations
// @Produce json // @Produce json
// @Success 200 {object} dto.AIConfigDiffListResponse // @Success 200 {object} dto.AIConfigDiffListResponse
// @Failure 401 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse
@@ -221,7 +221,7 @@ func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
// aiConfigDiffApprove godoc // aiConfigDiffApprove godoc
// @Summary Approve one reviewable AI config diff // @Summary Approve one reviewable AI config diff
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job. // @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
// @Tags production-operations // @Tags plugin-operations
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param id path string true "AI config diff ID" // @Param id path string true "AI config diff ID"
+4 -2
View File
@@ -1519,7 +1519,9 @@ func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) {
serverID := createRuntimeAPIFixtures(t, router, adminSession) serverID := createRuntimeAPIFixtures(t, router, adminSession)
createAIProviderFixture(t, router, adminSession) createAIProviderFixture(t, router, adminSession)
lifecycle := postOKJSONWithAuth[dto.PluginLifecycleActionResponse](t, router, "/api/v1/plugin-lifecycles/server.runtime/actions", dto.PluginLifecycleActionRequest{ServerInstanceID: serverID, Operation: "install", TargetVersion: "1.0.0", IdempotencyKey: "api-plugin-install", Confirmed: false}, adminSession) lifecycleRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-lifecycles/server.runtime/actions", dto.PluginLifecycleActionRequest{ServerInstanceID: serverID, Operation: "install", TargetVersion: "1.0.0", IdempotencyKey: "api-plugin-install", Confirmed: false}, adminSession)
assertStatus(t, lifecycleRecorder, http.StatusAccepted)
lifecycle := decodeBody[dto.PluginLifecycleActionResponse](t, lifecycleRecorder)
if lifecycle.Status != "queued" || lifecycle.Job.ID == "" || lifecycle.Installation.ID == "" { if lifecycle.Status != "queued" || lifecycle.Job.ID == "" || lifecycle.Installation.ID == "" {
t.Fatalf("expected queued plugin lifecycle job, got %+v", lifecycle) t.Fatalf("expected queued plugin lifecycle job, got %+v", lifecycle)
} }
@@ -1546,7 +1548,7 @@ func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) {
evidence := fmt.Sprintf("%+v %+v %+v %+v", lifecycle, lifecycles, diffs, approval) evidence := fmt.Sprintf("%+v %+v %+v %+v", lifecycle, lifecycles, diffs, approval)
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} { for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
if strings.Contains(evidence, forbidden) { if strings.Contains(evidence, forbidden) {
t.Fatalf("production operations response leaked forbidden fragment %q: %s", forbidden, evidence) t.Fatalf("plugin operations response leaked forbidden fragment %q: %s", forbidden, evidence)
} }
} }
} }
+5 -9
View File
@@ -109,18 +109,14 @@ Marketplace state actions are metadata-only in this change. `install` and `enabl
Marketplace catalog state remains separate from production lifecycle installations. Server-bound install/enable/disable/upgrade/rollback/retire operations use the production lifecycle routes below. Marketplace catalog state remains separate from production lifecycle installations. Server-bound install/enable/disable/upgrade/rollback/retire operations use the production lifecycle routes below.
## Production Operations ## Plugin Operations
- `GET /api/v1/production/capacity`: return bounded endpoint capacity, durable job pressure, backlog counts, pressure codes, and active-alert count visible to the session.
- `POST /api/v1/production/capacity/admission`: evaluate server binding, endpoint heartbeat/capability, job limits, queue pressure, and spool pressure without dispatching work.
- `GET /api/v1/alerts`: list durable alerts with state/source/severity filters.
- `POST /api/v1/alerts/{id}/acknowledge`, `/resolve`, and `/retry`: persist one scoped alert transition or source retry with actor evidence.
- `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations. - `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations.
- `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, idempotency, and capacity before creating one durable Run job. - `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, and idempotency before creating one durable Run job.
- `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session. - `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session.
- `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job. - `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job.
These responses expose logical IDs, counts, states, pressure codes, safe diagnostics, and job links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material. These responses expose logical IDs, states, safe diagnostics, and job links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material.
## Implemented Plugin Bridge Actions ## Implemented Plugin Bridge Actions
@@ -159,7 +155,7 @@ Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/ev
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 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 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.
SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials. SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw request payloads, run sockets, host paths, or credentials.
`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. `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.
@@ -248,7 +244,7 @@ These route groups remain documented future work beyond the currently implemente
- External artifact storage backends, presigned URLs, and production throttling policies. Run self-update range reads and local artifact upload are implemented, but production mirrors/signing are not. - External artifact storage backends, presigned URLs, and production throttling policies. Run self-update range reads and local artifact upload are implemented, but production mirrors/signing are not.
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts. - Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
- Live AI provider connectivity tests and remote model discovery. - Live AI provider connectivity tests and remote model discovery.
- Production Run distribution signing/KMS, fleet rollout rings, client-manager lifecycle, plugin lifecycle, production scaling/alerts, and real AI-provider integration. - Production Run distribution signing/KMS, fleet rollout rings, and real AI-provider integration.
- Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions. - Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions.
## Core Service Boundary ## Core Service Boundary
-1
View File
@@ -13,7 +13,6 @@ Required model groups:
- artifacts and chunks. - artifacts and chunks.
- log streams and ingestion cursors. - log streams and ingestion cursors.
- operational events. - operational events.
- durable alerts and their acknowledgement/resolution metadata.
- server-bound plugin lifecycle installations and linked jobs. - server-bound plugin lifecycle installations and linked jobs.
- reviewable AI config diffs and approval fences. - reviewable AI config diffs and approval fences.
+1 -1
View File
@@ -38,4 +38,4 @@ AI invocation responses must be bounded and must not include raw provider creden
Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode. Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode.
Provider failures create redacted alert evidence and return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected. Provider failures return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected.
@@ -16,4 +16,4 @@ Platform owns the reviewable dependency catalog, immutable plan digest, selected
3. The terminal staged result moves the safe phase to `restart-requested`. The local journal persists the activation manifest before helper launch. The helper backs up/replaces atomically, starts the new binary with helper environment removed, waits for health, and rolls back on timeout or identity failure. 3. The terminal staged result moves the safe phase to `restart-requested`. The local journal persists the activation manifest before helper launch. The helper backs up/replaces atomically, starts the new binary with helper environment removed, waits for health, and rolls back on timeout or identity failure.
4. The new Run reports success or rollback through signed `update-health` only after registration and job reconciliation. Platform then projects `succeeded` or `rolled-back`; a hello-only outcome is never treated as health confirmation. 4. The new Run reports success or rollback through signed `update-health` only after registration and job reconciliation. Platform then projects `succeeded` or `rolled-back`; a hello-only outcome is never treated as health confirmation.
Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, client-manager lifecycle, plugin lifecycle, production scaling/alerts, external mirrors/storage, or real AI-provider integration. Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, client-manager lifecycle, plugin lifecycle, external mirrors/storage, or real AI-provider integration.
+2 -2
View File
@@ -306,8 +306,8 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
} }
} }
func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) { func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "production-operations.json") path := filepath.Join(t.TempDir(), "plugin-operations.json")
store, err := NewFileStore(path) store, err := NewFileStore(path)
if err != nil { if err != nil {
t.Fatalf("create file store: %v", err) t.Fatalf("create file store: %v", err)
+2 -2
View File
@@ -114,9 +114,9 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
if request.ServerInstanceID == "" { if request.ServerInstanceID == "" {
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations") return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
} }
svc.productionMu.Lock() svc.pluginOperationsMu.Lock()
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result) preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
svc.productionMu.Unlock() svc.pluginOperationsMu.Unlock()
if persistErr != nil { if persistErr != nil {
return domain.AIInvocationResponse{}, persistErr return domain.AIInvocationResponse{}, persistErr
} }
+1 -1
View File
@@ -182,7 +182,7 @@ func TestCoreServiceDoesNotPrebindLegacyRunBeforeDistributionBuild(t *testing.T)
func TestCoreServiceDistributionBuildIgnoresStaleRunEndpoint(t *testing.T) { func TestCoreServiceDistributionBuildIgnoresStaleRunEndpoint(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
svc.now = func() time.Time { return fixedTime.Add(capacityHeartbeatStaleAfter + time.Second) } svc.now = func() time.Time { return fixedTime.Add(runHeartbeatStaleAfter + time.Second) }
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
+1 -1
View File
@@ -284,7 +284,7 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil { if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
if err := svc.projectProductionOpsJobResult(job, stamp); err != nil { if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
@@ -70,8 +70,8 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d
return svc.pluginLifecycleDenied(user.ID, instance, plugin, request, "plugin is not compatible with the assigned endpoint platform") return svc.pluginLifecycleDenied(user.ID, instance, plugin, request, "plugin is not compatible with the assigned endpoint platform")
} }
svc.productionMu.Lock() svc.pluginOperationsMu.Lock()
defer svc.productionMu.Unlock() defer svc.pluginOperationsMu.Unlock()
installationID := pluginLifecycleInstallationID(request.PluginID, request.ServerInstanceID) installationID := pluginLifecycleInstallationID(request.PluginID, request.ServerInstanceID)
installation, getErr := svc.store.PluginLifecycles().Get(installationID) installation, getErr := svc.store.PluginLifecycles().Get(installationID)
if errors.Is(getErr, repo.ErrNotFound) { if errors.Is(getErr, repo.ErrNotFound) {
@@ -156,8 +156,8 @@ func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request
if err != nil { if err != nil {
return domain.AIConfigDiffApprovalResult{}, err return domain.AIConfigDiffApprovalResult{}, err
} }
svc.productionMu.Lock() svc.pluginOperationsMu.Lock()
defer svc.productionMu.Unlock() defer svc.pluginOperationsMu.Unlock()
preview, err := svc.store.AIConfigDiffs().Get(request.DiffID) preview, err := svc.store.AIConfigDiffs().Get(request.DiffID)
if err != nil { if err != nil {
return domain.AIConfigDiffApprovalResult{}, err return domain.AIConfigDiffApprovalResult{}, err
@@ -206,12 +206,12 @@ func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
} }
func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time.Time) error { func (svc *CoreService) projectPluginOperationsJobResult(job domain.Job, stamp time.Time) error {
if !strings.HasPrefix(job.ID, "job-plugin-lifecycle-") || job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" { if !strings.HasPrefix(job.ID, "job-plugin-lifecycle-") || job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
return nil return nil
} }
svc.productionMu.Lock() svc.pluginOperationsMu.Lock()
defer svc.productionMu.Unlock() defer svc.pluginOperationsMu.Unlock()
installation, err := svc.store.PluginLifecycles().Get(pluginLifecycleInstallationID(job.ExecutionInput.PluginID, job.ServerInstanceID)) installation, err := svc.store.PluginLifecycles().Get(pluginLifecycleInstallationID(job.ExecutionInput.PluginID, job.ServerInstanceID))
if err != nil { if err != nil {
return err return err
@@ -288,8 +288,8 @@ func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string)
} }
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) { func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
svc.productionMu.Lock() svc.pluginOperationsMu.Lock()
defer svc.productionMu.Unlock() defer svc.pluginOperationsMu.Unlock()
stamp := svc.now() stamp := svc.now()
installation := domain.PluginLifecycleInstallation{ID: pluginLifecycleInstallationID(plugin.ID, instance.ID), PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStateFailed, LastOperation: request.Operation, Compatibility: "incompatible", DependencyState: domain.DependencyStateUnknown, FailureReason: safeBridgeReason(reason), IdempotencyKey: request.IdempotencyKey, CreatedAt: stamp, UpdatedAt: stamp} installation := domain.PluginLifecycleInstallation{ID: pluginLifecycleInstallationID(plugin.ID, instance.ID), PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStateFailed, LastOperation: request.Operation, Compatibility: "incompatible", DependencyState: domain.DependencyStateUnknown, FailureReason: safeBridgeReason(reason), IdempotencyKey: request.IdempotencyKey, CreatedAt: stamp, UpdatedAt: stamp}
if existing, err := svc.store.PluginLifecycles().Get(installation.ID); err == nil { if existing, err := svc.store.PluginLifecycles().Get(installation.ID); err == nil {
@@ -308,13 +308,15 @@ func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil { if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
return domain.PluginLifecycleResult{}, err return domain.PluginLifecycleResult{}, err
} }
if _, err := svc.store.PluginLifecycles().Get(installation.ID); errors.Is(err, repo.ErrNotFound) { _, getErr := svc.store.PluginLifecycles().Get(installation.ID)
err = svc.store.PluginLifecycles().Create(installation) switch {
} else if err == nil { case errors.Is(getErr, repo.ErrNotFound):
err = svc.store.PluginLifecycles().Update(installation) getErr = svc.store.PluginLifecycles().Create(installation)
case getErr == nil:
getErr = svc.store.PluginLifecycles().Update(installation)
} }
if err != nil { if getErr != nil {
return domain.PluginLifecycleResult{}, err return domain.PluginLifecycleResult{}, getErr
} }
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Status: "denied"}), nil return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Status: "denied"}), nil
} }
@@ -10,7 +10,7 @@ import (
) )
func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) { func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
svc, session, instance := newProductionOpsFixture(t) svc, session, instance := newPluginOperationsFixture(t)
request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"} request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"}
first, err := svc.RunPluginLifecycleForSession(session, request) first, err := svc.RunPluginLifecycleForSession(session, request)
if err != nil { if err != nil {
@@ -35,7 +35,7 @@ func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
} }
func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) { func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) {
svc, session, instance := newProductionOpsFixture(t) svc, session, instance := newPluginOperationsFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID) plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil { if err != nil {
t.Fatalf("get plugin: %v", err) t.Fatalf("get plugin: %v", err)
@@ -73,7 +73,7 @@ func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) {
} }
func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) { func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) {
svc, session, instance := newProductionOpsFixture(t) svc, session, instance := newPluginOperationsFixture(t)
provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"}) provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"})
if err != nil { if err != nil {
t.Fatalf("create provider: %v", err) t.Fatalf("create provider: %v", err)
@@ -122,7 +122,7 @@ func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testin
} }
} }
func newProductionOpsFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) { func newPluginOperationsFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
t.Helper() t.Helper()
svc := newTestCoreService() svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+13 -3
View File
@@ -23,7 +23,10 @@ var (
ErrForbidden = errors.New("forbidden") ErrForbidden = errors.New("forbidden")
) )
const ServerDeletionForceConfirmation = "FORCE DELETE" const (
ServerDeletionForceConfirmation = "FORCE DELETE"
runHeartbeatStaleAfter = 2 * time.Minute
)
type ForbiddenError struct { type ForbiddenError struct {
Reason string Reason string
@@ -234,7 +237,7 @@ type CoreService struct {
artifactTransfers map[string]domain.ArtifactTransferSession artifactTransfers map[string]domain.ArtifactTransferSession
artifactPayloads map[string][]byte artifactPayloads map[string][]byte
artifactTransferSeq uint64 artifactTransferSeq uint64
productionMu sync.Mutex pluginOperationsMu sync.Mutex
sourceRCONCommands *sourceRCONCommandBroker sourceRCONCommands *sourceRCONCommandBroker
aiProviderClient AIProviderClient aiProviderClient AIProviderClient
secretEnvelope SecretEnvelope secretEnvelope SecretEnvelope
@@ -2654,7 +2657,14 @@ func (svc *CoreService) runEndpointHeartbeatCurrent(endpoint domain.RunEndpoint)
if endpoint.LastHeartbeatAt.IsZero() { if endpoint.LastHeartbeatAt.IsZero() {
return false return false
} }
return !svc.now().After(endpoint.LastHeartbeatAt.Add(capacityHeartbeatStaleAfter)) return !svc.now().After(endpoint.LastHeartbeatAt.Add(runHeartbeatStaleAfter))
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
} }
func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plugin domain.GamePlugin) error { func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plugin domain.GamePlugin) error {
+12 -87
View File
@@ -86,7 +86,7 @@ async function main() {
throw new Error("AI provider response exposed Platform-owned endpoint or secret reference"); throw new Error("AI provider response exposed Platform-owned endpoint or secret reference");
} }
const productionSeed = await prepareProductionOperations(authHeaders, server, plugin); const pluginSeed = await preparePluginOperations(authHeaders, server, plugin);
const chrome = await startChrome(); const chrome = await startChrome();
const evidence = { const evidence = {
@@ -114,7 +114,7 @@ async function main() {
logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])), logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])),
artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])), artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])),
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"]), usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"]),
production: productionSeed.apiProof pluginOperations: pluginSeed.apiProof
}, },
routes: [], routes: [],
safety: { safety: {
@@ -132,7 +132,7 @@ async function main() {
{ {
name: "首页", name: "首页",
hash: "#/home", hash: "#/home",
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU", "生产容量与告警", "运行槽位"] markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU"]
}, },
{ {
name: "服务器管理", name: "服务器管理",
@@ -165,12 +165,12 @@ async function main() {
{ {
name: "AI 提供商管理", name: "AI 提供商管理",
hash: "#/aiProviders", hash: "#/aiProviders",
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", productionSeed.diff.diffSummary, server.id] markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", pluginSeed.diff.diffSummary, server.id]
}, },
{ {
name: "系统维护", name: "系统维护",
hash: "#/maintenance", hash: "#/maintenance",
markers: ["系统维护", "容量与告警闭环", "运行槽位", productionSeed.alert.title] markers: ["系统维护", "运行节点", "最近失败任务"]
}, },
{ {
name: "服务器详情", name: "服务器详情",
@@ -202,10 +202,9 @@ async function main() {
const pluginPage = await clickAndVerify(chrome, "概览", ["插件概览", "dev-game-plugin 页面 bundle", "server.instances.read"]); const pluginPage = await clickAndVerify(chrome, "概览", ["插件概览", "dev-game-plugin 页面 bundle", "server.instances.read"]);
evidence.routes.push({ name: "服务器详情 / 插件声明页面", url: await chrome.url(), ...pluginPage }); evidence.routes.push({ name: "服务器详情 / 插件声明页面", url: await chrome.url(), ...pluginPage });
evidence.productionInteractions = { evidence.pluginInteractions = {
alert: await verifyAlertInteraction(chrome, authHeaders, productionSeed.alert),
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server), pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, productionSeed.diff) aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, pluginSeed.diff)
}; };
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server); evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
@@ -558,7 +557,7 @@ async function ensureAiProvider(headers) {
); );
} }
async function prepareProductionOperations(headers, server, plugin) { async function preparePluginOperations(headers, server, plugin) {
const stamp = Date.now(); const stamp = Date.now();
const lifecycle = await postJson( const lifecycle = await postJson(
`/plugin-lifecycles/${encodeURIComponent(plugin.id)}/actions`, `/plugin-lifecycles/${encodeURIComponent(plugin.id)}/actions`,
@@ -589,99 +588,25 @@ async function prepareProductionOperations(headers, server, plugin) {
throw new Error(`AI invocation did not persist a reviewable diff: ${JSON.stringify(aiInvocation)}`); throw new Error(`AI invocation did not persist a reviewable diff: ${JSON.stringify(aiInvocation)}`);
} }
const admission = await postJson( const [lifecycles, diffs] = await Promise.all([
"/production/capacity/admission",
{
serverInstanceId: server.id,
capability: "process.restart",
idempotencyKey: `browser-acceptance-capacity-gap-${stamp}`
},
headers
);
if (admission.accepted || admission.state !== "denied" || !admission.alertId) {
throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`);
}
const [capacity, alerts, lifecycles, diffs] = await Promise.all([
getJson("/production/capacity", headers),
getJson("/alerts", headers),
getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers), getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers),
getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(server.id)}`, headers) getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(server.id)}`, headers)
]); ]);
const alert = findRequired(alerts.items, (item) => item.id === admission.alertId && item.state === "active", "active capacity alert");
const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation"); const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation");
const diff = findRequired(diffs.items, (item) => item.id === aiInvocation.configRecommendation.diffId && item.state === "pending", "pending AI config diff"); const diff = findRequired(diffs.items, (item) => item.id === aiInvocation.configRecommendation.diffId && item.state === "pending", "pending AI config diff");
if (capacity.activeAlerts < 1 || !capacity.endpoints.some((item) => item.runEndpointId === server.runEndpointId)) {
throw new Error(`production capacity summary did not include seeded state: ${JSON.stringify(capacity)}`);
}
for (const [label, value] of Object.entries({ admission, capacity, alert, installation, diff, aiInvocation })) { for (const [label, value] of Object.entries({ installation, diff, aiInvocation })) {
assertNoForbiddenProjection(value, `production seed ${label}`); assertNoForbiddenProjection(value, `plugin operations seed ${label}`);
} }
return { return {
alert,
diff, diff,
apiProof: { apiProof: {
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId"]),
capacity: {
...pick(capacity, ["totalMaxJobs", "totalRunningJobs", "totalQueuedJobs", "activeAlerts", "generatedAt"]),
endpoints: capacity.endpoints.map((item) => pick(item, ["runEndpointId", "status", "maxJobs", "runningJobs", "queuedJobs", "logBacklogBatches", "artifactBacklogChunks", "pressureCodes"]))
},
alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount"]),
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId"]), pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId"]),
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"]) aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
} }
}; };
} }
async function verifyAlertInteraction(chrome, headers, seededAlert) {
await chrome.navigate(`${webUrl}/#/home`);
await chrome.waitForText(["生产容量与告警", seededAlert.title, "确认"], "production alert interaction");
await chrome.evaluate((title) => {
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found");
button.click();
}, seededAlert.title);
await chrome.waitForText(["确认告警", seededAlert.id, "取消"], "alert confirmation dialog");
await chrome.evaluate(() => {
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
if (!(cancel instanceof HTMLButtonElement)) throw new Error("alert confirmation cancel button not found");
cancel.click();
});
await delay(100);
if (await chrome.evaluate(() => Boolean(document.querySelector(".confirm-panel")))) {
throw new Error("alert confirmation dialog did not close after cancel");
}
const afterCancel = await getJson("/alerts", headers);
const stillActive = findRequired(afterCancel.items, (item) => item.id === seededAlert.id, "alert after confirmation cancel");
assertEqual(stillActive.state, "active", "cancel keeps durable alert active");
await chrome.evaluate((title) => {
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found after cancel");
button.click();
}, seededAlert.title);
await chrome.waitForText(["确认告警", seededAlert.id], "alert confirmation reopen");
await chrome.evaluate(() => {
const confirm = document.querySelector(".confirm-panel .confirm-primary");
if (!(confirm instanceof HTMLButtonElement)) throw new Error("alert confirmation submit button not found");
confirm.click();
});
await chrome.waitForText(["确认已由 Platform 持久化", "acknowledged"], "durable alert acknowledgement");
const alerts = await getJson("/alerts", headers);
const acknowledged = findRequired(alerts.items, (item) => item.id === seededAlert.id, "acknowledged capacity alert");
assertEqual(acknowledged.state, "acknowledged", "browser alert acknowledgement persisted");
assertNoForbiddenProjection(acknowledged, "acknowledged alert response");
return {
cancelPreservedState: stillActive.state,
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt"]),
forbiddenFragmentScan: "passed",
textSample: (await chrome.visibleText()).slice(0, 1200)
};
}
async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server) { async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server) {
await chrome.navigate(`${webUrl}/#/plugins`); await chrome.navigate(`${webUrl}/#/plugins`);
await chrome.waitForText(["插件市场", plugin.id, "查看详情"], "plugin lifecycle marketplace"); await chrome.waitForText(["插件市场", plugin.id, "查看详情"], "plugin lifecycle marketplace");
@@ -719,7 +644,7 @@ async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server)
} }
assertNoForbiddenProjection(installation, "browser plugin lifecycle response"); assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
return { return {
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "alertId"]), persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId"]),
forbiddenFragmentScan: "passed", forbiddenFragmentScan: "passed",
textSample: (await chrome.visibleText()).slice(0, 1200) textSample: (await chrome.visibleText()).slice(0, 1200)
}; };
-32
View File
@@ -10,9 +10,6 @@ import type {
AIInvocationResponse, AIInvocationResponse,
AIConfigDiffApprovalResponse, AIConfigDiffApprovalResponse,
AIConfigDiffListResponse, AIConfigDiffListResponse,
AlertListResponse,
AlertResponse,
AlertRetryResponse,
ApiErrorResponse, ApiErrorResponse,
ArtifactContentChunk, ArtifactContentChunk,
ArtifactDownloadReferenceResponse, ArtifactDownloadReferenceResponse,
@@ -63,8 +60,6 @@ import type {
MarketplacePluginResponse, MarketplacePluginResponse,
MarketplacePluginStateRequest, MarketplacePluginStateRequest,
PlatformResourceUsageResponse, PlatformResourceUsageResponse,
ProductionCapacitySummaryResponse,
CapacityAdmissionDecisionResponse,
PluginLifecycleActionRequest, PluginLifecycleActionRequest,
PluginLifecycleActionResponse, PluginLifecycleActionResponse,
PluginLifecycleListResponse, PluginLifecycleListResponse,
@@ -506,33 +501,6 @@ export class PlatformApiClient {
return this.request<PlatformResourceUsageResponse>("/metrics/platform"); return this.request<PlatformResourceUsageResponse>("/metrics/platform");
} }
async getProductionCapacity(): Promise<ProductionCapacitySummaryResponse> {
return this.request<ProductionCapacitySummaryResponse>("/production/capacity");
}
async checkCapacityAdmission(request: { serverInstanceId?: string; runEndpointId?: string; capability: string; targetKey?: string; idempotencyKey?: string }): Promise<CapacityAdmissionDecisionResponse> {
return this.request<CapacityAdmissionDecisionResponse>("/production/capacity/admission", { method: "POST", body: request });
}
async listAlerts(filter: { state?: string; sourceKind?: string; sourceId?: string; severity?: string } = {}): Promise<AlertListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<AlertListResponse>(`/alerts${query ? `?${query}` : ""}`);
}
async acknowledgeAlert(id: string, note = ""): Promise<AlertResponse> {
return this.request<AlertResponse>(`/alerts/${encodeURIComponent(id)}/acknowledge`, { method: "POST", body: { note } });
}
async resolveAlert(id: string, note = ""): Promise<AlertResponse> {
return this.request<AlertResponse>(`/alerts/${encodeURIComponent(id)}/resolve`, { method: "POST", body: { note } });
}
async retryAlert(id: string, idempotencyKey: string): Promise<AlertRetryResponse> {
return this.request<AlertRetryResponse>(`/alerts/${encodeURIComponent(id)}/retry`, { method: "POST", body: { idempotencyKey } });
}
async listPluginLifecycles(filter: { pluginId?: string; serverInstanceId?: string; currentState?: string } = {}): Promise<PluginLifecycleListResponse> { async listPluginLifecycles(filter: { pluginId?: string; serverInstanceId?: string; currentState?: string } = {}): Promise<PluginLifecycleListResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); }); Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
@@ -2,33 +2,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client"; import { PlatformApiClient } from "./client";
describe("PlatformApiClient production operations", () => { describe("PlatformApiClient plugin operations", () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
it("uses Platform-only operations routes and bounded request bodies", async () => { it("uses Platform-only operations routes and bounded request bodies", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = []; const calls: Array<{ url: string; method: string; body?: unknown }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); calls.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
return new Response(JSON.stringify({ items: [], count: 0, endpoints: [], totalMaxJobs: 0, totalRunningJobs: 0, totalQueuedJobs: 0, activeAlerts: 0, generatedAt: "2026-07-18T00:00:00Z", status: "queued", installation: {}, job: {}, decision: {}, alert: {} }), { status: 200, headers: { "Content-Type": "application/json" } }); return new Response(JSON.stringify({ items: [], count: 0, status: "queued", installation: {}, job: {} }), { status: 200, headers: { "Content-Type": "application/json" } });
})); }));
const client = new PlatformApiClient("/api/v1", () => "session-token"); const client = new PlatformApiClient("/api/v1", () => "session-token");
await client.getProductionCapacity();
await client.listAlerts({ state: "active" });
await client.acknowledgeAlert("alert-1", "reviewed");
await client.resolveAlert("alert-1", "resolved");
await client.retryAlert("alert-1", "retry-1");
await client.listPluginLifecycles({ pluginId: "game.scum" }); await client.listPluginLifecycles({ pluginId: "game.scum" });
await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false }); await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false });
await client.listAIConfigDiffs({ state: "pending" }); await client.listAIConfigDiffs({ state: "pending" });
await client.approveAIConfigDiff("diff-1", "approve-1"); await client.approveAIConfigDiff("diff-1", "approve-1");
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"GET /api/v1/production/capacity",
"GET /api/v1/alerts?state=active",
"POST /api/v1/alerts/alert-1/acknowledge",
"POST /api/v1/alerts/alert-1/resolve",
"POST /api/v1/alerts/alert-1/retry",
"GET /api/v1/plugin-lifecycles?pluginId=game.scum", "GET /api/v1/plugin-lifecycles?pluginId=game.scum",
"POST /api/v1/plugin-lifecycles/game.scum/actions", "POST /api/v1/plugin-lifecycles/game.scum/actions",
"GET /api/v1/ai/config-diffs?state=pending", "GET /api/v1/ai/config-diffs?state=pending",
@@ -36,6 +26,6 @@ describe("PlatformApiClient production operations", () => {
]); ]);
const serialized = JSON.stringify(calls); const serialized = JSON.stringify(calls);
expect(serialized).not.toMatch(/apiKey|token|secret|providerBaseUrl|runSocket|runEndpointUrl|hostPath|credential|dsn|rcon/i); expect(serialized).not.toMatch(/apiKey|token|secret|providerBaseUrl|runSocket|runEndpointUrl|hostPath|credential|dsn|rcon/i);
expect(calls[6]?.body).toEqual({ serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false }); expect(calls[1]?.body).toEqual({ serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false });
}); });
}); });
+1 -74
View File
@@ -1592,78 +1592,6 @@ export interface PluginProductionLifecycleDeclaration {
approvalRequired: Array<"disable" | "rollback" | "retire">; approvalRequired: Array<"disable" | "rollback" | "retire">;
} }
export type CapacityAdmissionState = "accepted" | "deferred" | "denied";
export interface CapacityAdmissionDecisionResponse {
accepted: boolean;
state: CapacityAdmissionState;
reason: string;
retryAfterSeconds?: number;
serverInstanceId?: string;
runEndpointId?: string;
capability: string;
targetKey?: string;
maxJobs: number;
runningJobs: number;
queuedJobs: number;
pressureCodes?: string[];
checkedAt: string;
alertId?: string;
}
export interface EndpointCapacityProjectionResponse {
runEndpointId: string;
displayName: string;
status: RunEndpointStatus;
capabilities: string[];
maxJobs: number;
runningJobs: number;
queuedJobs: number;
logBacklogBatches?: number;
artifactBacklogChunks?: number;
pressureCodes?: string[];
summary?: string;
lastHeartbeatAt: string;
lastAdmissionDecision?: CapacityAdmissionState;
lastAdmissionReason?: string;
lastAdmissionCheckedAt?: string;
}
export interface ProductionCapacitySummaryResponse {
endpoints: EndpointCapacityProjectionResponse[];
totalMaxJobs: number;
totalRunningJobs: number;
totalQueuedJobs: number;
activeAlerts: number;
generatedAt: string;
}
export type AlertState = "active" | "acknowledged" | "resolved";
export interface AlertResponse {
id: string;
sourceKind: string;
sourceId: string;
ruleKey: string;
severity: "info" | "warning" | "critical";
state: AlertState;
title: string;
message: string;
occurrenceCount: number;
retryable: boolean;
retryAfterSeconds?: number;
lastJobId?: string;
lastSeenAt: string;
acknowledgedBy?: string;
acknowledgedAt?: string;
resolvedBy?: string;
resolvedAt?: string;
resolutionNote?: string;
createdAt: string;
updatedAt: string;
}
export interface AlertListResponse { items: AlertResponse[]; count: number; }
export interface AlertRetryResponse { status: string; alert: AlertResponse; decision: CapacityAdmissionDecisionResponse; }
export type PluginLifecycleOperation = "install" | "enable" | "disable" | "upgrade" | "rollback" | "retire" | "dependency-check"; export type PluginLifecycleOperation = "install" | "enable" | "disable" | "upgrade" | "rollback" | "retire" | "dependency-check";
export interface PluginLifecycleInstallationResponse { export interface PluginLifecycleInstallationResponse {
id: string; id: string;
@@ -1678,14 +1606,13 @@ export interface PluginLifecycleInstallationResponse {
compatibility?: string; compatibility?: string;
dependencyState?: string; dependencyState?: string;
jobId?: string; jobId?: string;
alertId?: string;
failureReason?: string; failureReason?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface PluginLifecycleListResponse { items: PluginLifecycleInstallationResponse[]; count: number; } export interface PluginLifecycleListResponse { items: PluginLifecycleInstallationResponse[]; count: number; }
export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; confirmed: boolean; } export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; confirmed: boolean; }
export interface PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; decision: CapacityAdmissionDecisionResponse; alert?: AlertResponse; } export interface PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; }
export interface AIConfigDiffPreviewResponse { export interface AIConfigDiffPreviewResponse {
id: string; id: string;
+1 -1
View File
@@ -38,7 +38,7 @@ export function OperationsTray({ operations }: OperationsTrayProps) {
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite"> <div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
<div className="operations-tray-heading"> <div className="operations-tray-heading">
<strong></strong> <strong></strong>
<span> Platform </span> <span> Platform </span>
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="operations-tray-empty"></p> <p className="operations-tray-empty"></p>
@@ -63,7 +63,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`, idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`,
confirmed: disruptiveOperations.includes(operation) confirmed: disruptiveOperations.includes(operation)
}); });
const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · "); const evidence = [response.job?.id && `任务 ${response.job.id}`].filter(Boolean).join(" · ");
setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}${response.status}${evidence ? ` · ${evidence}` : ""}` }); setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}${response.status}${evidence ? ` · ${evidence}` : ""}` });
setConfirming(false); setConfirming(false);
await refresh(); await refresh();
@@ -102,7 +102,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
<div className="console-record-head"><strong>{installation.currentState} {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div> <div className="console-record-head"><strong>{installation.currentState} {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
<div className="console-record-meta"> <div className="console-record-meta">
<span> {installation.currentVersion || "--"}</span><span> {installation.targetVersion || "--"}</span><span> {installation.dependencyState || "unknown"}</span> <span> {installation.currentVersion || "--"}</span><span> {installation.targetVersion || "--"}</span><span> {installation.dependencyState || "unknown"}</span>
{installation.jobId && <span> {installation.jobId}</span>}{installation.alertId && <span> {installation.alertId}</span>} {installation.jobId && <span> {installation.jobId}</span>}
</div> </div>
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>} {installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
</div> </div>
@@ -2,21 +2,19 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel"; import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
import { ProductionOperationsPanel } from "./ProductionOperationsPanel"; import { PluginLifecycleWorkbench } from "./PluginLifecycleWorkbench";
import operationsSource from "./ProductionOperationsPanel.tsx?raw";
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw"; import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw"; import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
describe("production operations components", () => { describe("plugin operations components", () => {
it("renders persisted loading states without optimistic terminal success", () => { it("renders persisted loading states without optimistic terminal success", () => {
expect(renderToStaticMarkup(<ProductionOperationsPanel />)).toContain("正在同步容量与告警"); expect(renderToStaticMarkup(<PluginLifecycleWorkbench pluginId="game.example" pluginName="Example" />)).toContain("正在同步插件生命周期");
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异"); expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
for (const source of [operationsSource, lifecycleSource, diffSource]) { for (const source of [lifecycleSource, diffSource]) {
expect(source).not.toContain("setTimeout"); expect(source).not.toContain("setTimeout");
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i); expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
expect(source).toContain("disabled="); expect(source).toContain("disabled=");
} }
expect(operationsSource).toContain("if (!intent || busyKey) return");
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return"); expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
expect(diffSource).toContain("if (!selected || busyId) return"); expect(diffSource).toContain("if (!selected || busyId) return");
}); });
@@ -1,146 +0,0 @@
import { Activity, AlertTriangle, Check, CheckCheck, RotateCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AlertResponse, ProductionCapacitySummaryResponse } from "../api/types";
import { cx } from "../utils/classes";
import { ConfirmDialog } from "./OperationControls";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
type AlertAction = "acknowledge" | "resolve" | "retry";
interface ProductionOperationsPanelProps {
compact?: boolean;
title?: string;
}
export function ProductionOperationsPanel({ compact = false, title = "容量与告警" }: ProductionOperationsPanelProps) {
const [capacity, setCapacity] = useState<ProductionCapacitySummaryResponse | null>(null);
const [alerts, setAlerts] = useState<AlertResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [intent, setIntent] = useState<{ alert: AlertResponse; action: AlertAction } | null>(null);
const [busyKey, setBusyKey] = useState("");
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [capacityResponse, alertResponse] = await Promise.all([platformApiClient.getProductionCapacity(), platformApiClient.listAlerts()]);
setCapacity(capacityResponse);
setAlerts(alertResponse.items);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "生产状态加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function submitIntent() {
if (!intent || busyKey) return;
const key = `${intent.alert.id}:${intent.action}`;
setBusyKey(key);
setResult(null);
try {
if (intent.action === "acknowledge") {
await platformApiClient.acknowledgeAlert(intent.alert.id, "operator acknowledged from production console");
} else if (intent.action === "resolve") {
await platformApiClient.resolveAlert(intent.alert.id, "operator resolved after production review");
} else {
await platformApiClient.retryAlert(intent.alert.id, `web:alert.retry:${intent.alert.id}:${Date.now()}`);
}
setResult({ status: "succeeded", label: `${alertActionLabel(intent.action)}已由 Platform 持久化` });
setIntent(null);
await refresh();
} catch (caught) {
setResult({ status: "failed", label: caught instanceof Error ? caught.message : `${alertActionLabel(intent.action)}失败` });
setIntent(null);
} finally {
setBusyKey("");
}
}
const visibleAlerts = compact ? alerts.filter((alert) => alert.state !== "resolved").slice(0, 3) : alerts.slice(0, 12);
const visibleEndpoints = compact ? capacity?.endpoints.slice(0, 3) ?? [] : capacity?.endpoints ?? [];
return (
<section className="console-panel console-module production-operations-panel" aria-label="production capacity and alerts">
<div className="panel-header">
<h2><AlertTriangle size={16} /> {title}</h2>
<button type="button" className="icon-command" disabled={loading || Boolean(busyKey)} onClick={() => void refresh()} title="刷新容量与告警">
<RotateCw size={14} />
<span></span>
</button>
</div>
{result && <ResultBadge status={result.status} label={result.label} />}
{loading && <LoadingState label="正在同步容量与告警…" compact />}
{!loading && error && <ErrorState title="生产状态不可用" reason={error} diagnosticId="production-operations" onRetry={() => void refresh()} compact />}
{!loading && !error && capacity && (
<>
<dl className="console-stat-strip console-stat-strip-spaced">
<div><dt></dt><dd>{capacity.totalRunningJobs}/{capacity.totalMaxJobs}</dd></div>
<div><dt></dt><dd>{capacity.totalQueuedJobs}</dd></div>
<div><dt></dt><dd>{capacity.activeAlerts}</dd></div>
</dl>
<div className="console-row-list" aria-label="capacity endpoints">
{visibleEndpoints.map((endpoint) => (
<div key={endpoint.runEndpointId} className="console-row">
<span><strong>{endpoint.displayName}</strong><small>{endpoint.pressureCodes?.join(", ") || "capacity.available"}</small></span>
<span className={cx("status-pill", endpoint.pressureCodes?.length ? "status-warning" : `status-${endpoint.status}`)}>{endpoint.pressureCodes?.length ? "压力" : endpoint.status}</span>
<span>{endpoint.runningJobs}/{endpoint.maxJobs} · {endpoint.queuedJobs}</span>
</div>
))}
</div>
<div className="console-record-list console-record-list-spaced production-alert-list" aria-label="durable alerts">
{visibleAlerts.length === 0 && <p className="console-empty-note"></p>}
{visibleAlerts.map((alert) => {
const pending = busyKey.startsWith(`${alert.id}:`);
return (
<div key={alert.id} className="console-record operation-item">
<div className="console-record-head">
<strong>{alert.title}</strong>
<span className={cx("status-pill", alert.severity === "critical" ? "status-failed" : alert.state === "resolved" ? "status-succeeded" : "status-warning")}>{alert.state}</span>
</div>
<p>{alert.message}</p>
<div className="console-record-meta">
<span>{alert.sourceKind} · {alert.sourceId}</span>
<span> {alert.occurrenceCount} </span>
{alert.lastJobId && <span> {alert.lastJobId}</span>}
</div>
{alert.state !== "resolved" && (
<div className="row-actions console-row-actions production-alert-actions">
{alert.state === "active" && <button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "acknowledge" })}><Check size={14} /><span></span></button>}
<button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "resolve" })}><CheckCheck size={14} /><span></span></button>
{alert.retryable && <button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "retry" })}><Activity size={14} /><span></span></button>}
</div>
)}
</div>
);
})}
</div>
</>
)}
<ConfirmDialog
open={intent !== null}
title={intent ? `${alertActionLabel(intent.action)}告警` : "告警操作"}
description={intent ? `目标 ${intent.alert.id},仅处理来源 ${intent.alert.sourceKind}/${intent.alert.sourceId}` : "确认告警操作。"}
confirmLabel={intent ? alertActionLabel(intent.action) : "确认"}
danger={intent?.action === "resolve"}
busy={Boolean(busyKey)}
onCancel={() => { if (!busyKey) setIntent(null); }}
onConfirm={() => void submitIntent()}
/>
</section>
);
}
function alertActionLabel(action: AlertAction) {
if (action === "acknowledge") return "确认";
if (action === "resolve") return "解决";
return "重试来源";
}
-4
View File
@@ -24,7 +24,6 @@ import type {
ServerMetricsResponse ServerMetricsResponse
} from "../api/types"; } from "../api/types";
import { UsageMeter } from "../components/OperationControls"; import { UsageMeter } from "../components/OperationControls";
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole"; import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
import { jobCapabilityLabel } from "../contracts/jobPresentation"; import { jobCapabilityLabel } from "../contracts/jobPresentation";
@@ -278,9 +277,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
)} )}
</article> </article>
</section> </section>
<ProductionOperationsPanel compact title="生产容量与告警" />
<section className="overview-two-col"> <section className="overview-two-col">
<article className="console-panel" aria-label="resource usage"> <article className="console-panel" aria-label="resource usage">
<div className="panel-header"> <div className="panel-header">
-4
View File
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types"; import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
import { jobCapabilityLabel } from "../contracts/jobPresentation"; import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
@@ -126,9 +125,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
</section> </section>
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />} {triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
<ProductionOperationsPanel title="容量与告警闭环" />
<section className="console-panel" aria-label="run endpoints"> <section className="console-panel" aria-label="run endpoints">
<div className="panel-header"> <div className="panel-header">
<h2></h2> <h2></h2>
+1 -2
View File
@@ -10,11 +10,10 @@ function compact(value) {
} }
describe("platform web shared theme CSS", () => { describe("platform web shared theme CSS", () => {
it("keeps production lifecycle and alert controls bounded at narrow width", () => { it("keeps plugin lifecycle controls bounded at narrow width", () => {
const css = compact(readThemeCss()); const css = compact(readThemeCss());
expect(css).toContain("@media(max-width:640px){.plugin-lifecycle-controls{grid-template-columns:minmax(0,1fr)}"); expect(css).toContain("@media(max-width:640px){.plugin-lifecycle-controls{grid-template-columns:minmax(0,1fr)}");
expect(css).toContain(".production-alert-actions,.production-alert-actionsbutton{width:100%}");
expect(css).toContain("overflow-wrap:anywhere"); expect(css).toContain("overflow-wrap:anywhere");
}); });
+5 -7
View File
@@ -589,17 +589,15 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.console-row-actions .theme-upload,.maintenance-actions .theme-upload,.user-actions .theme-upload{min-height:30px} .console-row-actions .theme-upload,.maintenance-actions .theme-upload,.user-actions .theme-upload{min-height:30px}
.console-record-list,.operation-list{display:grid;gap:10px} .console-record-list,.operation-list{display:grid;gap:10px}
.console-record,.operation-item{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim);position:relative;overflow:hidden;min-width:0} .console-record,.operation-item{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim);position:relative;overflow:hidden;min-width:0}
.ai-diff-review-panel,.production-operations-panel{margin-block:14px} .ai-diff-review-panel{margin-block:14px}
.console-stat-strip-spaced,.production-capacity-strip{margin-bottom:12px} .console-stat-strip-spaced{margin-bottom:12px}
.console-record-list-spaced,.production-alert-list{margin-top:12px} .console-record-list-spaced{margin-top:12px}
.plugin-lifecycle-controls,.production-alert-actions{flex-wrap:wrap} .plugin-lifecycle-controls{flex-wrap:wrap}
.plugin-lifecycle-workbench{display:grid;gap:10px;padding-block:10px;border-block:1px solid color-mix(in srgb,var(--frame-accent) 34%,transparent)} .plugin-lifecycle-workbench{display:grid;gap:10px;padding-block:10px;border-block:1px solid color-mix(in srgb,var(--frame-accent) 34%,transparent)}
.plugin-lifecycle-controls{display:grid;grid-template-columns:minmax(180px,1.4fr) minmax(132px,0.8fr) minmax(120px,0.8fr) auto;align-items:center} .plugin-lifecycle-controls{display:grid;grid-template-columns:minmax(180px,1.4fr) minmax(132px,0.8fr) minmax(120px,0.8fr) auto;align-items:center}
.ai-config-proposal,.plugin-lifecycle-state{min-width:0} .ai-config-proposal,.plugin-lifecycle-state{min-width:0}
.ai-config-proposal{max-height:280px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere} .ai-config-proposal{max-height:280px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}
@media (max-width:640px){.plugin-lifecycle-controls{grid-template-columns:minmax(0,1fr)} @media (max-width:640px){.plugin-lifecycle-controls{grid-template-columns:minmax(0,1fr)}}
.production-alert-actions,.production-alert-actions button{width:100%}
}
.console-record-head,.operation-item-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap} .console-record-head,.operation-item-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.console-record-head>strong,.operation-item-head>strong{min-width:0;overflow-wrap:anywhere} .console-record-head>strong,.operation-item-head>strong{min-width:0;overflow-wrap:anywhere}
.console-record-head strong,.operation-item-head strong{color:var(--ink)} .console-record-head strong,.operation-item-head strong{color:var(--ink)}