Document state summary integration contract

This commit is contained in:
rcourtman 2026-06-29 17:39:11 +01:00
parent e103278885
commit 73208a2863
5 changed files with 77 additions and 0 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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")