Remove pre-1.0 production governance surfaces
This commit is contained in:
@@ -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:
|
||||
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -174,7 +174,7 @@ Most common edits:
|
||||
- Frontend API: `VITE_PLATFORM_API_BASE_URL=/api/v1`.
|
||||
- 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.
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -145,7 +145,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
// pluginLifecycles godoc
|
||||
// @Summary List server-bound plugin lifecycle state
|
||||
// @Description Lists durable plugin installation, desired/current state, compatibility, dependency, and job metadata.
|
||||
// @Tags production-operations
|
||||
// @Tags plugin-operations
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.PluginLifecycleListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
@@ -166,8 +166,8 @@ func (h *coreHandlers) pluginLifecycles(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// pluginLifecycleAction godoc
|
||||
// @Summary Dispatch a platform-mediated plugin lifecycle action
|
||||
// @Description Runs compatibility and capacity gates before creating one durable bounded Run job.
|
||||
// @Tags production-operations
|
||||
// @Description Validates manifest compatibility and creates one durable bounded Run job.
|
||||
// @Tags plugin-operations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pluginId path string true "Plugin ID"
|
||||
@@ -199,7 +199,7 @@ func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Requ
|
||||
// aiConfigDiffs godoc
|
||||
// @Summary List reviewable AI config diffs
|
||||
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
|
||||
// @Tags production-operations
|
||||
// @Tags plugin-operations
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.AIConfigDiffListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
@@ -221,7 +221,7 @@ func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
|
||||
// aiConfigDiffApprove godoc
|
||||
// @Summary Approve one reviewable AI config diff
|
||||
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
|
||||
// @Tags production-operations
|
||||
// @Tags plugin-operations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "AI config diff ID"
|
||||
|
||||
@@ -1519,7 +1519,9 @@ func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) {
|
||||
serverID := createRuntimeAPIFixtures(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 == "" {
|
||||
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)
|
||||
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
- `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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
|
||||
- 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.
|
||||
|
||||
## Core Service Boundary
|
||||
|
||||
@@ -13,7 +13,6 @@ Required model groups:
|
||||
- artifacts and chunks.
|
||||
- log streams and ingestion cursors.
|
||||
- operational events.
|
||||
- durable alerts and their acknowledgement/resolution metadata.
|
||||
- server-bound plugin lifecycle installations and linked jobs.
|
||||
- reviewable AI config diffs and approval fences.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -306,8 +306,8 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "production-operations.json")
|
||||
func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "plugin-operations.json")
|
||||
store, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create file store: %v", err)
|
||||
|
||||
@@ -114,9 +114,9 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if request.ServerInstanceID == "" {
|
||||
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)
|
||||
svc.productionMu.Unlock()
|
||||
svc.pluginOperationsMu.Unlock()
|
||||
if persistErr != nil {
|
||||
return domain.AIInvocationResponse{}, persistErr
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestCoreServiceDoesNotPrebindLegacyRunBeforeDistributionBuild(t *testing.T)
|
||||
|
||||
func TestCoreServiceDistributionBuildIgnoresStaleRunEndpoint(t *testing.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{
|
||||
ServerInstanceID: instance.ID,
|
||||
|
||||
@@ -284,7 +284,7 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
|
||||
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{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")
|
||||
}
|
||||
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
svc.pluginOperationsMu.Lock()
|
||||
defer svc.pluginOperationsMu.Unlock()
|
||||
installationID := pluginLifecycleInstallationID(request.PluginID, request.ServerInstanceID)
|
||||
installation, getErr := svc.store.PluginLifecycles().Get(installationID)
|
||||
if errors.Is(getErr, repo.ErrNotFound) {
|
||||
@@ -156,8 +156,8 @@ func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
svc.pluginOperationsMu.Lock()
|
||||
defer svc.pluginOperationsMu.Unlock()
|
||||
preview, err := svc.store.AIConfigDiffs().Get(request.DiffID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
return nil
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
svc.pluginOperationsMu.Lock()
|
||||
defer svc.pluginOperationsMu.Unlock()
|
||||
installation, err := svc.store.PluginLifecycles().Get(pluginLifecycleInstallationID(job.ExecutionInput.PluginID, job.ServerInstanceID))
|
||||
if err != nil {
|
||||
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) {
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
svc.pluginOperationsMu.Lock()
|
||||
defer svc.pluginOperationsMu.Unlock()
|
||||
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}
|
||||
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 {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if _, err := svc.store.PluginLifecycles().Get(installation.ID); errors.Is(err, repo.ErrNotFound) {
|
||||
err = svc.store.PluginLifecycles().Create(installation)
|
||||
} else if err == nil {
|
||||
err = svc.store.PluginLifecycles().Update(installation)
|
||||
_, getErr := svc.store.PluginLifecycles().Get(installation.ID)
|
||||
switch {
|
||||
case errors.Is(getErr, repo.ErrNotFound):
|
||||
getErr = svc.store.PluginLifecycles().Create(installation)
|
||||
case getErr == nil:
|
||||
getErr = svc.store.PluginLifecycles().Update(installation)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
if getErr != nil {
|
||||
return domain.PluginLifecycleResult{}, getErr
|
||||
}
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Status: "denied"}), nil
|
||||
}
|
||||
+4
-4
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
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"}
|
||||
first, err := svc.RunPluginLifecycleForSession(session, request)
|
||||
if err != nil {
|
||||
@@ -35,7 +35,7 @@ func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(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)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
@@ -73,7 +73,7 @@ func TestPluginLifecycleBridgeDispatchesBoundedJob(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"})
|
||||
if err != nil {
|
||||
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()
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
@@ -23,7 +23,10 @@ var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
const ServerDeletionForceConfirmation = "FORCE DELETE"
|
||||
const (
|
||||
ServerDeletionForceConfirmation = "FORCE DELETE"
|
||||
runHeartbeatStaleAfter = 2 * time.Minute
|
||||
)
|
||||
|
||||
type ForbiddenError struct {
|
||||
Reason string
|
||||
@@ -234,7 +237,7 @@ type CoreService struct {
|
||||
artifactTransfers map[string]domain.ArtifactTransferSession
|
||||
artifactPayloads map[string][]byte
|
||||
artifactTransferSeq uint64
|
||||
productionMu sync.Mutex
|
||||
pluginOperationsMu sync.Mutex
|
||||
sourceRCONCommands *sourceRCONCommandBroker
|
||||
aiProviderClient AIProviderClient
|
||||
secretEnvelope SecretEnvelope
|
||||
@@ -2654,7 +2657,14 @@ func (svc *CoreService) runEndpointHeartbeatCurrent(endpoint domain.RunEndpoint)
|
||||
if endpoint.LastHeartbeatAt.IsZero() {
|
||||
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 {
|
||||
|
||||
@@ -86,7 +86,7 @@ async function main() {
|
||||
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 evidence = {
|
||||
@@ -114,7 +114,7 @@ async function main() {
|
||||
logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])),
|
||||
artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])),
|
||||
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"]),
|
||||
production: productionSeed.apiProof
|
||||
pluginOperations: pluginSeed.apiProof
|
||||
},
|
||||
routes: [],
|
||||
safety: {
|
||||
@@ -132,7 +132,7 @@ async function main() {
|
||||
{
|
||||
name: "首页",
|
||||
hash: "#/home",
|
||||
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU", "生产容量与告警", "运行槽位"]
|
||||
markers: ["平台概览", "运营数据已同步", "game.example", "运行节点", "CPU"]
|
||||
},
|
||||
{
|
||||
name: "服务器管理",
|
||||
@@ -165,12 +165,12 @@ async function main() {
|
||||
{
|
||||
name: "AI 提供商管理",
|
||||
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: "系统维护",
|
||||
hash: "#/maintenance",
|
||||
markers: ["系统维护", "容量与告警闭环", "运行槽位", productionSeed.alert.title]
|
||||
markers: ["系统维护", "运行节点", "最近失败任务"]
|
||||
},
|
||||
{
|
||||
name: "服务器详情",
|
||||
@@ -202,10 +202,9 @@ async function main() {
|
||||
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),
|
||||
evidence.pluginInteractions = {
|
||||
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);
|
||||
@@ -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 lifecycle = await postJson(
|
||||
`/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)}`);
|
||||
}
|
||||
|
||||
const admission = await postJson(
|
||||
"/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),
|
||||
const [lifecycles, diffs] = await Promise.all([
|
||||
getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&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 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 })) {
|
||||
assertNoForbiddenProjection(value, `production seed ${label}`);
|
||||
for (const [label, value] of Object.entries({ installation, diff, aiInvocation })) {
|
||||
assertNoForbiddenProjection(value, `plugin operations seed ${label}`);
|
||||
}
|
||||
return {
|
||||
alert,
|
||||
diff,
|
||||
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"]),
|
||||
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) {
|
||||
await chrome.navigate(`${webUrl}/#/plugins`);
|
||||
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");
|
||||
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",
|
||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||
};
|
||||
|
||||
@@ -10,9 +10,6 @@ import type {
|
||||
AIInvocationResponse,
|
||||
AIConfigDiffApprovalResponse,
|
||||
AIConfigDiffListResponse,
|
||||
AlertListResponse,
|
||||
AlertResponse,
|
||||
AlertRetryResponse,
|
||||
ApiErrorResponse,
|
||||
ArtifactContentChunk,
|
||||
ArtifactDownloadReferenceResponse,
|
||||
@@ -63,8 +60,6 @@ import type {
|
||||
MarketplacePluginResponse,
|
||||
MarketplacePluginStateRequest,
|
||||
PlatformResourceUsageResponse,
|
||||
ProductionCapacitySummaryResponse,
|
||||
CapacityAdmissionDecisionResponse,
|
||||
PluginLifecycleActionRequest,
|
||||
PluginLifecycleActionResponse,
|
||||
PluginLifecycleListResponse,
|
||||
@@ -506,33 +501,6 @@ export class PlatformApiClient {
|
||||
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> {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
|
||||
|
||||
+3
-13
@@ -2,33 +2,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PlatformApiClient } from "./client";
|
||||
|
||||
describe("PlatformApiClient production operations", () => {
|
||||
describe("PlatformApiClient plugin operations", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("uses Platform-only operations routes and bounded request bodies", async () => {
|
||||
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||
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 });
|
||||
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");
|
||||
|
||||
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.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false });
|
||||
await client.listAIConfigDiffs({ state: "pending" });
|
||||
await client.approveAIConfigDiff("diff-1", "approve-1");
|
||||
|
||||
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",
|
||||
"POST /api/v1/plugin-lifecycles/game.scum/actions",
|
||||
"GET /api/v1/ai/config-diffs?state=pending",
|
||||
@@ -36,6 +26,6 @@ describe("PlatformApiClient production operations", () => {
|
||||
]);
|
||||
const serialized = JSON.stringify(calls);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1592,78 +1592,6 @@ export interface PluginProductionLifecycleDeclaration {
|
||||
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 interface PluginLifecycleInstallationResponse {
|
||||
id: string;
|
||||
@@ -1678,14 +1606,13 @@ export interface PluginLifecycleInstallationResponse {
|
||||
compatibility?: string;
|
||||
dependencyState?: string;
|
||||
jobId?: string;
|
||||
alertId?: string;
|
||||
failureReason?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface PluginLifecycleListResponse { items: PluginLifecycleInstallationResponse[]; count: number; }
|
||||
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 {
|
||||
id: string;
|
||||
|
||||
@@ -38,7 +38,7 @@ export function OperationsTray({ operations }: OperationsTrayProps) {
|
||||
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
|
||||
<div className="operations-tray-heading">
|
||||
<strong>当前浏览器会话</strong>
|
||||
<span>持久任务与告警记录以 Platform 页面为准</span>
|
||||
<span>持久任务记录以 Platform 页面为准</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<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()}`,
|
||||
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}` : ""}` });
|
||||
setConfirming(false);
|
||||
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-meta">
|
||||
<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>
|
||||
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
|
||||
</div>
|
||||
|
||||
+4
-6
@@ -2,21 +2,19 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
|
||||
import { ProductionOperationsPanel } from "./ProductionOperationsPanel";
|
||||
import operationsSource from "./ProductionOperationsPanel.tsx?raw";
|
||||
import { PluginLifecycleWorkbench } from "./PluginLifecycleWorkbench";
|
||||
import lifecycleSource from "./PluginLifecycleWorkbench.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", () => {
|
||||
expect(renderToStaticMarkup(<ProductionOperationsPanel />)).toContain("正在同步容量与告警");
|
||||
expect(renderToStaticMarkup(<PluginLifecycleWorkbench pluginId="game.example" pluginName="Example" />)).toContain("正在同步插件生命周期");
|
||||
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.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
||||
expect(source).toContain("disabled=");
|
||||
}
|
||||
expect(operationsSource).toContain("if (!intent || busyKey) return");
|
||||
expect(lifecycleSource).toContain("if (!selectedServerId || busy) 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 "重试来源";
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
ServerMetricsResponse
|
||||
} from "../api/types";
|
||||
import { UsageMeter } from "../components/OperationControls";
|
||||
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
@@ -278,9 +277,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<ProductionOperationsPanel compact title="生产容量与告警" />
|
||||
|
||||
<section className="overview-two-col">
|
||||
<article className="console-panel" aria-label="resource usage">
|
||||
<div className="panel-header">
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { cx } from "../utils/classes";
|
||||
@@ -126,9 +125,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
||||
</section>
|
||||
|
||||
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
||||
|
||||
<ProductionOperationsPanel title="容量与告警闭环" />
|
||||
|
||||
<section className="console-panel" aria-label="run endpoints">
|
||||
<div className="panel-header">
|
||||
<h2>运行节点</h2>
|
||||
|
||||
@@ -10,11 +10,10 @@ function compact(value) {
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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-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}
|
||||
.ai-diff-review-panel,.production-operations-panel{margin-block:14px}
|
||||
.console-stat-strip-spaced,.production-capacity-strip{margin-bottom:12px}
|
||||
.console-record-list-spaced,.production-alert-list{margin-top:12px}
|
||||
.plugin-lifecycle-controls,.production-alert-actions{flex-wrap:wrap}
|
||||
.ai-diff-review-panel{margin-block:14px}
|
||||
.console-stat-strip-spaced{margin-bottom:12px}
|
||||
.console-record-list-spaced{margin-top:12px}
|
||||
.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-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{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)}
|
||||
.production-alert-actions,.production-alert-actions button{width:100%}
|
||||
}
|
||||
@media (max-width:640px){.plugin-lifecycle-controls{grid-template-columns:minmax(0,1fr)}}
|
||||
.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{color:var(--ink)}
|
||||
|
||||
Reference in New Issue
Block a user