diff --git a/docs/API.md b/docs/API.md index 4e335ebe2..fbd28358c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -68,6 +68,27 @@ Check if Pulse is running. `GET /api/state` Returns the complete state of your infrastructure (Nodes, VMs, Containers, Storage, Alerts). This is the main endpoint used by the dashboard. +`GET /api/state/summary` +Returns a lightweight integration summary for external dashboards and checks. Requires `monitoring:read`. + +```json +{ + "activeAlerts": 1, + "nodes": 2, + "vms": 8, + "containers": 12, + "dockerHosts": [ + { + "name": "Docker Host", + "containers": 5, + "uptimeSeconds": 86400, + "cpuUsagePercent": 12.5 + } + ], + "lastUpdate": "2026-05-24T10:11:12Z" +} +``` + ### Simple Stats (HTML) `GET /simple-stats` Lightweight HTML status page for quick checks. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4a3999ac0..2fc9c38de 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1772,6 +1772,13 @@ a new API state machine, queue contract, or verification-accounting field. page-local activity stores or a second query vocabulary. 7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts That same shared API contract also owns the external resource `type`, canonical display name, and cluster identity published through `/api/resources` and `/api/state`; the websocket/state hydrate path must not emit legacy aliases or raw store labels once the unified resource contract has normalized them. + Lightweight integration polling uses the same canonical read-state boundary: + `GET /api/state/summary` is an authenticated `monitoring:read` route that + returns only aggregate counts, Docker host summaries, active-alert count, + and `lastUpdate`. It must stay derived from the unified read state plus the + alert snapshot, and must not expose `resources`, per-resource histories, + raw provider payloads, command data, or child arrays from the full + `/api/state` dashboard hydrate. Realtime `/api/state` and websocket `resources` snapshots must also collapse transient split host identities before broadcast: a Proxmox-node row and a host-agent row for the same machine must leave the API boundary as one diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 499f50394..3916b8a08 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -532,6 +532,11 @@ dashboard-specific overview, trend, or summary transport. `/api/state` and paying an avoidable `401`; once a local-login hint exists or the operator is on a protected route, that shared state probe remains the canonical runtime detector. + External dashboards and scripts that only need fleet counters must use + `/api/state/summary`, not the full `/api/state` hydrate. That summary route + must remain bounded to aggregate counts and Docker host rollups, with no + resource arrays, metrics history, connected-infrastructure list, or chart + payloads added to the response. That same app-shell performance boundary treats global commercial banners as non-hot-path UI. Retired self-hosted trial or managed-model acquisition surfaces must not be reintroduced into `frontend-modern/src/App.tsx` as diff --git a/internal/api/router_state_test.go b/internal/api/router_state_test.go index d6653a3c1..464ff6e32 100644 --- a/internal/api/router_state_test.go +++ b/internal/api/router_state_test.go @@ -162,6 +162,19 @@ func TestRouter_HandleStateSummary(t *testing.T) { assert.Equal(t, int64(3600), summary.DockerHosts[0].UptimeSeconds) assert.Equal(t, 12.5, summary.DockerHosts[0].CPUUsagePercent) } + + if rec.Body.Len() >= 5*1024 { + t.Fatalf("expected /api/state/summary body under 5 KiB, got %d bytes", rec.Body.Len()) + } + + var payload map[string]any + err = json.Unmarshal(rec.Body.Bytes(), &payload) + assert.NoError(t, err) + for _, key := range []string{"resources", "metrics", "performance", "stats", "connectedInfrastructure", "pveTagStyles", "pveTagColors"} { + if _, ok := payload[key]; ok { + t.Fatalf("expected %q to be omitted from /api/state/summary payload", key) + } + } } func TestBuildStateSummaryCountsSnapshotResources(t *testing.T) { diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index b7387f4ef..06038ec71 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -4510,6 +4510,37 @@ func TestStateRequiresMonitoringReadScope(t *testing.T) { } } +func TestStateSummaryRequiresAuthInAPIMode(t *testing.T) { + record := newTokenRecord(t, "state-summary-token-123.12345678", []string{config.ScopeMonitoringRead}, nil) + cfg := newTestConfigWithTokens(t, record) + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without auth, got %d", rec.Code) + } +} + +func TestStateSummaryRequiresMonitoringReadScope(t *testing.T) { + rawToken := "state-summary-scope-token-123.12345678" + record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) + cfg := newTestConfigWithTokens(t, record) + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil) + req.Header.Set("X-API-Token", rawToken) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for missing monitoring:read scope, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) { + t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String()) + } +} + func TestVerifyTemperatureSSHAllowsSetupToken(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")