From bd774932d5701f39ca2d048d84bebbcfb84cc69d Mon Sep 17 00:00:00 2001
From: ITWorx Pulse release export
Date: Thu, 3 Sep 2026 02:09:19 +0200
Subject: [PATCH] Publish ITWorx Pulse source
---
.dockerignore | 18 +
.editorconfig | 19 +
.env.example | 88 +
.gitattributes | 16 +
.gitea/workflows/public-validation.yml | 52 +
.gitignore | 77 +
CHANGELOG.md | 20 +
CONTRIBUTING.md | 22 +
LICENSE | 661 ++++
Makefile | 43 +
PACKAGE_VERSION | 1 +
PUBLIC_SOURCE_EXPORT.md | 5 +
PUBLIC_SOURCE_MANIFEST.json | 3066 +++++++++++++++++
README.md | 92 +
SECURITY.md | 19 +
apps/web/index.html | 14 +
apps/web/package.json | 35 +
apps/web/playwright.config.ts | 33 +
apps/web/public/pulse-icon.svg | 21 +
apps/web/src/AlertControlsPanel.tsx | 65 +
apps/web/src/AlertOperationsPanel.tsx | 96 +
apps/web/src/AlertRulesPage.tsx | 261 ++
apps/web/src/App.tsx | 807 +++++
apps/web/src/ApplicationPage.tsx | 47 +
apps/web/src/ArrayPage.tsx | 32 +
apps/web/src/CapacityPage.tsx | 52 +
apps/web/src/ContainerPage.tsx | 144 +
apps/web/src/DashboardEditor.tsx | 244 ++
apps/web/src/DashboardRuntimeWidget.tsx | 141 +
apps/web/src/DashboardTransfer.tsx | 50 +
apps/web/src/DashboardVariablesEditor.tsx | 41 +
apps/web/src/DiskPage.tsx | 15 +
apps/web/src/EventsPage.tsx | 110 +
apps/web/src/HostPage.tsx | 90 +
apps/web/src/IncidentPage.tsx | 46 +
apps/web/src/InventoryPage.tsx | 140 +
apps/web/src/MetricWidgets.tsx | 264 ++
apps/web/src/NetworkPage.tsx | 35 +
apps/web/src/NotFoundPage.tsx | 11 +
apps/web/src/OnboardingPage.tsx | 57 +
apps/web/src/OperationalSignalPath.tsx | 75 +
apps/web/src/PoolPage.tsx | 21 +
apps/web/src/ProcessPage.tsx | 35 +
apps/web/src/ServicePage.tsx | 184 +
apps/web/src/SharePage.tsx | 14 +
apps/web/src/SignIn.tsx | 43 +
apps/web/src/SourceStatusDetails.tsx | 29 +
apps/web/src/StoragePage.tsx | 50 +
apps/web/src/StorageVisuals.tsx | 17 +
apps/web/src/SystemStatusPage.tsx | 47 +
apps/web/src/TopologyPage.tsx | 100 +
apps/web/src/WidgetConfigDrawer.tsx | 103 +
apps/web/src/auth.ts | 130 +
apps/web/src/copy.ts | 881 +++++
apps/web/src/dashboardScope.ts | 24 +
apps/web/src/listQuery.ts | 15 +
apps/web/src/liveBuffer.ts | 168 +
apps/web/src/liveClient.ts | 308 ++
apps/web/src/locale.ts | 33 +
apps/web/src/main.tsx | 10 +
apps/web/src/metricClient.ts | 40 +
apps/web/src/overviewSignals.ts | 57 +
apps/web/src/presentation.ts | 181 +
apps/web/src/routes.ts | 15 +
apps/web/src/styles.css | 1478 ++++++++
apps/web/src/systemStatus.ts | 220 ++
apps/web/src/useLiveMetric.ts | 89 +
apps/web/src/useMetricQuery.ts | 23 +
apps/web/src/vite-env.d.ts | 1 +
apps/web/src/wallboardLayout.ts | 28 +
apps/web/tests/e2e/accessibility.spec.ts | 129 +
apps/web/tests/e2e/alert-workspace.spec.ts | 94 +
apps/web/tests/e2e/capacity-real.spec.ts | 26 +
apps/web/tests/e2e/core-visual-audit.spec.ts | 29 +
.../tests/e2e/dashboard-editor-polish.spec.ts | 91 +
.../tests/e2e/dashboard-real-sources.spec.ts | 40 +
apps/web/tests/e2e/event-timeline.spec.ts | 74 +
apps/web/tests/e2e/inventory-real.spec.ts | 30 +
apps/web/tests/e2e/large-lists.spec.ts | 104 +
.../tests/e2e/localized-alert-editor.spec.ts | 55 +
.../tests/e2e/management-workspace.spec.ts | 85 +
apps/web/tests/e2e/mobile-incident.spec.ts | 39 +
apps/web/tests/e2e/real-stack.spec.ts | 198 ++
.../web/tests/e2e/release-backup-real.spec.ts | 33 +
apps/web/tests/e2e/responsive-polish.spec.ts | 69 +
.../web/tests/e2e/service-states-real.spec.ts | 30 +
apps/web/tests/e2e/session-boundary.spec.ts | 23 +
apps/web/tests/e2e/sol-ultra.spec.ts | 130 +
.../e2e/source-status-presentation.spec.ts | 55 +
apps/web/tests/e2e/storage-real-stack.spec.ts | 32 +
apps/web/tests/e2e/wallboard-viewport.spec.ts | 62 +
apps/web/tests/setup.ts | 15 +
apps/web/tests/unit/AlertRulesPage.test.tsx | 126 +
apps/web/tests/unit/AppOverview.test.tsx | 269 ++
apps/web/tests/unit/ApplicationPage.test.tsx | 43 +
apps/web/tests/unit/ArrayPage.test.tsx | 24 +
apps/web/tests/unit/CapacityPage.test.tsx | 40 +
apps/web/tests/unit/ContainerPage.test.tsx | 48 +
apps/web/tests/unit/DashboardCache.test.tsx | 30 +
apps/web/tests/unit/EventsPage.test.tsx | 81 +
apps/web/tests/unit/InventoryPage.test.tsx | 52 +
apps/web/tests/unit/MetricWidgets.test.tsx | 81 +
apps/web/tests/unit/NetworkPage.test.tsx | 41 +
apps/web/tests/unit/OnboardingPage.test.tsx | 51 +
.../tests/unit/OperationalSignalPath.test.tsx | 56 +
apps/web/tests/unit/PoolPage.test.tsx | 25 +
apps/web/tests/unit/ServicePage.test.tsx | 60 +
.../tests/unit/SourceStatusDetails.test.tsx | 24 +
apps/web/tests/unit/StoragePage.test.tsx | 30 +
apps/web/tests/unit/StorageVisuals.test.tsx | 21 +
apps/web/tests/unit/auth.test.ts | 35 +
apps/web/tests/unit/dashboardScope.test.ts | 18 +
apps/web/tests/unit/liveClient.test.ts | 121 +
apps/web/tests/unit/locale.test.ts | 18 +
apps/web/tests/unit/overviewSignals.test.ts | 27 +
apps/web/tests/unit/presentation.test.ts | 53 +
apps/web/tests/unit/routing.test.tsx | 24 +
apps/web/tests/unit/systemStatus.test.ts | 112 +
apps/web/tests/unit/useLiveMetric.test.tsx | 54 +
apps/web/tests/unit/wallboardLayout.test.ts | 20 +
apps/web/tsconfig.json | 20 +
apps/web/vite.config.ts | 31 +
cmd/agent/agent.go | 372 ++
cmd/agent/agent_test.go | 543 +++
cmd/agent/main.go | 157 +
cmd/agent/store.go | 70 +
cmd/api/main.go | 569 +++
cmd/api/session_live_test.go | 38 +
cmd/api/source_health.go | 40 +
cmd/api/source_health_test.go | 61 +
cmd/migrate/main.go | 36 +
cmd/worker/main.go | 324 ++
cmd/worker/main_test.go | 123 +
config/README.md | 25 +
config/alerts/default-rules.example.json | 173 +
.../dashboards/default-overview.example.json | 331 ++
config/metrics/catalog.example.json | 397 +++
config/probes/probe.example.json | 23 +
deploy/IMAGE_DIGESTS.md | 67 +
deploy/agent.Dockerfile | 21 +
deploy/api.Dockerfile | 17 +
deploy/compose.dev.yaml | 19 +
deploy/compose.prod.yaml | 65 +
deploy/compose.real-source-smoke.yaml | 10 +
deploy/compose.server-smoke.yaml | 22 +
deploy/compose.smoke.yaml | 42 +
deploy/compose.yaml | 306 ++
deploy/healthcheck-heartbeat.sh | 63 +
deploy/migrate.Dockerfile | 13 +
deploy/nginx.conf | 87 +
deploy/postgres.Dockerfile | 37 +
deploy/pulse-entrypoint.sh | 18 +
deploy/smoke-fixture.Dockerfile | 12 +
deploy/verify-image-digests.sh | 84 +
deploy/web.Dockerfile | 20 +
deploy/worker.Dockerfile | 19 +
docs/PUBLIC_DEPLOYMENT.md | 46 +
docs/PUBLIC_SOURCE_BOUNDARY.md | 19 +
docs/REPOSITORY_BOUNDARY.md | 19 +
docs/architecture/ALERTING_AND_INCIDENTS.md | 185 +
docs/architecture/API_CONTRACT.md | 371 ++
docs/architecture/DATA_MODEL.md | 371 ++
docs/architecture/SECURITY_THREAT_MODEL.md | 248 ++
docs/architecture/SYSTEM_ARCHITECTURE.md | 240 ++
.../TELEMETRY_AND_QUERY_ENGINE.md | 168 +
docs/architecture/adr/0001-read-only-v1.md | 19 +
docs/architecture/adr/0002-go-react-stack.md | 17 +
.../adr/0003-prometheus-v1-history.md | 17 +
.../adr/0004-postgresql-domain-state.md | 18 +
.../adr/0005-docker-access-boundary.md | 17 +
docs/architecture/adr/0006-rest-websocket.md | 17 +
docs/architecture/adr/0007-localization.md | 17 +
.../architecture/adr/0008-stale-is-unknown.md | 18 +
.../adr/0009-upstream-dependency-baseline.md | 42 +
docs/engineering/BACKEND_STANDARDS.md | 95 +
docs/engineering/CI_PIPELINE.md | 36 +
docs/engineering/DEPENDENCIES.md | 35 +
docs/engineering/DEPENDENCY_POLICY.md | 59 +
docs/engineering/ENGINEERING_STANDARDS.md | 125 +
docs/engineering/FRONTEND_STANDARDS.md | 92 +
docs/engineering/PERFORMANCE_BUDGETS.md | 73 +
docs/engineering/QUALITY_GATES.md | 121 +
docs/engineering/TEST_STRATEGY.md | 137 +
docs/operations/BACKUP_RESTORE.md | 128 +
docs/operations/DEVELOPMENT_SETUP.md | 61 +
docs/operations/OBSERVABILITY_OF_PULSE.md | 87 +
.../WORKER_AGENT_HEALTHCHECK_CONTRACT.md | 118 +
docs/product/MONITORING_REQUIREMENTS.md | 94 +
docs/product/PRODUCT_REQUIREMENTS.md | 238 ++
docs/product/REQUIREMENTS_INDEX.md | 44 +
docs/product/SERVICE_MONITORING.md | 83 +
docs/product/STORAGE_MONITORING.md | 95 +
docs/product/UX_SPEC.md | 308 ++
docs/product/WIDGET_CATALOG.md | 179 +
fixtures/README.md | 16 +
fixtures/scenarios/array-degraded.json | 36 +
fixtures/scenarios/capacity-forecast.json | 22 +
.../scenarios/container-restart-loop.json | 65 +
fixtures/scenarios/database-restart.json | 37 +
fixtures/scenarios/disk-temperature.json | 60 +
.../scenarios/dns-outage-suppression.json | 44 +
fixtures/scenarios/healthy-baseline.json | 60 +
.../scenarios/pool-capacity-pressure.json | 42 +
fixtures/scenarios/pool-degraded-scrub.json | 21 +
fixtures/scenarios/probe-executor-cases.json | 33 +
fixtures/scenarios/probe-scale-300.json | 15 +
fixtures/scenarios/prometheus-stale.json | 50 +
.../service-down-container-running.json | 53 +
fixtures/scenarios/share-growth.json | 21 +
fixtures/scenarios/smart-warning.json | 37 +
fixtures/scenarios/storage-map-heatmap.json | 12 +
fixtures/scenarios/ups-on-battery.json | 52 +
fixtures/scenarios/websocket-slow-client.json | 43 +
go.mod | 19 +
go.sum | 34 +
go.work | 3 +
go.work.sum | 7 +
internal/agentprotocol/protocol.go | 42 +
internal/agentprotocol/protocol_test.go | 14 +
internal/agentsource/agentsource.go | 289 ++
internal/agentsource/application.go | 212 ++
internal/agentsource/application_test.go | 210 ++
internal/agentsource/health_test.go | 68 +
internal/agentsource/providers.go | 185 +
internal/agentsource/providers_test.go | 423 +++
internal/agentstore/contract.go | 104 +
internal/agentstore/postgres.go | 246 ++
.../agentstore/postgres_integration_test.go | 110 +
internal/agentstore/postgres_test.go | 218 ++
internal/alert/alerts.go | 104 +
internal/alert/grouping.go | 230 ++
internal/alert/grouping_test.go | 100 +
internal/alert/operations.go | 99 +
internal/alert/operations_integration_test.go | 71 +
internal/alert/operations_test.go | 26 +
internal/alert/repository.go | 357 ++
internal/alert/repository_integration_test.go | 87 +
internal/alert/state.go | 261 ++
internal/alert/state_repository.go | 360 ++
.../state_repository_integration_test.go | 171 +
internal/alert/state_test.go | 167 +
internal/alert/types.go | 423 +++
internal/alert/types_test.go | 87 +
internal/alertapi/handler.go | 293 ++
internal/alertapi/handler_test.go | 121 +
internal/alertcontrol/expiry.go | 18 +
internal/alertcontrol/repository.go | 342 ++
internal/alertcontrol/repository_test.go | 121 +
internal/alertcontrol/runner.go | 26 +
internal/alertcontrol/types.go | 245 ++
internal/alertcontrol/types_benchmark_test.go | 17 +
internal/alertcontrol/types_test.go | 54 +
internal/alertcontrolapi/handler.go | 252 ++
internal/alertcontrolapi/handler_test.go | 197 ++
internal/alertdefaults/seed.go | 111 +
internal/alertdefaults/seed.json | 172 +
.../alertdefaults/seed_integration_test.go | 55 +
internal/alertdefaults/seed_test.go | 167 +
internal/alertopsapi/handler.go | 202 ++
internal/alertopsapi/handler_test.go | 137 +
internal/alertworker/memory_store.go | 69 +
internal/alertworker/postgres_store.go | 89 +
.../postgres_store_integration_test.go | 57 +
internal/alertworker/worker.go | 273 ++
internal/alertworker/worker_test.go | 188 +
internal/application/types.go | 244 ++
internal/application/types_test.go | 81 +
internal/applicationapi/handler.go | 68 +
internal/applicationapi/handler_test.go | 46 +
internal/array/types.go | 398 +++
internal/array/types_test.go | 126 +
internal/arrayapi/handler.go | 52 +
internal/arrayapi/handler_test.go | 63 +
internal/audit/audit.go | 85 +
internal/audit/audit_test.go | 19 +
internal/auth/oidc.go | 293 ++
internal/auth/oidc_test.go | 227 ++
internal/auth/session.go | 208 ++
internal/auth/session_test.go | 197 ++
internal/authapi/fakeidp_test.go | 159 +
internal/authapi/flowstore.go | 116 +
internal/authapi/flowstore_test.go | 154 +
internal/authapi/handler.go | 347 ++
internal/authapi/handler_test.go | 578 ++++
internal/authapi/wiring_test.go | 72 +
internal/backup/manager.go | 573 +++
internal/backup/manager_integration_test.go | 225 ++
internal/backup/manager_test.go | 70 +
internal/backupapi/handler.go | 98 +
internal/backupapi/handler_test.go | 44 +
internal/buildinfo/buildinfo.go | 19 +
internal/buildinfo/buildinfo_test.go | 25 +
internal/config/config.go | 466 +++
internal/config/config_test.go | 259 ++
internal/container/types.go | 320 ++
internal/container/types_test.go | 117 +
internal/containerapi/handler.go | 85 +
internal/containerapi/handler_test.go | 57 +
internal/correlation/correlation.go | 43 +
internal/correlation/correlation_test.go | 39 +
internal/dashboard/document.go | 86 +
internal/dashboard/document_test.go | 32 +
internal/dashboard/errors.go | 14 +
.../immutability_integration_test.go | 42 +
internal/dashboard/repository.go | 284 ++
.../dashboard/repository_integration_test.go | 113 +
.../repository_scale_integration_test.go | 60 +
internal/dashboard/version.go | 24 +
internal/dashboardapi/handler.go | 407 +++
internal/dashboardapi/handler_test.go | 112 +
internal/database/database.go | 129 +
internal/database/database_test.go | 302 ++
.../database/migrations/0001_foundation.sql | 154 +
.../database/migrations/0002_inventory.sql | 36 +
.../0003_dashboard_immutability.sql | 11 +
.../migrations/0004_dashboard_revision.sql | 6 +
.../migrations/0005_services_probes.sql | 124 +
.../database/migrations/0006_alert_rules.sql | 41 +
.../0007_alert_evaluator_leases.sql | 6 +
.../database/migrations/0008_alert_state.sql | 41 +
.../migrations/0009_alert_hysteresis.sql | 8 +
.../migrations/0010_alert_controls.sql | 42 +
.../migrations/0011_alert_unacknowledge.sql | 8 +
.../migrations/0012_notifications.sql | 42 +
.../database/migrations/0013_incidents.sql | 48 +
.../migrations/0014_incident_notes.sql | 9 +
.../migrations/0015_entity_listing_index.sql | 1 +
.../migrations/0016_agent_snapshots.sql | 17 +
.../migrations/0017_worker_runtime.sql | 38 +
.../0018_inventory_read_indexes.sql | 16 +
.../migrations/0019_capacity_samples.sql | 14 +
...0020_service_certificate_history_index.sql | 2 +
internal/datasource/contracts.go | 234 ++
internal/datasource/contracts_test.go | 46 +
internal/discovery/jobs.go | 166 +
internal/discovery/jobs_test.go | 51 +
internal/discovery/postgres_store.go | 332 ++
.../postgres_store_integration_test.go | 99 +
internal/disk/performance.go | 187 +
internal/disk/performance_test.go | 52 +
internal/disk/smart.go | 187 +
internal/disk/smart_test.go | 55 +
internal/disk/types.go | 398 +++
internal/disk/types_test.go | 108 +
internal/diskapi/handler.go | 87 +
internal/diskapi/handler_test.go | 52 +
internal/eventapi/handler.go | 86 +
internal/eventapi/handler_test.go | 44 +
.../eventapi/postgres_integration_test.go | 61 +
internal/forecast/storage.go | 211 ++
internal/forecast/storage_integration_test.go | 70 +
internal/forecast/storage_test.go | 93 +
internal/forecast/types.go | 275 ++
internal/forecast/types_test.go | 86 +
internal/forecastapi/handler.go | 49 +
internal/forecastapi/handler_test.go | 61 +
internal/freshness/evaluator.go | 54 +
internal/freshness/evaluator_test.go | 40 +
internal/host/adapter.go | 38 +
internal/host/hardware.go | 300 ++
internal/host/hardware_test.go | 70 +
internal/host/types.go | 456 +++
internal/host/types_test.go | 108 +
internal/hostapi/handler.go | 46 +
internal/hostapi/handler_test.go | 51 +
internal/hostcollect/clock_linux.go | 46 +
internal/hostcollect/clock_other.go | 14 +
internal/hostcollect/collector.go | 350 ++
internal/hostcollect/collector_test.go | 432 +++
internal/hostcollect/cpu.go | 180 +
internal/hostcollect/errors.go | 10 +
internal/hostcollect/filesystem.go | 139 +
internal/hostcollect/loadavg.go | 36 +
internal/hostcollect/memory.go | 71 +
internal/hostcollect/network.go | 97 +
internal/hostcollect/parse_test.go | 199 ++
internal/hostcollect/process.go | 352 ++
internal/hostcollect/process_test.go | 261 ++
internal/hostcollect/procfs.go | 67 +
internal/hostcollect/statfs_linux.go | 39 +
internal/hostcollect/statfs_other.go | 11 +
.../testdata/proc-healthy/1/cmdline | Bin 0 -> 20 bytes
.../hostcollect/testdata/proc-healthy/1/stat | 1 +
.../testdata/proc-healthy/1/status | 11 +
.../testdata/proc-healthy/1234/cmdline | Bin 0 -> 75 bytes
.../testdata/proc-healthy/1234/stat | 1 +
.../testdata/proc-healthy/1234/status | 5 +
.../testdata/proc-healthy/2/cmdline | 0
.../hostcollect/testdata/proc-healthy/2/stat | 1 +
.../testdata/proc-healthy/2/status | 6 +
.../testdata/proc-healthy/3131/cmdline | Bin 0 -> 14 bytes
.../testdata/proc-healthy/3131/status | 2 +
.../testdata/proc-healthy/4567/cmdline | Bin 0 -> 29311 bytes
.../testdata/proc-healthy/4567/stat | 1 +
.../testdata/proc-healthy/4567/status | 5 +
.../testdata/proc-healthy/5555/stat | 1 +
.../testdata/proc-healthy/9999/stat | 1 +
.../hostcollect/testdata/proc-healthy/loadavg | 1 +
.../hostcollect/testdata/proc-healthy/meminfo | 16 +
.../hostcollect/testdata/proc-healthy/mounts | 12 +
.../hostcollect/testdata/proc-healthy/net/dev | 7 +
.../testdata/proc-healthy/self/stat | 1 +
.../hostcollect/testdata/proc-healthy/stat | 10 +
.../testdata/proc-healthy/sys/kernel/hostname | 1 +
.../proc-healthy/sys/kernel/osrelease | 1 +
.../hostcollect/testdata/proc-healthy/uptime | 1 +
.../hostcollect/testdata/proc-messy/loadavg | 1 +
.../hostcollect/testdata/proc-messy/meminfo | 10 +
.../hostcollect/testdata/proc-messy/mounts | 4 +
.../hostcollect/testdata/proc-messy/net/dev | 2 +
internal/hostcollect/testdata/proc-messy/stat | 6 +
.../testdata/proc-messy/sys/kernel/hostname | 1 +
.../hostcollect/testdata/proc-messy/uptime | 1 +
.../sys-healthy/class/net/br0/operstate | 1 +
.../sys-healthy/class/net/eth0/operstate | 1 +
internal/hostcollect/uptime.go | 34 +
internal/incident/owner_notes.go | 79 +
internal/incident/repository.go | 275 ++
.../incident/repository_integration_test.go | 86 +
internal/incident/types.go | 264 ++
internal/incident/types_test.go | 71 +
internal/incidentapi/handler.go | 246 ++
internal/incidentapi/handler_test.go | 105 +
internal/inventory/readmodel.go | 282 ++
internal/inventory/readmodel_test.go | 28 +
internal/inventory/repository.go | 301 ++
.../inventory/repository_integration_test.go | 303 ++
internal/inventory/types.go | 53 +
internal/inventoryapi/handler.go | 142 +
internal/inventoryapi/handler_test.go | 108 +
internal/lifecycle/types.go | 219 ++
internal/lifecycle/types_test.go | 50 +
internal/live/backpressure_test.go | 15 +
internal/live/live.go | 511 +++
internal/live/live_test.go | 222 ++
internal/live/registry.go | 243 ++
internal/live/registry_test.go | 185 +
internal/livesampler/sampler.go | 395 +++
internal/livesampler/sampler_test.go | 338 ++
internal/m7gate/close_test.go | 86 +
internal/metriccatalog/catalog.go | 318 ++
internal/metriccatalog/catalog_test.go | 122 +
internal/metriccatalog/seed.json | 446 +++
internal/metricquery/handler.go | 125 +
internal/metricquery/handler_test.go | 119 +
internal/metricquery/service.go | 325 ++
internal/metricquery/service_test.go | 257 ++
internal/metricsapi/handler.go | 33 +
internal/metricsapi/handler_test.go | 52 +
internal/network/provider.go | 55 +
internal/network/provider_test.go | 35 +
internal/network/types.go | 424 +++
internal/network/types_test.go | 104 +
internal/networkapi/handler.go | 43 +
internal/networkapi/handler_test.go | 49 +
internal/notification/dispatcher.go | 67 +
internal/notification/dispatcher_test.go | 60 +
internal/notification/repository.go | 463 +++
.../repository_integration_test.go | 249 ++
internal/notification/types.go | 216 ++
internal/notification/types_test.go | 78 +
internal/notification/webhook.go | 182 +
internal/notification/webhook_test.go | 144 +
internal/observability/metrics.go | 304 ++
internal/observability/metrics_test.go | 63 +
internal/onboarding/default-dashboard.json | 331 ++
.../onboarding/repository_integration_test.go | 56 +
internal/onboarding/service.go | 222 ++
internal/onboarding/service_test.go | 73 +
internal/onboarding/store.go | 53 +
internal/onboarding/types.go | 37 +
internal/onboardingapi/handler.go | 87 +
internal/onboardingapi/handler_test.go | 65 +
internal/pool/types.go | 537 +++
internal/pool/types_test.go | 135 +
internal/poolapi/handler.go | 87 +
internal/poolapi/handler_test.go | 54 +
internal/probe/executor.go | 266 ++
internal/probe/executor_test.go | 246 ++
internal/probe/policy.go | 325 ++
internal/probe/policy_test.go | 109 +
internal/probe/scenario_test.go | 52 +
internal/probe/scheduler.go | 308 ++
internal/probe/scheduler_test.go | 160 +
internal/probe/types.go | 92 +
internal/probe/types_test.go | 19 +
internal/problem/problem.go | 35 +
internal/problem/problem_test.go | 34 +
internal/process/types.go | 276 ++
internal/process/types_test.go | 89 +
internal/processapi/handler.go | 67 +
internal/processapi/handler_test.go | 51 +
internal/prometheus/client.go | 189 +
internal/prometheus/client_test.go | 135 +
internal/promqlbinding/binding.go | 180 +
internal/promqlbinding/binding_test.go | 143 +
internal/queryplan/planner.go | 256 ++
internal/queryplan/planner_test.go | 121 +
internal/reconciliation/container_identity.go | 209 ++
.../reconciliation/container_identity_test.go | 136 +
internal/reconciliation/engine.go | 103 +
internal/reconciliation/engine_test.go | 84 +
internal/redaction/redaction.go | 39 +
internal/redaction/redaction_test.go | 26 +
internal/reverseproxy/http_client.go | 133 +
internal/reverseproxy/http_client_test.go | 57 +
internal/reverseproxy/types.go | 433 +++
internal/reverseproxy/types_test.go | 95 +
internal/reverseproxyapi/handler.go | 57 +
internal/reverseproxyapi/handler_test.go | 56 +
internal/runtimeconfig/agent.go | 148 +
internal/runtimeconfig/agent_test.go | 144 +
internal/runtimeconfig/config.go | 91 +
internal/runtimeconfig/config_test.go | 28 +
internal/service/dependency.go | 135 +
internal/service/dependency_repository.go | 237 ++
.../dependency_repository_integration_test.go | 110 +
internal/service/dependency_test.go | 44 +
internal/service/health.go | 25 +
internal/service/health_test.go | 41 +
internal/service/postgres.go | 182 +
internal/service/postgres_integration_test.go | 89 +
internal/service/scenario_test.go | 52 +
internal/service/signal.go | 15 +
internal/service/signal_test.go | 25 +
internal/service/status.go | 411 +++
internal/service/status_test.go | 173 +
internal/service/topology.go | 150 +
.../service/topology_reverseproxy_test.go | 22 +
internal/service/topology_test.go | 87 +
internal/service/types.go | 69 +
internal/service/types_test.go | 18 +
internal/serviceapi/handler.go | 252 ++
internal/serviceapi/handler_test.go | 148 +
internal/servicedefaults/seed.go | 203 ++
.../servicedefaults/seed_integration_test.go | 64 +
internal/servicedefaults/seed_test.go | 54 +
internal/share/types.go | 382 ++
internal/share/types_test.go | 71 +
internal/shareapi/handler.go | 87 +
internal/shareapi/handler_test.go | 50 +
internal/storagescenarios/scenario_test.go | 107 +
internal/systemstatus/status.go | 418 +++
internal/systemstatus/status_test.go | 195 ++
internal/systemstatusapi/handler.go | 76 +
internal/systemstatusapi/handler_test.go | 45 +
internal/unraid/array.go | 198 ++
internal/unraid/array_test.go | 58 +
internal/unraid/client.go | 121 +
internal/unraid/client_test.go | 89 +
internal/unraid/containers.go | 111 +
internal/unraid/containers_test.go | 105 +
internal/unraid/pools.go | 69 +
internal/unraid/pools_test.go | 50 +
internal/unraid/shares.go | 73 +
internal/unraid/shares_test.go | 32 +
internal/widget/registry.go | 100 +
internal/widget/registry_test.go | 22 +
internal/widgetapi/handler.go | 68 +
internal/widgetapi/handler_test.go | 81 +
internal/widgetpreview/preview.go | 180 +
internal/widgetpreview/preview_test.go | 49 +
internal/workerruntime/alertjob.go | 515 +++
internal/workerruntime/discoveryjob.go | 620 ++++
.../inventory_discovery_integration_test.go | 105 +
internal/workerruntime/jobs_test.go | 704 ++++
internal/workerruntime/lease.go | 272 ++
internal/workerruntime/metricsource_test.go | 66 +
internal/workerruntime/notificationjob.go | 116 +
.../postgres_integration_test.go | 196 ++
internal/workerruntime/probejob.go | 304 ++
internal/workerruntime/runtime.go | 562 +++
internal/workerruntime/runtime_test.go | 525 +++
internal/workerruntime/schedule.go | 76 +
internal/workerruntime/status.go | 91 +
package.json | 24 +
pnpm-lock.yaml | 1818 ++++++++++
pnpm-workspace.yaml | 3 +
requirements-dev.txt | 1 +
scripts/bootstrap.ps1 | 13 +
scripts/build.ps1 | 16 +
scripts/export-public-source.mjs | 127 +
scripts/integration-smoke.ps1 | 175 +
scripts/lint.ps1 | 14 +
scripts/production-smoke.ps1 | 126 +
scripts/public-verify.ps1 | 36 +
scripts/run-trivy-fs-scan.sh | 39 +
scripts/test.ps1 | 14 +
scripts/validate-public-source.mjs | 41 +
scripts/wallboard-soak.ps1 | 71 +
specs/alert-rule-set.schema.json | 16 +
specs/alert-rule.schema.json | 171 +
specs/api-routes.json | 99 +
specs/capability.schema.json | 29 +
specs/dashboard.schema.json | 125 +
specs/entity.schema.json | 163 +
specs/event.schema.json | 84 +
specs/live-message.schema.json | 309 ++
specs/metric-catalog.schema.json | 16 +
specs/metric-definition.schema.json | 197 ++
specs/probe.schema.json | 131 +
specs/simulator-scenario.schema.json | 104 +
specs/task-ledger.schema.json | 95 +
specs/widget-instance.schema.json | 246 ++
tools/analyze-wallboard-soak.mjs | 75 +
tools/check_api_contract.py | 77 +
tools/check_secrets.py | 42 +
tools/check_wiring.py | 329 ++
tools/deadman_check.py | 47 +
tools/integrationfixture/main.go | 113 +
tools/validate_contracts.py | 88 +
tools/wallboard-soak-analysis.mjs | 89 +
tools/wallboard-soak.mjs | 280 ++
tools/wiring_allowlist.json | 4 +
614 files changed, 77116 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .editorconfig
create mode 100644 .env.example
create mode 100644 .gitattributes
create mode 100644 .gitea/workflows/public-validation.yml
create mode 100644 .gitignore
create mode 100644 CHANGELOG.md
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 Makefile
create mode 100644 PACKAGE_VERSION
create mode 100644 PUBLIC_SOURCE_EXPORT.md
create mode 100644 PUBLIC_SOURCE_MANIFEST.json
create mode 100644 README.md
create mode 100644 SECURITY.md
create mode 100644 apps/web/index.html
create mode 100644 apps/web/package.json
create mode 100644 apps/web/playwright.config.ts
create mode 100644 apps/web/public/pulse-icon.svg
create mode 100644 apps/web/src/AlertControlsPanel.tsx
create mode 100644 apps/web/src/AlertOperationsPanel.tsx
create mode 100644 apps/web/src/AlertRulesPage.tsx
create mode 100644 apps/web/src/App.tsx
create mode 100644 apps/web/src/ApplicationPage.tsx
create mode 100644 apps/web/src/ArrayPage.tsx
create mode 100644 apps/web/src/CapacityPage.tsx
create mode 100644 apps/web/src/ContainerPage.tsx
create mode 100644 apps/web/src/DashboardEditor.tsx
create mode 100644 apps/web/src/DashboardRuntimeWidget.tsx
create mode 100644 apps/web/src/DashboardTransfer.tsx
create mode 100644 apps/web/src/DashboardVariablesEditor.tsx
create mode 100644 apps/web/src/DiskPage.tsx
create mode 100644 apps/web/src/EventsPage.tsx
create mode 100644 apps/web/src/HostPage.tsx
create mode 100644 apps/web/src/IncidentPage.tsx
create mode 100644 apps/web/src/InventoryPage.tsx
create mode 100644 apps/web/src/MetricWidgets.tsx
create mode 100644 apps/web/src/NetworkPage.tsx
create mode 100644 apps/web/src/NotFoundPage.tsx
create mode 100644 apps/web/src/OnboardingPage.tsx
create mode 100644 apps/web/src/OperationalSignalPath.tsx
create mode 100644 apps/web/src/PoolPage.tsx
create mode 100644 apps/web/src/ProcessPage.tsx
create mode 100644 apps/web/src/ServicePage.tsx
create mode 100644 apps/web/src/SharePage.tsx
create mode 100644 apps/web/src/SignIn.tsx
create mode 100644 apps/web/src/SourceStatusDetails.tsx
create mode 100644 apps/web/src/StoragePage.tsx
create mode 100644 apps/web/src/StorageVisuals.tsx
create mode 100644 apps/web/src/SystemStatusPage.tsx
create mode 100644 apps/web/src/TopologyPage.tsx
create mode 100644 apps/web/src/WidgetConfigDrawer.tsx
create mode 100644 apps/web/src/auth.ts
create mode 100644 apps/web/src/copy.ts
create mode 100644 apps/web/src/dashboardScope.ts
create mode 100644 apps/web/src/listQuery.ts
create mode 100644 apps/web/src/liveBuffer.ts
create mode 100644 apps/web/src/liveClient.ts
create mode 100644 apps/web/src/locale.ts
create mode 100644 apps/web/src/main.tsx
create mode 100644 apps/web/src/metricClient.ts
create mode 100644 apps/web/src/overviewSignals.ts
create mode 100644 apps/web/src/presentation.ts
create mode 100644 apps/web/src/routes.ts
create mode 100644 apps/web/src/styles.css
create mode 100644 apps/web/src/systemStatus.ts
create mode 100644 apps/web/src/useLiveMetric.ts
create mode 100644 apps/web/src/useMetricQuery.ts
create mode 100644 apps/web/src/vite-env.d.ts
create mode 100644 apps/web/src/wallboardLayout.ts
create mode 100644 apps/web/tests/e2e/accessibility.spec.ts
create mode 100644 apps/web/tests/e2e/alert-workspace.spec.ts
create mode 100644 apps/web/tests/e2e/capacity-real.spec.ts
create mode 100644 apps/web/tests/e2e/core-visual-audit.spec.ts
create mode 100644 apps/web/tests/e2e/dashboard-editor-polish.spec.ts
create mode 100644 apps/web/tests/e2e/dashboard-real-sources.spec.ts
create mode 100644 apps/web/tests/e2e/event-timeline.spec.ts
create mode 100644 apps/web/tests/e2e/inventory-real.spec.ts
create mode 100644 apps/web/tests/e2e/large-lists.spec.ts
create mode 100644 apps/web/tests/e2e/localized-alert-editor.spec.ts
create mode 100644 apps/web/tests/e2e/management-workspace.spec.ts
create mode 100644 apps/web/tests/e2e/mobile-incident.spec.ts
create mode 100644 apps/web/tests/e2e/real-stack.spec.ts
create mode 100644 apps/web/tests/e2e/release-backup-real.spec.ts
create mode 100644 apps/web/tests/e2e/responsive-polish.spec.ts
create mode 100644 apps/web/tests/e2e/service-states-real.spec.ts
create mode 100644 apps/web/tests/e2e/session-boundary.spec.ts
create mode 100644 apps/web/tests/e2e/sol-ultra.spec.ts
create mode 100644 apps/web/tests/e2e/source-status-presentation.spec.ts
create mode 100644 apps/web/tests/e2e/storage-real-stack.spec.ts
create mode 100644 apps/web/tests/e2e/wallboard-viewport.spec.ts
create mode 100644 apps/web/tests/setup.ts
create mode 100644 apps/web/tests/unit/AlertRulesPage.test.tsx
create mode 100644 apps/web/tests/unit/AppOverview.test.tsx
create mode 100644 apps/web/tests/unit/ApplicationPage.test.tsx
create mode 100644 apps/web/tests/unit/ArrayPage.test.tsx
create mode 100644 apps/web/tests/unit/CapacityPage.test.tsx
create mode 100644 apps/web/tests/unit/ContainerPage.test.tsx
create mode 100644 apps/web/tests/unit/DashboardCache.test.tsx
create mode 100644 apps/web/tests/unit/EventsPage.test.tsx
create mode 100644 apps/web/tests/unit/InventoryPage.test.tsx
create mode 100644 apps/web/tests/unit/MetricWidgets.test.tsx
create mode 100644 apps/web/tests/unit/NetworkPage.test.tsx
create mode 100644 apps/web/tests/unit/OnboardingPage.test.tsx
create mode 100644 apps/web/tests/unit/OperationalSignalPath.test.tsx
create mode 100644 apps/web/tests/unit/PoolPage.test.tsx
create mode 100644 apps/web/tests/unit/ServicePage.test.tsx
create mode 100644 apps/web/tests/unit/SourceStatusDetails.test.tsx
create mode 100644 apps/web/tests/unit/StoragePage.test.tsx
create mode 100644 apps/web/tests/unit/StorageVisuals.test.tsx
create mode 100644 apps/web/tests/unit/auth.test.ts
create mode 100644 apps/web/tests/unit/dashboardScope.test.ts
create mode 100644 apps/web/tests/unit/liveClient.test.ts
create mode 100644 apps/web/tests/unit/locale.test.ts
create mode 100644 apps/web/tests/unit/overviewSignals.test.ts
create mode 100644 apps/web/tests/unit/presentation.test.ts
create mode 100644 apps/web/tests/unit/routing.test.tsx
create mode 100644 apps/web/tests/unit/systemStatus.test.ts
create mode 100644 apps/web/tests/unit/useLiveMetric.test.tsx
create mode 100644 apps/web/tests/unit/wallboardLayout.test.ts
create mode 100644 apps/web/tsconfig.json
create mode 100644 apps/web/vite.config.ts
create mode 100644 cmd/agent/agent.go
create mode 100644 cmd/agent/agent_test.go
create mode 100644 cmd/agent/main.go
create mode 100644 cmd/agent/store.go
create mode 100644 cmd/api/main.go
create mode 100644 cmd/api/session_live_test.go
create mode 100644 cmd/api/source_health.go
create mode 100644 cmd/api/source_health_test.go
create mode 100644 cmd/migrate/main.go
create mode 100644 cmd/worker/main.go
create mode 100644 cmd/worker/main_test.go
create mode 100644 config/README.md
create mode 100644 config/alerts/default-rules.example.json
create mode 100644 config/dashboards/default-overview.example.json
create mode 100644 config/metrics/catalog.example.json
create mode 100644 config/probes/probe.example.json
create mode 100644 deploy/IMAGE_DIGESTS.md
create mode 100644 deploy/agent.Dockerfile
create mode 100644 deploy/api.Dockerfile
create mode 100644 deploy/compose.dev.yaml
create mode 100644 deploy/compose.prod.yaml
create mode 100644 deploy/compose.real-source-smoke.yaml
create mode 100644 deploy/compose.server-smoke.yaml
create mode 100644 deploy/compose.smoke.yaml
create mode 100644 deploy/compose.yaml
create mode 100644 deploy/healthcheck-heartbeat.sh
create mode 100644 deploy/migrate.Dockerfile
create mode 100644 deploy/nginx.conf
create mode 100644 deploy/postgres.Dockerfile
create mode 100644 deploy/pulse-entrypoint.sh
create mode 100644 deploy/smoke-fixture.Dockerfile
create mode 100644 deploy/verify-image-digests.sh
create mode 100644 deploy/web.Dockerfile
create mode 100644 deploy/worker.Dockerfile
create mode 100644 docs/PUBLIC_DEPLOYMENT.md
create mode 100644 docs/PUBLIC_SOURCE_BOUNDARY.md
create mode 100644 docs/REPOSITORY_BOUNDARY.md
create mode 100644 docs/architecture/ALERTING_AND_INCIDENTS.md
create mode 100644 docs/architecture/API_CONTRACT.md
create mode 100644 docs/architecture/DATA_MODEL.md
create mode 100644 docs/architecture/SECURITY_THREAT_MODEL.md
create mode 100644 docs/architecture/SYSTEM_ARCHITECTURE.md
create mode 100644 docs/architecture/TELEMETRY_AND_QUERY_ENGINE.md
create mode 100644 docs/architecture/adr/0001-read-only-v1.md
create mode 100644 docs/architecture/adr/0002-go-react-stack.md
create mode 100644 docs/architecture/adr/0003-prometheus-v1-history.md
create mode 100644 docs/architecture/adr/0004-postgresql-domain-state.md
create mode 100644 docs/architecture/adr/0005-docker-access-boundary.md
create mode 100644 docs/architecture/adr/0006-rest-websocket.md
create mode 100644 docs/architecture/adr/0007-localization.md
create mode 100644 docs/architecture/adr/0008-stale-is-unknown.md
create mode 100644 docs/architecture/adr/0009-upstream-dependency-baseline.md
create mode 100644 docs/engineering/BACKEND_STANDARDS.md
create mode 100644 docs/engineering/CI_PIPELINE.md
create mode 100644 docs/engineering/DEPENDENCIES.md
create mode 100644 docs/engineering/DEPENDENCY_POLICY.md
create mode 100644 docs/engineering/ENGINEERING_STANDARDS.md
create mode 100644 docs/engineering/FRONTEND_STANDARDS.md
create mode 100644 docs/engineering/PERFORMANCE_BUDGETS.md
create mode 100644 docs/engineering/QUALITY_GATES.md
create mode 100644 docs/engineering/TEST_STRATEGY.md
create mode 100644 docs/operations/BACKUP_RESTORE.md
create mode 100644 docs/operations/DEVELOPMENT_SETUP.md
create mode 100644 docs/operations/OBSERVABILITY_OF_PULSE.md
create mode 100644 docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
create mode 100644 docs/product/MONITORING_REQUIREMENTS.md
create mode 100644 docs/product/PRODUCT_REQUIREMENTS.md
create mode 100644 docs/product/REQUIREMENTS_INDEX.md
create mode 100644 docs/product/SERVICE_MONITORING.md
create mode 100644 docs/product/STORAGE_MONITORING.md
create mode 100644 docs/product/UX_SPEC.md
create mode 100644 docs/product/WIDGET_CATALOG.md
create mode 100644 fixtures/README.md
create mode 100644 fixtures/scenarios/array-degraded.json
create mode 100644 fixtures/scenarios/capacity-forecast.json
create mode 100644 fixtures/scenarios/container-restart-loop.json
create mode 100644 fixtures/scenarios/database-restart.json
create mode 100644 fixtures/scenarios/disk-temperature.json
create mode 100644 fixtures/scenarios/dns-outage-suppression.json
create mode 100644 fixtures/scenarios/healthy-baseline.json
create mode 100644 fixtures/scenarios/pool-capacity-pressure.json
create mode 100644 fixtures/scenarios/pool-degraded-scrub.json
create mode 100644 fixtures/scenarios/probe-executor-cases.json
create mode 100644 fixtures/scenarios/probe-scale-300.json
create mode 100644 fixtures/scenarios/prometheus-stale.json
create mode 100644 fixtures/scenarios/service-down-container-running.json
create mode 100644 fixtures/scenarios/share-growth.json
create mode 100644 fixtures/scenarios/smart-warning.json
create mode 100644 fixtures/scenarios/storage-map-heatmap.json
create mode 100644 fixtures/scenarios/ups-on-battery.json
create mode 100644 fixtures/scenarios/websocket-slow-client.json
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 go.work
create mode 100644 go.work.sum
create mode 100644 internal/agentprotocol/protocol.go
create mode 100644 internal/agentprotocol/protocol_test.go
create mode 100644 internal/agentsource/agentsource.go
create mode 100644 internal/agentsource/application.go
create mode 100644 internal/agentsource/application_test.go
create mode 100644 internal/agentsource/health_test.go
create mode 100644 internal/agentsource/providers.go
create mode 100644 internal/agentsource/providers_test.go
create mode 100644 internal/agentstore/contract.go
create mode 100644 internal/agentstore/postgres.go
create mode 100644 internal/agentstore/postgres_integration_test.go
create mode 100644 internal/agentstore/postgres_test.go
create mode 100644 internal/alert/alerts.go
create mode 100644 internal/alert/grouping.go
create mode 100644 internal/alert/grouping_test.go
create mode 100644 internal/alert/operations.go
create mode 100644 internal/alert/operations_integration_test.go
create mode 100644 internal/alert/operations_test.go
create mode 100644 internal/alert/repository.go
create mode 100644 internal/alert/repository_integration_test.go
create mode 100644 internal/alert/state.go
create mode 100644 internal/alert/state_repository.go
create mode 100644 internal/alert/state_repository_integration_test.go
create mode 100644 internal/alert/state_test.go
create mode 100644 internal/alert/types.go
create mode 100644 internal/alert/types_test.go
create mode 100644 internal/alertapi/handler.go
create mode 100644 internal/alertapi/handler_test.go
create mode 100644 internal/alertcontrol/expiry.go
create mode 100644 internal/alertcontrol/repository.go
create mode 100644 internal/alertcontrol/repository_test.go
create mode 100644 internal/alertcontrol/runner.go
create mode 100644 internal/alertcontrol/types.go
create mode 100644 internal/alertcontrol/types_benchmark_test.go
create mode 100644 internal/alertcontrol/types_test.go
create mode 100644 internal/alertcontrolapi/handler.go
create mode 100644 internal/alertcontrolapi/handler_test.go
create mode 100644 internal/alertdefaults/seed.go
create mode 100644 internal/alertdefaults/seed.json
create mode 100644 internal/alertdefaults/seed_integration_test.go
create mode 100644 internal/alertdefaults/seed_test.go
create mode 100644 internal/alertopsapi/handler.go
create mode 100644 internal/alertopsapi/handler_test.go
create mode 100644 internal/alertworker/memory_store.go
create mode 100644 internal/alertworker/postgres_store.go
create mode 100644 internal/alertworker/postgres_store_integration_test.go
create mode 100644 internal/alertworker/worker.go
create mode 100644 internal/alertworker/worker_test.go
create mode 100644 internal/application/types.go
create mode 100644 internal/application/types_test.go
create mode 100644 internal/applicationapi/handler.go
create mode 100644 internal/applicationapi/handler_test.go
create mode 100644 internal/array/types.go
create mode 100644 internal/array/types_test.go
create mode 100644 internal/arrayapi/handler.go
create mode 100644 internal/arrayapi/handler_test.go
create mode 100644 internal/audit/audit.go
create mode 100644 internal/audit/audit_test.go
create mode 100644 internal/auth/oidc.go
create mode 100644 internal/auth/oidc_test.go
create mode 100644 internal/auth/session.go
create mode 100644 internal/auth/session_test.go
create mode 100644 internal/authapi/fakeidp_test.go
create mode 100644 internal/authapi/flowstore.go
create mode 100644 internal/authapi/flowstore_test.go
create mode 100644 internal/authapi/handler.go
create mode 100644 internal/authapi/handler_test.go
create mode 100644 internal/authapi/wiring_test.go
create mode 100644 internal/backup/manager.go
create mode 100644 internal/backup/manager_integration_test.go
create mode 100644 internal/backup/manager_test.go
create mode 100644 internal/backupapi/handler.go
create mode 100644 internal/backupapi/handler_test.go
create mode 100644 internal/buildinfo/buildinfo.go
create mode 100644 internal/buildinfo/buildinfo_test.go
create mode 100644 internal/config/config.go
create mode 100644 internal/config/config_test.go
create mode 100644 internal/container/types.go
create mode 100644 internal/container/types_test.go
create mode 100644 internal/containerapi/handler.go
create mode 100644 internal/containerapi/handler_test.go
create mode 100644 internal/correlation/correlation.go
create mode 100644 internal/correlation/correlation_test.go
create mode 100644 internal/dashboard/document.go
create mode 100644 internal/dashboard/document_test.go
create mode 100644 internal/dashboard/errors.go
create mode 100644 internal/dashboard/immutability_integration_test.go
create mode 100644 internal/dashboard/repository.go
create mode 100644 internal/dashboard/repository_integration_test.go
create mode 100644 internal/dashboard/repository_scale_integration_test.go
create mode 100644 internal/dashboard/version.go
create mode 100644 internal/dashboardapi/handler.go
create mode 100644 internal/dashboardapi/handler_test.go
create mode 100644 internal/database/database.go
create mode 100644 internal/database/database_test.go
create mode 100644 internal/database/migrations/0001_foundation.sql
create mode 100644 internal/database/migrations/0002_inventory.sql
create mode 100644 internal/database/migrations/0003_dashboard_immutability.sql
create mode 100644 internal/database/migrations/0004_dashboard_revision.sql
create mode 100644 internal/database/migrations/0005_services_probes.sql
create mode 100644 internal/database/migrations/0006_alert_rules.sql
create mode 100644 internal/database/migrations/0007_alert_evaluator_leases.sql
create mode 100644 internal/database/migrations/0008_alert_state.sql
create mode 100644 internal/database/migrations/0009_alert_hysteresis.sql
create mode 100644 internal/database/migrations/0010_alert_controls.sql
create mode 100644 internal/database/migrations/0011_alert_unacknowledge.sql
create mode 100644 internal/database/migrations/0012_notifications.sql
create mode 100644 internal/database/migrations/0013_incidents.sql
create mode 100644 internal/database/migrations/0014_incident_notes.sql
create mode 100644 internal/database/migrations/0015_entity_listing_index.sql
create mode 100644 internal/database/migrations/0016_agent_snapshots.sql
create mode 100644 internal/database/migrations/0017_worker_runtime.sql
create mode 100644 internal/database/migrations/0018_inventory_read_indexes.sql
create mode 100644 internal/database/migrations/0019_capacity_samples.sql
create mode 100644 internal/database/migrations/0020_service_certificate_history_index.sql
create mode 100644 internal/datasource/contracts.go
create mode 100644 internal/datasource/contracts_test.go
create mode 100644 internal/discovery/jobs.go
create mode 100644 internal/discovery/jobs_test.go
create mode 100644 internal/discovery/postgres_store.go
create mode 100644 internal/discovery/postgres_store_integration_test.go
create mode 100644 internal/disk/performance.go
create mode 100644 internal/disk/performance_test.go
create mode 100644 internal/disk/smart.go
create mode 100644 internal/disk/smart_test.go
create mode 100644 internal/disk/types.go
create mode 100644 internal/disk/types_test.go
create mode 100644 internal/diskapi/handler.go
create mode 100644 internal/diskapi/handler_test.go
create mode 100644 internal/eventapi/handler.go
create mode 100644 internal/eventapi/handler_test.go
create mode 100644 internal/eventapi/postgres_integration_test.go
create mode 100644 internal/forecast/storage.go
create mode 100644 internal/forecast/storage_integration_test.go
create mode 100644 internal/forecast/storage_test.go
create mode 100644 internal/forecast/types.go
create mode 100644 internal/forecast/types_test.go
create mode 100644 internal/forecastapi/handler.go
create mode 100644 internal/forecastapi/handler_test.go
create mode 100644 internal/freshness/evaluator.go
create mode 100644 internal/freshness/evaluator_test.go
create mode 100644 internal/host/adapter.go
create mode 100644 internal/host/hardware.go
create mode 100644 internal/host/hardware_test.go
create mode 100644 internal/host/types.go
create mode 100644 internal/host/types_test.go
create mode 100644 internal/hostapi/handler.go
create mode 100644 internal/hostapi/handler_test.go
create mode 100644 internal/hostcollect/clock_linux.go
create mode 100644 internal/hostcollect/clock_other.go
create mode 100644 internal/hostcollect/collector.go
create mode 100644 internal/hostcollect/collector_test.go
create mode 100644 internal/hostcollect/cpu.go
create mode 100644 internal/hostcollect/errors.go
create mode 100644 internal/hostcollect/filesystem.go
create mode 100644 internal/hostcollect/loadavg.go
create mode 100644 internal/hostcollect/memory.go
create mode 100644 internal/hostcollect/network.go
create mode 100644 internal/hostcollect/parse_test.go
create mode 100644 internal/hostcollect/process.go
create mode 100644 internal/hostcollect/process_test.go
create mode 100644 internal/hostcollect/procfs.go
create mode 100644 internal/hostcollect/statfs_linux.go
create mode 100644 internal/hostcollect/statfs_other.go
create mode 100644 internal/hostcollect/testdata/proc-healthy/1/cmdline
create mode 100644 internal/hostcollect/testdata/proc-healthy/1/stat
create mode 100644 internal/hostcollect/testdata/proc-healthy/1/status
create mode 100644 internal/hostcollect/testdata/proc-healthy/1234/cmdline
create mode 100644 internal/hostcollect/testdata/proc-healthy/1234/stat
create mode 100644 internal/hostcollect/testdata/proc-healthy/1234/status
create mode 100644 internal/hostcollect/testdata/proc-healthy/2/cmdline
create mode 100644 internal/hostcollect/testdata/proc-healthy/2/stat
create mode 100644 internal/hostcollect/testdata/proc-healthy/2/status
create mode 100644 internal/hostcollect/testdata/proc-healthy/3131/cmdline
create mode 100644 internal/hostcollect/testdata/proc-healthy/3131/status
create mode 100644 internal/hostcollect/testdata/proc-healthy/4567/cmdline
create mode 100644 internal/hostcollect/testdata/proc-healthy/4567/stat
create mode 100644 internal/hostcollect/testdata/proc-healthy/4567/status
create mode 100644 internal/hostcollect/testdata/proc-healthy/5555/stat
create mode 100644 internal/hostcollect/testdata/proc-healthy/9999/stat
create mode 100644 internal/hostcollect/testdata/proc-healthy/loadavg
create mode 100644 internal/hostcollect/testdata/proc-healthy/meminfo
create mode 100644 internal/hostcollect/testdata/proc-healthy/mounts
create mode 100644 internal/hostcollect/testdata/proc-healthy/net/dev
create mode 100644 internal/hostcollect/testdata/proc-healthy/self/stat
create mode 100644 internal/hostcollect/testdata/proc-healthy/stat
create mode 100644 internal/hostcollect/testdata/proc-healthy/sys/kernel/hostname
create mode 100644 internal/hostcollect/testdata/proc-healthy/sys/kernel/osrelease
create mode 100644 internal/hostcollect/testdata/proc-healthy/uptime
create mode 100644 internal/hostcollect/testdata/proc-messy/loadavg
create mode 100644 internal/hostcollect/testdata/proc-messy/meminfo
create mode 100644 internal/hostcollect/testdata/proc-messy/mounts
create mode 100644 internal/hostcollect/testdata/proc-messy/net/dev
create mode 100644 internal/hostcollect/testdata/proc-messy/stat
create mode 100644 internal/hostcollect/testdata/proc-messy/sys/kernel/hostname
create mode 100644 internal/hostcollect/testdata/proc-messy/uptime
create mode 100644 internal/hostcollect/testdata/sys-healthy/class/net/br0/operstate
create mode 100644 internal/hostcollect/testdata/sys-healthy/class/net/eth0/operstate
create mode 100644 internal/hostcollect/uptime.go
create mode 100644 internal/incident/owner_notes.go
create mode 100644 internal/incident/repository.go
create mode 100644 internal/incident/repository_integration_test.go
create mode 100644 internal/incident/types.go
create mode 100644 internal/incident/types_test.go
create mode 100644 internal/incidentapi/handler.go
create mode 100644 internal/incidentapi/handler_test.go
create mode 100644 internal/inventory/readmodel.go
create mode 100644 internal/inventory/readmodel_test.go
create mode 100644 internal/inventory/repository.go
create mode 100644 internal/inventory/repository_integration_test.go
create mode 100644 internal/inventory/types.go
create mode 100644 internal/inventoryapi/handler.go
create mode 100644 internal/inventoryapi/handler_test.go
create mode 100644 internal/lifecycle/types.go
create mode 100644 internal/lifecycle/types_test.go
create mode 100644 internal/live/backpressure_test.go
create mode 100644 internal/live/live.go
create mode 100644 internal/live/live_test.go
create mode 100644 internal/live/registry.go
create mode 100644 internal/live/registry_test.go
create mode 100644 internal/livesampler/sampler.go
create mode 100644 internal/livesampler/sampler_test.go
create mode 100644 internal/m7gate/close_test.go
create mode 100644 internal/metriccatalog/catalog.go
create mode 100644 internal/metriccatalog/catalog_test.go
create mode 100644 internal/metriccatalog/seed.json
create mode 100644 internal/metricquery/handler.go
create mode 100644 internal/metricquery/handler_test.go
create mode 100644 internal/metricquery/service.go
create mode 100644 internal/metricquery/service_test.go
create mode 100644 internal/metricsapi/handler.go
create mode 100644 internal/metricsapi/handler_test.go
create mode 100644 internal/network/provider.go
create mode 100644 internal/network/provider_test.go
create mode 100644 internal/network/types.go
create mode 100644 internal/network/types_test.go
create mode 100644 internal/networkapi/handler.go
create mode 100644 internal/networkapi/handler_test.go
create mode 100644 internal/notification/dispatcher.go
create mode 100644 internal/notification/dispatcher_test.go
create mode 100644 internal/notification/repository.go
create mode 100644 internal/notification/repository_integration_test.go
create mode 100644 internal/notification/types.go
create mode 100644 internal/notification/types_test.go
create mode 100644 internal/notification/webhook.go
create mode 100644 internal/notification/webhook_test.go
create mode 100644 internal/observability/metrics.go
create mode 100644 internal/observability/metrics_test.go
create mode 100644 internal/onboarding/default-dashboard.json
create mode 100644 internal/onboarding/repository_integration_test.go
create mode 100644 internal/onboarding/service.go
create mode 100644 internal/onboarding/service_test.go
create mode 100644 internal/onboarding/store.go
create mode 100644 internal/onboarding/types.go
create mode 100644 internal/onboardingapi/handler.go
create mode 100644 internal/onboardingapi/handler_test.go
create mode 100644 internal/pool/types.go
create mode 100644 internal/pool/types_test.go
create mode 100644 internal/poolapi/handler.go
create mode 100644 internal/poolapi/handler_test.go
create mode 100644 internal/probe/executor.go
create mode 100644 internal/probe/executor_test.go
create mode 100644 internal/probe/policy.go
create mode 100644 internal/probe/policy_test.go
create mode 100644 internal/probe/scenario_test.go
create mode 100644 internal/probe/scheduler.go
create mode 100644 internal/probe/scheduler_test.go
create mode 100644 internal/probe/types.go
create mode 100644 internal/probe/types_test.go
create mode 100644 internal/problem/problem.go
create mode 100644 internal/problem/problem_test.go
create mode 100644 internal/process/types.go
create mode 100644 internal/process/types_test.go
create mode 100644 internal/processapi/handler.go
create mode 100644 internal/processapi/handler_test.go
create mode 100644 internal/prometheus/client.go
create mode 100644 internal/prometheus/client_test.go
create mode 100644 internal/promqlbinding/binding.go
create mode 100644 internal/promqlbinding/binding_test.go
create mode 100644 internal/queryplan/planner.go
create mode 100644 internal/queryplan/planner_test.go
create mode 100644 internal/reconciliation/container_identity.go
create mode 100644 internal/reconciliation/container_identity_test.go
create mode 100644 internal/reconciliation/engine.go
create mode 100644 internal/reconciliation/engine_test.go
create mode 100644 internal/redaction/redaction.go
create mode 100644 internal/redaction/redaction_test.go
create mode 100644 internal/reverseproxy/http_client.go
create mode 100644 internal/reverseproxy/http_client_test.go
create mode 100644 internal/reverseproxy/types.go
create mode 100644 internal/reverseproxy/types_test.go
create mode 100644 internal/reverseproxyapi/handler.go
create mode 100644 internal/reverseproxyapi/handler_test.go
create mode 100644 internal/runtimeconfig/agent.go
create mode 100644 internal/runtimeconfig/agent_test.go
create mode 100644 internal/runtimeconfig/config.go
create mode 100644 internal/runtimeconfig/config_test.go
create mode 100644 internal/service/dependency.go
create mode 100644 internal/service/dependency_repository.go
create mode 100644 internal/service/dependency_repository_integration_test.go
create mode 100644 internal/service/dependency_test.go
create mode 100644 internal/service/health.go
create mode 100644 internal/service/health_test.go
create mode 100644 internal/service/postgres.go
create mode 100644 internal/service/postgres_integration_test.go
create mode 100644 internal/service/scenario_test.go
create mode 100644 internal/service/signal.go
create mode 100644 internal/service/signal_test.go
create mode 100644 internal/service/status.go
create mode 100644 internal/service/status_test.go
create mode 100644 internal/service/topology.go
create mode 100644 internal/service/topology_reverseproxy_test.go
create mode 100644 internal/service/topology_test.go
create mode 100644 internal/service/types.go
create mode 100644 internal/service/types_test.go
create mode 100644 internal/serviceapi/handler.go
create mode 100644 internal/serviceapi/handler_test.go
create mode 100644 internal/servicedefaults/seed.go
create mode 100644 internal/servicedefaults/seed_integration_test.go
create mode 100644 internal/servicedefaults/seed_test.go
create mode 100644 internal/share/types.go
create mode 100644 internal/share/types_test.go
create mode 100644 internal/shareapi/handler.go
create mode 100644 internal/shareapi/handler_test.go
create mode 100644 internal/storagescenarios/scenario_test.go
create mode 100644 internal/systemstatus/status.go
create mode 100644 internal/systemstatus/status_test.go
create mode 100644 internal/systemstatusapi/handler.go
create mode 100644 internal/systemstatusapi/handler_test.go
create mode 100644 internal/unraid/array.go
create mode 100644 internal/unraid/array_test.go
create mode 100644 internal/unraid/client.go
create mode 100644 internal/unraid/client_test.go
create mode 100644 internal/unraid/containers.go
create mode 100644 internal/unraid/containers_test.go
create mode 100644 internal/unraid/pools.go
create mode 100644 internal/unraid/pools_test.go
create mode 100644 internal/unraid/shares.go
create mode 100644 internal/unraid/shares_test.go
create mode 100644 internal/widget/registry.go
create mode 100644 internal/widget/registry_test.go
create mode 100644 internal/widgetapi/handler.go
create mode 100644 internal/widgetapi/handler_test.go
create mode 100644 internal/widgetpreview/preview.go
create mode 100644 internal/widgetpreview/preview_test.go
create mode 100644 internal/workerruntime/alertjob.go
create mode 100644 internal/workerruntime/discoveryjob.go
create mode 100644 internal/workerruntime/inventory_discovery_integration_test.go
create mode 100644 internal/workerruntime/jobs_test.go
create mode 100644 internal/workerruntime/lease.go
create mode 100644 internal/workerruntime/metricsource_test.go
create mode 100644 internal/workerruntime/notificationjob.go
create mode 100644 internal/workerruntime/postgres_integration_test.go
create mode 100644 internal/workerruntime/probejob.go
create mode 100644 internal/workerruntime/runtime.go
create mode 100644 internal/workerruntime/runtime_test.go
create mode 100644 internal/workerruntime/schedule.go
create mode 100644 internal/workerruntime/status.go
create mode 100644 package.json
create mode 100644 pnpm-lock.yaml
create mode 100644 pnpm-workspace.yaml
create mode 100644 requirements-dev.txt
create mode 100644 scripts/bootstrap.ps1
create mode 100644 scripts/build.ps1
create mode 100644 scripts/export-public-source.mjs
create mode 100644 scripts/integration-smoke.ps1
create mode 100644 scripts/lint.ps1
create mode 100644 scripts/production-smoke.ps1
create mode 100644 scripts/public-verify.ps1
create mode 100644 scripts/run-trivy-fs-scan.sh
create mode 100644 scripts/test.ps1
create mode 100644 scripts/validate-public-source.mjs
create mode 100644 scripts/wallboard-soak.ps1
create mode 100644 specs/alert-rule-set.schema.json
create mode 100644 specs/alert-rule.schema.json
create mode 100644 specs/api-routes.json
create mode 100644 specs/capability.schema.json
create mode 100644 specs/dashboard.schema.json
create mode 100644 specs/entity.schema.json
create mode 100644 specs/event.schema.json
create mode 100644 specs/live-message.schema.json
create mode 100644 specs/metric-catalog.schema.json
create mode 100644 specs/metric-definition.schema.json
create mode 100644 specs/probe.schema.json
create mode 100644 specs/simulator-scenario.schema.json
create mode 100644 specs/task-ledger.schema.json
create mode 100644 specs/widget-instance.schema.json
create mode 100644 tools/analyze-wallboard-soak.mjs
create mode 100644 tools/check_api_contract.py
create mode 100644 tools/check_secrets.py
create mode 100644 tools/check_wiring.py
create mode 100644 tools/deadman_check.py
create mode 100644 tools/integrationfixture/main.go
create mode 100644 tools/validate_contracts.py
create mode 100644 tools/wallboard-soak-analysis.mjs
create mode 100644 tools/wallboard-soak.mjs
create mode 100644 tools/wiring_allowlist.json
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..441ff33
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+.git
+.codex
+.agents
+node_modules
+**/node_modules
+dist
+**/dist
+bin
+tmp
+coverage
+artifacts/evidence
+backups
+.env
+.env.*
+!.env.example
+*.log
+*.sqlite
+*.db
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..c923d3c
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,19 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 2
+
+[*.go]
+indent_style = tab
+indent_size = 4
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1eefbef
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,88 @@
+# General
+PULSE_ENV=development
+PULSE_TIMEZONE=Europe/Brussels
+PULSE_DEFAULT_LOCALE=nl-BE
+PULSE_LOG_LEVEL=info
+PULSE_AUTH_MODE=mock
+
+# Public URL — select only after M0 conflict/discovery checks
+PULSE_PUBLIC_URL=http://localhost:8080
+
+# PostgreSQL
+PULSE_DATABASE_URL=postgres://pulse:pulse-dev-only@postgres:5432/pulse?sslmode=disable
+
+# Metrics source
+PULSE_PROMETHEUS_URL=http://prometheus:9090
+PULSE_PROMETHEUS_TIMEOUT=10s
+
+# Unraid — do not commit real tokens
+PULSE_UNRAID_URL=https://unraid.example.invalid
+PULSE_UNRAID_API_TOKEN=
+# Production mounts this public certificate read-only; never put a private key here.
+PULSE_UNRAID_CA_FILE_HOST=/path/to/unraid-ca.pem
+# DNS name and address of the same Unraid host as used by PULSE_UNRAID_URL.
+# Required by deploy/compose.prod.yaml; discover them instead of assuming host-gateway.
+PULSE_UNRAID_HOST_NAME=unraid.example.test
+PULSE_UNRAID_HOST_GATEWAY=192.0.2.10
+
+# OIDC / Authentik
+# Required when PULSE_AUTH_MODE=oidc; development may use the explicit mock mode above.
+PULSE_OIDC_ISSUER=https://auth.example.invalid/application/o/pulse/
+PULSE_OIDC_CLIENT_ID=pulse
+PULSE_OIDC_CLIENT_SECRET=
+PULSE_OIDC_REDIRECT_URL=http://localhost:8080/auth/callback
+# ID token claim carrying the group memberships used for role mapping.
+PULSE_OIDC_GROUPS_CLAIM=groups
+# Maps identity provider group claim values onto Pulse roles. Required in production:
+# without it no identity can be granted a role and nobody can sign in.
+# Roles: viewer, operator, editor, administrator.
+PULSE_OIDC_ROLE_MAPPING=pulse-viewer=viewer,pulse-operator=operator,pulse-editor=editor,pulse-admin=administrator
+
+# Break-glass account must be disabled unless explicitly initialized securely
+PULSE_BREAK_GLASS_ENABLED=false
+
+# pulse-agent — read-only host collector
+# Identifies the agent in every snapshot and in the protocol hello.
+PULSE_AGENT_ID=pulse-agent
+# How often a full collection pass runs (1s–5m). The scheduling loop ticks faster when
+# this is larger, so the heartbeat stays inside the healthcheck window.
+PULSE_AGENT_COLLECT_INTERVAL=10s
+# Read-only mounts of the host's kernel interfaces; compose bind mounts /proc and /sys.
+PULSE_AGENT_PROC_ROOT=/host/proc
+PULSE_AGENT_SYS_ROOT=/host/sys
+# The host name as it should appear in Pulse. Inside a container the kernel reports the
+# container's own name, so set this explicitly (for example: unraid-host).
+PULSE_AGENT_HOST_NAME=
+# Filesystem capacity and inode collection is off unless a host root is mounted
+# read-only and named here (for example /host/root together with "- /:/host/root:ro").
+# Without it the agent reports no filesystems rather than measuring its own overlay.
+PULSE_AGENT_FS_ROOT=
+# Optional cap on the process inventory (1–5000); empty uses the domain default of 1000.
+PULSE_AGENT_MAX_PROCESSES=
+
+# Liveness heartbeat file written by pulse-worker and pulse-agent after every completed
+# loop iteration. See docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md.
+PULSE_HEARTBEAT_FILE=/tmp/healthy
+
+# pulse-worker — background runtime
+# data_sources UUID container discovery is attributed to. Discovery stays Disabled
+# until a source is registered, so inventory is never written against an unknown origin.
+PULSE_CONTAINER_SOURCE_ID=
+# Private/loopback CIDRs service probes may reach, comma separated (max 32).
+# Empty keeps all private space blocked. Link-local, multicast and cloud metadata
+# addresses stay blocked regardless of this value.
+PULSE_PROBE_ALLOWED_NETWORKS=
+
+# Optional real notification receiver. The URL is stored as non-secret channel
+# configuration; the bearer token remains runtime-only and is never persisted.
+# Production requires HTTPS. The receiver should deduplicate by Idempotency-Key.
+PULSE_NOTIFICATION_WEBHOOK_URL=
+PULSE_NOTIFICATION_WEBHOOK_TOKEN=
+PULSE_NOTIFICATION_WEBHOOK_TIMEOUT=10s
+
+# Production deployment (deploy/compose.prod.yaml)
+# Host port pulse-web is published on. See ADR-0011.
+PULSE_HOST_PORT=1238
+# Bind address for that port. Use 127.0.0.1 only if Nginx Proxy Manager reaches
+# Pulse over a shared Docker network rather than over the host.
+PULSE_PUBLISH_ADDRESS=0.0.0.0
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..512c0cf
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,16 @@
+* text=auto eol=lf
+*.ps1 text eol=crlf
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.webp binary
+*.zip binary
+
+# Keep release/source archives focused on product source and public documentation.
+/.agents export-ignore
+/.codex export-ignore
+/MASTER_PROMPT.txt export-ignore
+/planning export-ignore
+/artifacts/evidence export-ignore
+/AUDIT.md export-ignore
+/PACKAGE_REPORT.md export-ignore
diff --git a/.gitea/workflows/public-validation.yml b/.gitea/workflows/public-validation.yml
new file mode 100644
index 0000000..6d16da4
--- /dev/null
+++ b/.gitea/workflows/public-validation.yml
@@ -0,0 +1,52 @@
+name: Public source validation
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: public-validation-${{ gitea.repository }}-${{ gitea.event_name }}-${{ gitea.ref }}
+ cancel-in-progress: true
+
+jobs:
+ validate:
+ if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
+ with:
+ go-version-file: go.mod
+ cache: true
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '24'
+ cache: pnpm
+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ with:
+ version: '10.33.0'
+ - run: go test ./... && go vet ./...
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm test && pnpm typecheck && pnpm lint && pnpm build
+ - name: Python contract and repository checks
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 -m venv "$RUNNER_TEMP/pulse-validation"
+ validation_python="$RUNNER_TEMP/pulse-validation/bin/python"
+ "$validation_python" -m pip install --disable-pip-version-check -r requirements-dev.txt
+ "$validation_python" tools/check_api_contract.py
+ "$validation_python" tools/validate_contracts.py
+ "$validation_python" tools/check_wiring.py
+ "$validation_python" tools/check_secrets.py
+ - run: bash deploy/verify-image-digests.sh
+ - name: Export reviewed public source
+ run: node scripts/export-public-source.mjs --output "$RUNNER_TEMP/pulse-public"
+ - name: Validate exported source manifest
+ run: cd "$RUNNER_TEMP/pulse-public" && node scripts/validate-public-source.mjs
+ - name: Dependency scan
+ run: sh scripts/run-trivy-fs-scan.sh "$RUNNER_TEMP/pulse-public"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a780d16
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,77 @@
+# Secrets and local configuration
+.env
+.env.*
+!.env.example
+*.pem
+*.key
+*.pfx
+*.p12
+*.crt
+secrets/
+credentials/
+
+# Machine-local agent execution policy (tracked agent docs/skills remain intentional)
+.codex/config.toml
+.codex/config.local.toml
+.claude/
+
+# IDE / OS
+.vscode/
+.idea/
+.vs/
+.DS_Store
+Thumbs.db
+*.swp
+
+# Node / frontend
+node_modules/
+coverage/
+playwright-report/
+test-results/
+.next/
+dist/
+.npm/
+.pnpm-store/
+
+# Go
+bin/
+*.test
+*.out
+vendor/
+
+# Python
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.venv/
+
+# Databases / runtime
+*.db
+*.db-shm
+*.db-wal
+*.sqlite
+*.sqlite-shm
+*.sqlite-wal
+tmp/
+.cache/
+data/
+backups/local/
+
+# Generated heavy evidence
+artifacts/evidence/**/*.mp4
+artifacts/evidence/**/*.webm
+artifacts/evidence/**/*.zip
+artifacts/evidence/**/*.tar
+artifacts/evidence/**/*.gz
+
+# Keep intentional evidence summaries, structured output and reviewed images
+!artifacts/evidence/**/summary.md
+!artifacts/evidence/**/*.json
+!artifacts/evidence/**/*.txt
+!artifacts/evidence/**/*.png
+!artifacts/evidence/**/*.svg
+
+# Local scratch
+_to_delete/
+artifacts/_audit_tmp/
+.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8a99eb7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,20 @@
+# Changelog
+
+All notable user-facing changes are recorded here. Private deployment evidence and environment-specific rollout details are deliberately excluded.
+
+## 1.5.0
+
+- Added a mature Dutch operator interface with responsive overview, inventory, storage, service, alert, incident, settings, and wallboard flows.
+- Expanded read-only Unraid and host collection for containers, processes, array, disks, pools, shares, capacity, and topology.
+- Added bounded semantic metrics, historical queries, live WebSocket subscriptions, freshness handling, and explicit `Unknown` states.
+- Added versioned dashboards, alert lifecycle, silences, maintenance, incident grouping, audit, and notification delivery controls.
+- Added Authentik/OIDC, fail-closed role mapping, finite revocable sessions, and hardened production containers.
+- Added portable checksummed backups, clean-room restore verification, production smoke checks, and deterministic release gates.
+
+## Public-readiness changes
+
+- Added a curated parentless public-source exporter and manifest validator.
+- Added public deployment, security, and contribution guidance.
+- Removed environment-specific defaults from portable configuration.
+- Pinned third-party CI actions and added explicit read-only workflow permissions.
+- Fixed personal-dashboard read authorization and bounded retained browser sessions.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..b3fecab
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,22 @@
+# Contributing
+
+Contributions must preserve Pulse's read-only observability and evidence boundaries.
+
+Start with a focused issue or proposal for material behavior changes. Keep pull requests small enough to review, explain the user-visible outcome, and include tests for the changed boundary.
+
+- use synthetic telemetry and isolated test databases;
+- never commit production dashboards, host inventories, credentials, backups or alert payloads;
+- keep application changes separate from agent/planning/evidence updates;
+- document new data collection, retention, authorization and network behaviour;
+- run the repository's managed validation and the relevant Go, frontend, integration and Compose checks;
+- retain provenance for screenshots and evidence summaries, and keep generated heavy artifacts outside Git.
+
+Changes that alter the read-only promise, OIDC/RBAC policy, backup format, agent privileges or deployment topology require explicit security review. Report sensitive findings through `SECURITY.md`.
+
+For a public source checkout, run:
+
+```powershell
+pwsh -NoProfile -File scripts/public-verify.ps1
+```
+
+Go code must be formatted with `gofmt`; frontend changes must pass tests, typecheck, lint, and build. Do not weaken a failing gate or replace a real integration boundary with a mock merely to obtain a green result.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..a098f6e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+.PHONY: validate state next sync test-harness contracts docs checksums wiring bootstrap build test lint verify
+
+validate:
+ python tools/projectctl.py validate
+
+state:
+ python tools/projectctl.py summary
+
+next:
+ python tools/projectctl.py next
+
+sync:
+ python tools/projectctl.py sync-md
+
+test-harness:
+ python -m unittest discover -s tools/tests -p "test_*.py" -v
+
+contracts:
+ python tools/validate_contracts.py
+
+docs:
+ python tools/check_docs.py
+
+checksums:
+ python tools/generate_checksums.py
+
+wiring:
+ python tools/check_wiring.py
+
+bootstrap:
+ pwsh -NoProfile -File scripts/bootstrap.ps1
+
+build:
+ pwsh -NoProfile -File scripts/build.ps1
+
+test:
+ pwsh -NoProfile -File scripts/test.ps1
+
+lint:
+ pwsh -NoProfile -File scripts/lint.ps1
+
+verify:
+ pwsh -NoProfile -File scripts/verify.ps1
diff --git a/PACKAGE_VERSION b/PACKAGE_VERSION
new file mode 100644
index 0000000..bc80560
--- /dev/null
+++ b/PACKAGE_VERSION
@@ -0,0 +1 @@
+1.5.0
diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md
new file mode 100644
index 0000000..e8c8b25
--- /dev/null
+++ b/PUBLIC_SOURCE_EXPORT.md
@@ -0,0 +1,5 @@
+# Curated public source export
+
+Generated from private canonical revision `63254607a2e701e938a09d6e8f68e5bbc8fe83ac`.
+
+This parentless candidate excludes private operational history, evidence, planning, prompts, and machine-local agent configuration.
diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json
new file mode 100644
index 0000000..7ccbb4d
--- /dev/null
+++ b/PUBLIC_SOURCE_MANIFEST.json
@@ -0,0 +1,3066 @@
+{
+ "schemaVersion": 1,
+ "sourceRevision": "63254607a2e701e938a09d6e8f68e5bbc8fe83ac",
+ "files": [
+ {
+ "path": ".dockerignore",
+ "bytes": 152,
+ "sha256": "eecf5b3bdf673aff76da149d313c546c59ebbbd2a34891022d9ff52bf69b1db6"
+ },
+ {
+ "path": ".editorconfig",
+ "bytes": 262,
+ "sha256": "ec1b98ee04298135d361bf4a77b480604495a80869287afa18e338ba2c26c749"
+ },
+ {
+ "path": ".env.example",
+ "bytes": 4125,
+ "sha256": "ee98179f4f5b16065cbb178f793865e3793cb880297966a7b6fabe0e7339042e"
+ },
+ {
+ "path": ".gitattributes",
+ "bytes": 383,
+ "sha256": "6fda06b89761f65661b1f5a7715bcc4a88add441ef4fff423f1b74837253f344"
+ },
+ {
+ "path": ".gitea/workflows/public-validation.yml",
+ "bytes": 2057,
+ "sha256": "b7cb4ddeaf8de09eb137d7fa7456ec99e72689d67a4dcb6e8d767f183db71178"
+ },
+ {
+ "path": ".gitignore",
+ "bytes": 1086,
+ "sha256": "c2a4061dc32143780539cfe747ee1f7d3bf9b7b46ce9c9c9150bdaac030c0b72"
+ },
+ {
+ "path": "apps/web/index.html",
+ "bytes": 420,
+ "sha256": "d90d1366c6ce034ae6d5bfbe533bee3425d1f452a886cde72ded29ca52811b25"
+ },
+ {
+ "path": "apps/web/package.json",
+ "bytes": 900,
+ "sha256": "5b6dae0ca3800a557424dee5445a19d5ca93de3afd62eec17499ff550cb4534d"
+ },
+ {
+ "path": "apps/web/playwright.config.ts",
+ "bytes": 1399,
+ "sha256": "0cacb3ad04697c9e8160bd8a0461d8d8df191f1050cb158c96ccc29b6e1790e6"
+ },
+ {
+ "path": "apps/web/public/pulse-icon.svg",
+ "bytes": 1344,
+ "sha256": "5bb39870be5d7cd321ea6f268e2e2edabc32f48db149d4944dbde45e79aea7fd"
+ },
+ {
+ "path": "apps/web/src/AlertControlsPanel.tsx",
+ "bytes": 10037,
+ "sha256": "6730928affedab37f9312ddc60e45039ec51dfee406f83fe199a5c0e97fd8310"
+ },
+ {
+ "path": "apps/web/src/AlertOperationsPanel.tsx",
+ "bytes": 7043,
+ "sha256": "e44d4c0c83d057cc1e5c6f1f653fac95e8bf3637b413e78c31d87c01a62072ae"
+ },
+ {
+ "path": "apps/web/src/AlertRulesPage.tsx",
+ "bytes": 22530,
+ "sha256": "8232f652ffcd93ec4b7f207fb17eba6233162f3c0edc63af83c0fe960da41bdb"
+ },
+ {
+ "path": "apps/web/src/App.tsx",
+ "bytes": 80954,
+ "sha256": "3df1d18ec0e66ebe06bfb70c8f8460bf7216b036ad46c47509fb7bad842de262"
+ },
+ {
+ "path": "apps/web/src/ApplicationPage.tsx",
+ "bytes": 6061,
+ "sha256": "766634dd8f9aa401adbf3d93705b4be5e40f44d61573fa8cc384af0507d91fe8"
+ },
+ {
+ "path": "apps/web/src/ArrayPage.tsx",
+ "bytes": 6324,
+ "sha256": "74f410a5e86154f508c6270a3d399c03a212146de87efc4ecd477c8cc237e5c8"
+ },
+ {
+ "path": "apps/web/src/auth.ts",
+ "bytes": 5980,
+ "sha256": "5e1d5d891df1aedd237a8eab83757dcf68adc773828e26b20061179a4faaaf4e"
+ },
+ {
+ "path": "apps/web/src/CapacityPage.tsx",
+ "bytes": 6215,
+ "sha256": "100e45e631770d6372526f72e8d19317242b34c5e612f0a3cbc8d43de28c15d8"
+ },
+ {
+ "path": "apps/web/src/ContainerPage.tsx",
+ "bytes": 15609,
+ "sha256": "25a2c2c3aae5949cb0ff6e9a8f1ef428ba9e75c3a151e400a7573be3fd6e94df"
+ },
+ {
+ "path": "apps/web/src/copy.ts",
+ "bytes": 68847,
+ "sha256": "2fcd7cef068486551f6a8b571a90a554a83a3250265c793c6ba61fc0616becec"
+ },
+ {
+ "path": "apps/web/src/DashboardEditor.tsx",
+ "bytes": 24553,
+ "sha256": "2f59d5a4fdd404ab0e483ff9a74f7915df3adeec698459f03d6971d2d30b71fb"
+ },
+ {
+ "path": "apps/web/src/DashboardRuntimeWidget.tsx",
+ "bytes": 11733,
+ "sha256": "afcc31e9d15792a0af3155efa72ceabbb4e69fdc1e076dee65c300148ba82245"
+ },
+ {
+ "path": "apps/web/src/dashboardScope.ts",
+ "bytes": 1109,
+ "sha256": "d1c845a4861972ed07eceb7cad8e21e932f5d4a1712b0bfaaeb8a8a42dc13394"
+ },
+ {
+ "path": "apps/web/src/DashboardTransfer.tsx",
+ "bytes": 4629,
+ "sha256": "ed0cb90ac87a83251c795c4ecd959d0c353062bcd9e5cd3ca61f7600e5e8fa8d"
+ },
+ {
+ "path": "apps/web/src/DashboardVariablesEditor.tsx",
+ "bytes": 4920,
+ "sha256": "08cc0ed9b20a04395882613301a354c84f6575371470cade51e0fff5e752a37a"
+ },
+ {
+ "path": "apps/web/src/DiskPage.tsx",
+ "bytes": 11773,
+ "sha256": "a1c774166e2bda25ab9f19e281ec47522b4ff26de2c7ffb718b657ee8d7dc19e"
+ },
+ {
+ "path": "apps/web/src/EventsPage.tsx",
+ "bytes": 9585,
+ "sha256": "cebd5cf96501fe351b8d9c1c8d28878e46ccf9e56e55654e88a21f0ef6122395"
+ },
+ {
+ "path": "apps/web/src/HostPage.tsx",
+ "bytes": 11304,
+ "sha256": "10c985f1cb23a5c03fe1ebced76a19606a5a1e9fc1e70d0858afcf3a0685409a"
+ },
+ {
+ "path": "apps/web/src/IncidentPage.tsx",
+ "bytes": 10748,
+ "sha256": "b458bd558072f51a6f3495d0773c7e8a06d87f20f64b784f46b6612fdccf65aa"
+ },
+ {
+ "path": "apps/web/src/InventoryPage.tsx",
+ "bytes": 15295,
+ "sha256": "a6111833dcdb50edee8562c80dc04dd2949290dca4a739f0fb4ddda771aafeae"
+ },
+ {
+ "path": "apps/web/src/listQuery.ts",
+ "bytes": 734,
+ "sha256": "635160b05b40d0e464175ed9aee363a596f379c5f6853c07a83dcc12dcf0809c"
+ },
+ {
+ "path": "apps/web/src/liveBuffer.ts",
+ "bytes": 6399,
+ "sha256": "3321274415d90f6340f4a54f7167e0b3fee1553ad5b6eb7962ad0e9e8c3192d0"
+ },
+ {
+ "path": "apps/web/src/liveClient.ts",
+ "bytes": 12773,
+ "sha256": "88cd36f1d939b0714ca859e9bad97057f546fd162f8baafaf1d7303c03657066"
+ },
+ {
+ "path": "apps/web/src/locale.ts",
+ "bytes": 1587,
+ "sha256": "e96efdd2a805b9e342072e03bd0b7921e2b9beccc75d84a169265e28e23bac36"
+ },
+ {
+ "path": "apps/web/src/main.tsx",
+ "bytes": 232,
+ "sha256": "055735c1f2a805ceeca8a1376b884c7a8ae1b9d69ad42753b2d23b4e277bfebb"
+ },
+ {
+ "path": "apps/web/src/metricClient.ts",
+ "bytes": 3443,
+ "sha256": "9210889b9083ad7badcf0d1b16ea7f7e8f498b0c91a9ab0a60f5c28e6eb078bb"
+ },
+ {
+ "path": "apps/web/src/MetricWidgets.tsx",
+ "bytes": 21735,
+ "sha256": "8b367cc1aeed5bb170dc74a48e8c6f7e595fe0117ad8e2a4dcabc8ce028d58d0"
+ },
+ {
+ "path": "apps/web/src/NetworkPage.tsx",
+ "bytes": 8009,
+ "sha256": "0e5861931c7661e1d67bb96643d981121072c4111e0c87bf5ef7ad537532f10c"
+ },
+ {
+ "path": "apps/web/src/NotFoundPage.tsx",
+ "bytes": 523,
+ "sha256": "a20260e2b4cc6e1da7cb45e06e904f45ee874bda36e69fa719ffb833f7b30176"
+ },
+ {
+ "path": "apps/web/src/OnboardingPage.tsx",
+ "bytes": 7273,
+ "sha256": "f40a6524b2dd6f770590c7bd21ac8df59c2f401983a5a8cd9535d9bfc0700bb8"
+ },
+ {
+ "path": "apps/web/src/OperationalSignalPath.tsx",
+ "bytes": 4433,
+ "sha256": "4806735150b7630ac159ab21bd8604abb24a54b791ab7894164f00e3c26c3eda"
+ },
+ {
+ "path": "apps/web/src/overviewSignals.ts",
+ "bytes": 2441,
+ "sha256": "9155c24e55531505d467d5b3fe8c5c7ca8981fba8ac8bc2a80d8f4fbc4f20b88"
+ },
+ {
+ "path": "apps/web/src/PoolPage.tsx",
+ "bytes": 10387,
+ "sha256": "6689c1b227cef46de20777d788840252401d62e9710464886ec5261b9c1da623"
+ },
+ {
+ "path": "apps/web/src/presentation.ts",
+ "bytes": 11813,
+ "sha256": "e9097597abd6a36cea451ebf3bb58eb0e9f4692d1f909892245fcf18f90fa1c2"
+ },
+ {
+ "path": "apps/web/src/ProcessPage.tsx",
+ "bytes": 7291,
+ "sha256": "a84c5e3363ee193417b143d496efa97de9ee5776d70876a29d2ff2ae7eab6abe"
+ },
+ {
+ "path": "apps/web/src/routes.ts",
+ "bytes": 1409,
+ "sha256": "541fb16e6eed1f89be5e67dcc9f212105a2ec8d1c3bc3dd212163829b4a65405"
+ },
+ {
+ "path": "apps/web/src/ServicePage.tsx",
+ "bytes": 20623,
+ "sha256": "b564458e24fdf92ed01bd0c8bfefb60a73ad0d3dd1cbd1e7fb839924e5db881c"
+ },
+ {
+ "path": "apps/web/src/SharePage.tsx",
+ "bytes": 8212,
+ "sha256": "bfd87cee394c7e01e20f66b1fe9d5a96c9e1670ea8af4106cfad3c2a88815abf"
+ },
+ {
+ "path": "apps/web/src/SignIn.tsx",
+ "bytes": 2874,
+ "sha256": "cb3d44f469cea68fe3818a6ab86d62cf23ffbfea83fe2e737480eee8527f31b3"
+ },
+ {
+ "path": "apps/web/src/SourceStatusDetails.tsx",
+ "bytes": 1823,
+ "sha256": "c9f1e2cb2ddb2b2df50b16aef5df8417ceec6194973671b5ca730fd63c0c1574"
+ },
+ {
+ "path": "apps/web/src/StoragePage.tsx",
+ "bytes": 6521,
+ "sha256": "0c1939c57e0ea79fadf8a057263bed9be2ca5bf2a1d9f4cc661035f6da7450d1"
+ },
+ {
+ "path": "apps/web/src/StorageVisuals.tsx",
+ "bytes": 3606,
+ "sha256": "c91262e86686a97fcc3bda2c27c653d7b33c14945b54dbafae21e227452fd48f"
+ },
+ {
+ "path": "apps/web/src/styles.css",
+ "bytes": 116139,
+ "sha256": "a75362cfd509e2d0385322024e007c157b771b2504b41dda8a0fbdeb7a742f71"
+ },
+ {
+ "path": "apps/web/src/systemStatus.ts",
+ "bytes": 10614,
+ "sha256": "8b458987f3772d59ff30776af0813d7550783159a79e3b077e69ca6451302f61"
+ },
+ {
+ "path": "apps/web/src/SystemStatusPage.tsx",
+ "bytes": 5558,
+ "sha256": "05de01cbb09ba79c5c4093062df244c0d9a569b1fe3f0198848baeaee73feeae"
+ },
+ {
+ "path": "apps/web/src/TopologyPage.tsx",
+ "bytes": 9295,
+ "sha256": "7b3b7b7a4d8af2464887a13825e834ac350356597e259d98dcd3365713f2ea47"
+ },
+ {
+ "path": "apps/web/src/useLiveMetric.ts",
+ "bytes": 4057,
+ "sha256": "0e57c1aaf89ce38ddbe27b351b11b32d3bd7cb898a964b011f080fd735001b15"
+ },
+ {
+ "path": "apps/web/src/useMetricQuery.ts",
+ "bytes": 1333,
+ "sha256": "19914f0fbf20301757d404788c0b71bf6024d508cd5c8434acbce454001e12fc"
+ },
+ {
+ "path": "apps/web/src/vite-env.d.ts",
+ "bytes": 38,
+ "sha256": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
+ },
+ {
+ "path": "apps/web/src/wallboardLayout.ts",
+ "bytes": 1246,
+ "sha256": "4c7e2cc144eb49a6cabb7a1a436761db69c20adbb08100ae5300cab71a926982"
+ },
+ {
+ "path": "apps/web/src/WidgetConfigDrawer.tsx",
+ "bytes": 9669,
+ "sha256": "5da5698c08e0148360c988d2b5b00e3cb8c97008c3328e6507348027bca3383d"
+ },
+ {
+ "path": "apps/web/tests/e2e/accessibility.spec.ts",
+ "bytes": 7458,
+ "sha256": "35944ead608edc8ed86870d6ae14dd939d3c5da739c11fa36c717af112ad11d3"
+ },
+ {
+ "path": "apps/web/tests/e2e/alert-workspace.spec.ts",
+ "bytes": 5618,
+ "sha256": "ebb6c7d538c73aa5b0d7ed699adadbdf34d9cec85eea993478ff10963b5115f5"
+ },
+ {
+ "path": "apps/web/tests/e2e/capacity-real.spec.ts",
+ "bytes": 1634,
+ "sha256": "fbc0bb3bdeae6b3069e40a1f37b0eaaf73fac7a9fa22d1e93398a7ff31f3e035"
+ },
+ {
+ "path": "apps/web/tests/e2e/core-visual-audit.spec.ts",
+ "bytes": 1890,
+ "sha256": "b736ff755f290490f16bdb595bb3d6b89594866ffd2173b26f39e99de6c3e0ba"
+ },
+ {
+ "path": "apps/web/tests/e2e/dashboard-editor-polish.spec.ts",
+ "bytes": 6441,
+ "sha256": "31845e92fe0c253307baff8d2e833ae52c60794f4976c619b881770803abc8b6"
+ },
+ {
+ "path": "apps/web/tests/e2e/dashboard-real-sources.spec.ts",
+ "bytes": 2676,
+ "sha256": "0d5bcffdadd3a2ce585a5395400928c5be85c2c1797c863737175d0fa7e32262"
+ },
+ {
+ "path": "apps/web/tests/e2e/event-timeline.spec.ts",
+ "bytes": 4065,
+ "sha256": "d1916f121fdebc99249b25f21291c4c80ae5988329ed5abbb5e4c26fb7bf5f92"
+ },
+ {
+ "path": "apps/web/tests/e2e/inventory-real.spec.ts",
+ "bytes": 1889,
+ "sha256": "c07fe7c49280c0ffe97823878a7ea899881dc2a13be4e45605bc557d0dab46ab"
+ },
+ {
+ "path": "apps/web/tests/e2e/large-lists.spec.ts",
+ "bytes": 7534,
+ "sha256": "4465cce0cfb1f1dedce6bf276e047ddd3428f1852aef0b7003797ae9417a319d"
+ },
+ {
+ "path": "apps/web/tests/e2e/localized-alert-editor.spec.ts",
+ "bytes": 4297,
+ "sha256": "fdca3979ea4e31ddde551974958a516ad9bf37c7b5c8ab5e2f75267f44fe71e6"
+ },
+ {
+ "path": "apps/web/tests/e2e/management-workspace.spec.ts",
+ "bytes": 5055,
+ "sha256": "221dc1c37a3fc380c0a27fba57b8154907eac215d8a54a29033aa7c906fc0f63"
+ },
+ {
+ "path": "apps/web/tests/e2e/mobile-incident.spec.ts",
+ "bytes": 2724,
+ "sha256": "5bcdfde067adb2280009d42fd265b51f52230f94368e81e4e1da772b70fa393e"
+ },
+ {
+ "path": "apps/web/tests/e2e/real-stack.spec.ts",
+ "bytes": 12745,
+ "sha256": "80c4c55191786dbfcabb076338759861cf722c01ca8a32f5281f8bbaeabd45c5"
+ },
+ {
+ "path": "apps/web/tests/e2e/release-backup-real.spec.ts",
+ "bytes": 1774,
+ "sha256": "f606b0b1f8c0872e5b7661bddbe328a2749ba432eae7466e1c68245167a0d7dc"
+ },
+ {
+ "path": "apps/web/tests/e2e/responsive-polish.spec.ts",
+ "bytes": 4497,
+ "sha256": "5a1303cb00934b80743332b9ce962010e99f7057509a8c3fb9db5d12729c428a"
+ },
+ {
+ "path": "apps/web/tests/e2e/service-states-real.spec.ts",
+ "bytes": 1610,
+ "sha256": "6e044d8c3164523b50d62edde3d616974bbeb7219d58a6bd3825192fb5f15dff"
+ },
+ {
+ "path": "apps/web/tests/e2e/session-boundary.spec.ts",
+ "bytes": 1226,
+ "sha256": "9516efacb2c7290f4c6fee415490c94cb412d811f001d8bd9caa88cb37ff894a"
+ },
+ {
+ "path": "apps/web/tests/e2e/sol-ultra.spec.ts",
+ "bytes": 7478,
+ "sha256": "112cd3efa16c1ced5e443c998db019ec2760b0c022d2382312152edbf14ea778"
+ },
+ {
+ "path": "apps/web/tests/e2e/source-status-presentation.spec.ts",
+ "bytes": 3151,
+ "sha256": "264532c6c60f1081b21c38918ab6922d1f78af2c8fe729d5cb8c35818361e390"
+ },
+ {
+ "path": "apps/web/tests/e2e/storage-real-stack.spec.ts",
+ "bytes": 2210,
+ "sha256": "8c4b7ca4109a36ce98c4737a468498eea613715b7ff6e28fd89185b5b3b93cac"
+ },
+ {
+ "path": "apps/web/tests/e2e/wallboard-viewport.spec.ts",
+ "bytes": 5007,
+ "sha256": "554814b8620117613768984ed5b73e2aecf177cd7698c3d2d06e9b5bf77ea7f1"
+ },
+ {
+ "path": "apps/web/tests/setup.ts",
+ "bytes": 391,
+ "sha256": "3ee621a63db2fcc2441e86bdb40680f5552b2e39d4b86151eb3c0686a0d69999"
+ },
+ {
+ "path": "apps/web/tests/unit/AlertRulesPage.test.tsx",
+ "bytes": 8259,
+ "sha256": "f1b1b9c7bda5ec54196a4f11fa77f83ded78c139f2ffb65a6a4dc758aa3cf87e"
+ },
+ {
+ "path": "apps/web/tests/unit/ApplicationPage.test.tsx",
+ "bytes": 2117,
+ "sha256": "79cd6dee1b12a1487908c4e4e6fbf9450d5ac1f5e90ba087820296e501b15d92"
+ },
+ {
+ "path": "apps/web/tests/unit/AppOverview.test.tsx",
+ "bytes": 18353,
+ "sha256": "0dd586b1a1d02c9ad8738c3e0dff0429d838477e8cbab58037ea08e6c41639c7"
+ },
+ {
+ "path": "apps/web/tests/unit/ArrayPage.test.tsx",
+ "bytes": 931,
+ "sha256": "147816cb44c48194733450f4a903be4ae385655bebb862c4ebbb907c15d0f66d"
+ },
+ {
+ "path": "apps/web/tests/unit/auth.test.ts",
+ "bytes": 1577,
+ "sha256": "e5ecaaefed7fb68bccef56fdc70f1ad3c87a6ce7336ca164e9efd3698960d077"
+ },
+ {
+ "path": "apps/web/tests/unit/CapacityPage.test.tsx",
+ "bytes": 2116,
+ "sha256": "48e58bfd4198a24e0a088d6463d1c577e34aa3c104f0a182b696699c192a48b6"
+ },
+ {
+ "path": "apps/web/tests/unit/ContainerPage.test.tsx",
+ "bytes": 3310,
+ "sha256": "2244c5783e649649f89858201a8382e8584d8a41ab7c5fe4de3ec35ea037ade0"
+ },
+ {
+ "path": "apps/web/tests/unit/DashboardCache.test.tsx",
+ "bytes": 1306,
+ "sha256": "b191df5032d853ac678908f125e8e6ab8fdb761fe0547bb906521c37dcbca2cb"
+ },
+ {
+ "path": "apps/web/tests/unit/dashboardScope.test.ts",
+ "bytes": 767,
+ "sha256": "b9772bd9da6d0e2c4933c980d519cb478fb24de0ae678e95e40c37fb84a70aee"
+ },
+ {
+ "path": "apps/web/tests/unit/EventsPage.test.tsx",
+ "bytes": 3781,
+ "sha256": "474887a6b4d7f86418e6f42fde0513a26188c3d6868db93a0d94d62ae80848a2"
+ },
+ {
+ "path": "apps/web/tests/unit/InventoryPage.test.tsx",
+ "bytes": 4120,
+ "sha256": "9aa3cb3c865b338a10ad291601a44b11771f2ca8e166d7f6f0d59359ee2fb5c4"
+ },
+ {
+ "path": "apps/web/tests/unit/liveClient.test.ts",
+ "bytes": 4967,
+ "sha256": "3c65de0e3e2be1813406187d085423678e5f231be2c6040b6e784ca7f9995d70"
+ },
+ {
+ "path": "apps/web/tests/unit/locale.test.ts",
+ "bytes": 779,
+ "sha256": "b9ce76ed4b9f8ad987ca654c2439f29fe3b53a707453b855bdacd41b77c36044"
+ },
+ {
+ "path": "apps/web/tests/unit/MetricWidgets.test.tsx",
+ "bytes": 3660,
+ "sha256": "e53b9b9df1bd402f852bf91dcb3954f5f54b80a42105012fd9248f864ece9286"
+ },
+ {
+ "path": "apps/web/tests/unit/NetworkPage.test.tsx",
+ "bytes": 1910,
+ "sha256": "e3549dfa0df5712e045effbe75681511bc632d7ea9bb74046b63438cfbab58f8"
+ },
+ {
+ "path": "apps/web/tests/unit/OnboardingPage.test.tsx",
+ "bytes": 2730,
+ "sha256": "a8cebdaf90b8de00831da46c6e6582beff8a59714ce0861a93022a1aec42d155"
+ },
+ {
+ "path": "apps/web/tests/unit/OperationalSignalPath.test.tsx",
+ "bytes": 2735,
+ "sha256": "0420a4fe6a8c2bbe27e5bbc54c655748ea218fc70ec16bcb45561d66d0110a34"
+ },
+ {
+ "path": "apps/web/tests/unit/overviewSignals.test.ts",
+ "bytes": 1571,
+ "sha256": "3bdb3a5adef133fff8194b55707752c1a8fdd010c072b1f10a00f0f907797b62"
+ },
+ {
+ "path": "apps/web/tests/unit/PoolPage.test.tsx",
+ "bytes": 1438,
+ "sha256": "204808c12471f3c4a665895887ebf76ff679aab9cc1cb7cd8047e520f9468c93"
+ },
+ {
+ "path": "apps/web/tests/unit/presentation.test.ts",
+ "bytes": 3257,
+ "sha256": "7977ce5372b0a82639d2b93fdd91a97ebd0c888ff586e68f144033f9eef220c4"
+ },
+ {
+ "path": "apps/web/tests/unit/routing.test.tsx",
+ "bytes": 1366,
+ "sha256": "0524a17452c3204515dc9b16a7eac22aa737a8d25524c52fcd1acb31e0018098"
+ },
+ {
+ "path": "apps/web/tests/unit/ServicePage.test.tsx",
+ "bytes": 3022,
+ "sha256": "27b22902980f5ee3441775554d8dc23759cf830fde53365dfceb71585bc4c84c"
+ },
+ {
+ "path": "apps/web/tests/unit/SourceStatusDetails.test.tsx",
+ "bytes": 1237,
+ "sha256": "26dd31698a625db7571ecbca45e724d10bced9f29a1235a3e0dd064423b750b4"
+ },
+ {
+ "path": "apps/web/tests/unit/StoragePage.test.tsx",
+ "bytes": 1846,
+ "sha256": "24ec69a33eb4647eeb1bd568bec154181caa08c96be81bf4b0ec655d15e6cd41"
+ },
+ {
+ "path": "apps/web/tests/unit/StorageVisuals.test.tsx",
+ "bytes": 1162,
+ "sha256": "8ed8d62ddb8834ec375621b00afb667c92deba222e745167ec6ff6786ab0da0c"
+ },
+ {
+ "path": "apps/web/tests/unit/systemStatus.test.ts",
+ "bytes": 5856,
+ "sha256": "349df0f38d32b3c913ed096783edeff324a7026497d9c67638a843c61f7384d6"
+ },
+ {
+ "path": "apps/web/tests/unit/useLiveMetric.test.tsx",
+ "bytes": 2016,
+ "sha256": "29420e6ba4a4a6a3bc3461e52121a452483a9322ef885a0a8946e712b2893083"
+ },
+ {
+ "path": "apps/web/tests/unit/wallboardLayout.test.ts",
+ "bytes": 982,
+ "sha256": "b8cc31caa1968720117bd98a65c0254fa1d68167864655009fbddb4b032e2c1f"
+ },
+ {
+ "path": "apps/web/tsconfig.json",
+ "bytes": 558,
+ "sha256": "c075b6380c83f6206463d6993339998ca9c7c8df770bf7f8662193f530e65581"
+ },
+ {
+ "path": "apps/web/vite.config.ts",
+ "bytes": 977,
+ "sha256": "f06930f44169b28ccfbf392d365e29d0e75d66724a21cad444cc456e165891d1"
+ },
+ {
+ "path": "CHANGELOG.md",
+ "bytes": 1360,
+ "sha256": "376bb43c941d0a1fc1f8f5d9b937375d22d271555402bd326e7843a34c8129d7"
+ },
+ {
+ "path": "cmd/agent/agent_test.go",
+ "bytes": 18329,
+ "sha256": "4438e755f3141bd752e15c7d0cefc69b7cfc30a8bcdfb3b84fdfda0c3dbcdf73"
+ },
+ {
+ "path": "cmd/agent/agent.go",
+ "bytes": 12496,
+ "sha256": "7e3cafb05a8bad6871119acbe96f621dc9a5d42de39b0c061363de95a96c5eb6"
+ },
+ {
+ "path": "cmd/agent/main.go",
+ "bytes": 5045,
+ "sha256": "bdb31f6ab3b992eeb1bee7cc8b40bb8e1869d670a6ef6406733e6c5d18d386f4"
+ },
+ {
+ "path": "cmd/agent/store.go",
+ "bytes": 2592,
+ "sha256": "277b1430bbaefc12a9beeb533fe2849c1b9e83866f8fe4f5bf456992e68dcd3c"
+ },
+ {
+ "path": "cmd/api/main.go",
+ "bytes": 28573,
+ "sha256": "5ad373b907a97886b2871da6a1186b77fd711854c24bac2dfa6b7940cf96df58"
+ },
+ {
+ "path": "cmd/api/session_live_test.go",
+ "bytes": 1328,
+ "sha256": "33ec9ad578a8c91c4d1bf4c37ffd1ea60e006cbf304361e1cef55f69aaf5beda"
+ },
+ {
+ "path": "cmd/api/source_health_test.go",
+ "bytes": 2205,
+ "sha256": "3dd67f4da5960259136bf03e8926b92a33130c0122bb30db00815bda48ba5823"
+ },
+ {
+ "path": "cmd/api/source_health.go",
+ "bytes": 1334,
+ "sha256": "b607dd141d60a878d726b0481b40a7ed095dd2edc320c57cbf1e9fc90d793c14"
+ },
+ {
+ "path": "cmd/migrate/main.go",
+ "bytes": 780,
+ "sha256": "5969dbc925b5d1a58b8bd35d7cf243e4118f02aebbdc3fe995f5b54ecb910775"
+ },
+ {
+ "path": "cmd/worker/main_test.go",
+ "bytes": 4642,
+ "sha256": "e2d848590ee593a143ca1488f3714ddf00e6f957ea81ef3497807edefda98730"
+ },
+ {
+ "path": "cmd/worker/main.go",
+ "bytes": 12302,
+ "sha256": "691eaaefaf05f297c03df4cab4826cee468df86d01e079cd01274d59685640d8"
+ },
+ {
+ "path": "config/alerts/default-rules.example.json",
+ "bytes": 4546,
+ "sha256": "2a7882f4858cf00f9283daafaf55e1c4f4a20eb23fc4db7586fe85642592c51c"
+ },
+ {
+ "path": "config/dashboards/default-overview.example.json",
+ "bytes": 7114,
+ "sha256": "c1b91a9e24f8b3d1eb70becf22daa7390682dfaf020543df0b8f6e6289c88ed7"
+ },
+ {
+ "path": "config/metrics/catalog.example.json",
+ "bytes": 10368,
+ "sha256": "e0789df49da3759ba74c3bc2335e4a28017bd67005a1b29537785fcfedd543ed"
+ },
+ {
+ "path": "config/probes/probe.example.json",
+ "bytes": 484,
+ "sha256": "8ee10fb81072fb4980c262d0f51a931d70d86763e1d9e3e0f6fbcc90d8822553"
+ },
+ {
+ "path": "config/README.md",
+ "bytes": 1085,
+ "sha256": "9985faa80d7c5d8fc649b59d5bd41f3b79a81eebb07b8d272ad962aa9d2ef5bc"
+ },
+ {
+ "path": "CONTRIBUTING.md",
+ "bytes": 1330,
+ "sha256": "fea964bd6d6b05fa9b279a1930d7c13f66239868046b68aa2ee7726c166d2b5c"
+ },
+ {
+ "path": "deploy/agent.Dockerfile",
+ "bytes": 1344,
+ "sha256": "d8db87a26fe74156f87e97699b60e5bcb9a3fa2db0198bd424520b8decac6624"
+ },
+ {
+ "path": "deploy/api.Dockerfile",
+ "bytes": 1086,
+ "sha256": "e4f2ab4c0053476fe910d35edd074c93d54d86f093eaba2224c1119850b2c333"
+ },
+ {
+ "path": "deploy/compose.dev.yaml",
+ "bytes": 415,
+ "sha256": "4a704c51f0ec1cf343dc2c564c34ab4accbe63dcbf4fb0cab82a78aebe9b180a"
+ },
+ {
+ "path": "deploy/compose.prod.yaml",
+ "bytes": 3447,
+ "sha256": "bb4a931e9c139c3e812221722f93a7f5d92ee279bc35b65f678bc8184d6044cc"
+ },
+ {
+ "path": "deploy/compose.real-source-smoke.yaml",
+ "bytes": 491,
+ "sha256": "6ffc3990985ac1138d164c807a4e749fa8dc0ed7afb33d60133e1caaf68773b4"
+ },
+ {
+ "path": "deploy/compose.server-smoke.yaml",
+ "bytes": 852,
+ "sha256": "d18ab917ce52b35aef99deda3418fa34b46391e562d3f97b5154de1b9ed69369"
+ },
+ {
+ "path": "deploy/compose.smoke.yaml",
+ "bytes": 1339,
+ "sha256": "e29012f4d41104203b4c5202c2a2be1ffea5596e6618c4b43bbe8c98098508e4"
+ },
+ {
+ "path": "deploy/compose.yaml",
+ "bytes": 13337,
+ "sha256": "334cb58d78b9377e27bea0fba0593f810e16c2bc515ecc833c1bc09058c95bd9"
+ },
+ {
+ "path": "deploy/healthcheck-heartbeat.sh",
+ "bytes": 2482,
+ "sha256": "6c47247f12539c2c50ebdde6bb1d4a8e2a17a121accaf7b0f2b41a93a6b38fa7"
+ },
+ {
+ "path": "deploy/IMAGE_DIGESTS.md",
+ "bytes": 3669,
+ "sha256": "a2fc0fbd1adaa641741e4e67ac99e330637407c03c57aa516a2844e1f7bf59de"
+ },
+ {
+ "path": "deploy/migrate.Dockerfile",
+ "bytes": 749,
+ "sha256": "8e3a5125860b0b9f5e0a9e7fadcf783e2d79146252de6f4a96fa1c7ca50130b7"
+ },
+ {
+ "path": "deploy/nginx.conf",
+ "bytes": 3904,
+ "sha256": "5ea25697fe7fe1b44c3a7e5a575b87a72dc3c735ea6e7f6d95ac6e572255678d"
+ },
+ {
+ "path": "deploy/postgres.Dockerfile",
+ "bytes": 1352,
+ "sha256": "358f32c09c26d9b6be286ca94a07fd6a8d9724cbc8ecc2c4ef91610d995f0b53"
+ },
+ {
+ "path": "deploy/pulse-entrypoint.sh",
+ "bytes": 773,
+ "sha256": "d6834de2c7c10cfe70814b8d442bd211cb49bd678343cd6cc36a26ac4a794f50"
+ },
+ {
+ "path": "deploy/smoke-fixture.Dockerfile",
+ "bytes": 625,
+ "sha256": "ffc0fa2d65d1bf1820a8fb213eada23be50d5bed1f193b06ddfd19450b684dc9"
+ },
+ {
+ "path": "deploy/verify-image-digests.sh",
+ "bytes": 3266,
+ "sha256": "975d25044d997c3ccff7ec6594b5bd8acd26003381f7645af5696739a3542979"
+ },
+ {
+ "path": "deploy/web.Dockerfile",
+ "bytes": 1021,
+ "sha256": "541d964882e5f4491e1206d0d21f472d0b1f756e0dd9fcb5f54e461a4d070845"
+ },
+ {
+ "path": "deploy/worker.Dockerfile",
+ "bytes": 1090,
+ "sha256": "ffc8b886479b47fc3018c9468923231506567c0dcb118dcf662e11a2ec7acd91"
+ },
+ {
+ "path": "docs/architecture/adr/0001-read-only-v1.md",
+ "bytes": 756,
+ "sha256": "4d8ba81a916b92c13a75e95b2221ec7dddce335a0e77b07016255a1d96b645d5"
+ },
+ {
+ "path": "docs/architecture/adr/0002-go-react-stack.md",
+ "bytes": 837,
+ "sha256": "3c09535295360054ff7164fcccd64f8cd4aadad10ca11e620e98cb26da43a5a2"
+ },
+ {
+ "path": "docs/architecture/adr/0003-prometheus-v1-history.md",
+ "bytes": 695,
+ "sha256": "67b83b2d387c40810d1302a0590401d148921fb483fec35d6d3480b9160e57c3"
+ },
+ {
+ "path": "docs/architecture/adr/0004-postgresql-domain-state.md",
+ "bytes": 559,
+ "sha256": "1dc6871cba7e50d2c5bd34428f1a08a7ddebad1a9c57fa9778864f09635479e8"
+ },
+ {
+ "path": "docs/architecture/adr/0005-docker-access-boundary.md",
+ "bytes": 630,
+ "sha256": "e2cd0a7dea18452244106dc1537a6935d1d6eaefedef1fcaba70123b2a6f137d"
+ },
+ {
+ "path": "docs/architecture/adr/0006-rest-websocket.md",
+ "bytes": 547,
+ "sha256": "945d06d1350f0e1759fc7d24b0aecf0a0615d793cc4cfe7505e858711c1e81f8"
+ },
+ {
+ "path": "docs/architecture/adr/0007-localization.md",
+ "bytes": 552,
+ "sha256": "04fa52d0f021f233df5277460c74dc3ac51f7256b5d46c69f44c587aff164e28"
+ },
+ {
+ "path": "docs/architecture/adr/0008-stale-is-unknown.md",
+ "bytes": 519,
+ "sha256": "eaee0b68359130757770fef08c83d035fa54f0813cf18e3a3956b15cc72a9dd2"
+ },
+ {
+ "path": "docs/architecture/adr/0009-upstream-dependency-baseline.md",
+ "bytes": 4394,
+ "sha256": "6b8f30f2b511e5005bf5bf713c71462246985664eebc938c030d35f598d24393"
+ },
+ {
+ "path": "docs/architecture/ALERTING_AND_INCIDENTS.md",
+ "bytes": 9069,
+ "sha256": "a47641d3447c6e8fd4d54fba9add13c6107e4d1eadfb33605aa9f6250c451985"
+ },
+ {
+ "path": "docs/architecture/API_CONTRACT.md",
+ "bytes": 33239,
+ "sha256": "10165dd3ebe6506aa02326c5b03852a37d3551ff3b0a294df8191399064af086"
+ },
+ {
+ "path": "docs/architecture/DATA_MODEL.md",
+ "bytes": 15031,
+ "sha256": "de335d2842e583022104f80592d994bbcc8f096e72536253bb4503f21fc2eb3b"
+ },
+ {
+ "path": "docs/architecture/SECURITY_THREAT_MODEL.md",
+ "bytes": 8672,
+ "sha256": "50ce610636f00b2d757f4b50cc65f6bc863f3d6abd2bf9580a9d27faf6b7ecfa"
+ },
+ {
+ "path": "docs/architecture/SYSTEM_ARCHITECTURE.md",
+ "bytes": 7162,
+ "sha256": "2069d35e0c0998aae4867348f54217ead2651f09e0dab65ee0ad4b35a444dd74"
+ },
+ {
+ "path": "docs/architecture/TELEMETRY_AND_QUERY_ENGINE.md",
+ "bytes": 5625,
+ "sha256": "ca558a5726f7b1fd5464cb3c008356a53250b6c5b4b255e89fee09df8d281fbc"
+ },
+ {
+ "path": "docs/engineering/BACKEND_STANDARDS.md",
+ "bytes": 2504,
+ "sha256": "ef09e26713e25b1c67ca55c016db44a0131a0f9b6c05c752044b5aa739f39762"
+ },
+ {
+ "path": "docs/engineering/CI_PIPELINE.md",
+ "bytes": 1656,
+ "sha256": "469bd5db88792cd21debdafcd860a07478dad40b6d281720082c907b89cdd461"
+ },
+ {
+ "path": "docs/engineering/DEPENDENCIES.md",
+ "bytes": 3730,
+ "sha256": "45f0a7f855f8c943943f6566f1881e2f6c43a1ba0fd972fa189068c8ac7df23e"
+ },
+ {
+ "path": "docs/engineering/DEPENDENCY_POLICY.md",
+ "bytes": 2482,
+ "sha256": "5e94b22fde0c14ad2e3cbbae7eab770156bdc8bad78d3196e7bc5d33cfe06743"
+ },
+ {
+ "path": "docs/engineering/ENGINEERING_STANDARDS.md",
+ "bytes": 3606,
+ "sha256": "408bb0dc0ce5411b735a4358266e6bc6eb0b24272afa884973fa2f83f9fed9a2"
+ },
+ {
+ "path": "docs/engineering/FRONTEND_STANDARDS.md",
+ "bytes": 2566,
+ "sha256": "8b2ebdb80eb91a85921f75505abca0c3690424f2972ae1aa012ea9d4a6ca4040"
+ },
+ {
+ "path": "docs/engineering/PERFORMANCE_BUDGETS.md",
+ "bytes": 2442,
+ "sha256": "7f6dd241b8025fcec6556bb05d796b222d5e88489a6761599b3d83c041c734e8"
+ },
+ {
+ "path": "docs/engineering/QUALITY_GATES.md",
+ "bytes": 3798,
+ "sha256": "201baabdf290eb1ba97788929c59c1dadba2f0b6539389a10520c634702d7156"
+ },
+ {
+ "path": "docs/engineering/TEST_STRATEGY.md",
+ "bytes": 3909,
+ "sha256": "48aacf38fb6236e29a311e9a42027ed70e410d8dae62caba2c6f67a34520afa3"
+ },
+ {
+ "path": "docs/operations/BACKUP_RESTORE.md",
+ "bytes": 5981,
+ "sha256": "666705fe5abbb238a1a8da18ca3b6b36e879fb612b63b0afcb45d0d3fff5c855"
+ },
+ {
+ "path": "docs/operations/DEVELOPMENT_SETUP.md",
+ "bytes": 1815,
+ "sha256": "854a4e0345014283d8a786c8cfdd622c4e92f271e5edbd68b07c185bccfdc002"
+ },
+ {
+ "path": "docs/operations/OBSERVABILITY_OF_PULSE.md",
+ "bytes": 3228,
+ "sha256": "dbc086d26ec50e54d360e4b880524998c5473f08b2c183417514a280d4265f26"
+ },
+ {
+ "path": "docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md",
+ "bytes": 7159,
+ "sha256": "11e3e30384e3fa44d40311f46ed30dd535e240595142d089ae7dc66773c808f9"
+ },
+ {
+ "path": "docs/product/MONITORING_REQUIREMENTS.md",
+ "bytes": 4038,
+ "sha256": "15400f267e1b846a85f7da221363e3915e6a1ff205d6c9cb84703a7f10246cad"
+ },
+ {
+ "path": "docs/product/PRODUCT_REQUIREMENTS.md",
+ "bytes": 7918,
+ "sha256": "2f16d85e72290baa1da8ed5e65d2a269db00309dfa6147eb7b748b79d817b328"
+ },
+ {
+ "path": "docs/product/REQUIREMENTS_INDEX.md",
+ "bytes": 5425,
+ "sha256": "0ab6914f71091f63004f3e5b95da3147e868e7f8fed03721ea568705da8988de"
+ },
+ {
+ "path": "docs/product/SERVICE_MONITORING.md",
+ "bytes": 2039,
+ "sha256": "e16f285b85338edf5f2b2720f6e7e47d2c4bd719b51abc8a82fee6daa0a201d2"
+ },
+ {
+ "path": "docs/product/STORAGE_MONITORING.md",
+ "bytes": 2768,
+ "sha256": "a3a3403b7ff3939214d16bc14e60abb4e74025807e87f1bb6ba71c35b5fc33d0"
+ },
+ {
+ "path": "docs/product/UX_SPEC.md",
+ "bytes": 5403,
+ "sha256": "a2d598fbf6ce0b032f620afb1b9757ca73c326afde9b808d426c43b498907d89"
+ },
+ {
+ "path": "docs/product/WIDGET_CATALOG.md",
+ "bytes": 3273,
+ "sha256": "11f19c1ecb2a09864b683088d61d1156f3cfae0a2e6432485c8398eed7376f53"
+ },
+ {
+ "path": "docs/PUBLIC_DEPLOYMENT.md",
+ "bytes": 2608,
+ "sha256": "b68cd80f64ba497f14a333a21a9c82e22d334cb92afe0bf76c09572664a1db3a"
+ },
+ {
+ "path": "docs/PUBLIC_SOURCE_BOUNDARY.md",
+ "bytes": 1681,
+ "sha256": "8f71df117b9437a5b1dd3a8c2c1f4d2f2f751a23312888230d8f1d3c4ce36de3"
+ },
+ {
+ "path": "docs/REPOSITORY_BOUNDARY.md",
+ "bytes": 1281,
+ "sha256": "3f19cef17b3502aba8cd8f89be329123fa66da2240346f548fc01bfd8d355b11"
+ },
+ {
+ "path": "fixtures/README.md",
+ "bytes": 770,
+ "sha256": "f81fc12566a8260bca8e27e2cdc2349872ecaf58faa674c5e517270d6f3ac09e"
+ },
+ {
+ "path": "fixtures/scenarios/array-degraded.json",
+ "bytes": 807,
+ "sha256": "212e8d322c69361062f5fcd9b2bcf414637de2ff55719909765d5b38119a93ee"
+ },
+ {
+ "path": "fixtures/scenarios/capacity-forecast.json",
+ "bytes": 1194,
+ "sha256": "62bc833e474069483c4152ba70cad893cd4b1a4eaa82558198d5dee884302dc6"
+ },
+ {
+ "path": "fixtures/scenarios/container-restart-loop.json",
+ "bytes": 1517,
+ "sha256": "e67c7ebf5b4f2e559af7291aa8b2ee1a56c1051e2d8a2a79f67d74cf0fe96e2c"
+ },
+ {
+ "path": "fixtures/scenarios/database-restart.json",
+ "bytes": 815,
+ "sha256": "af57feb59edb827e543b910d77df75b2e1a6c4086d27da813025550cdb962c24"
+ },
+ {
+ "path": "fixtures/scenarios/disk-temperature.json",
+ "bytes": 1396,
+ "sha256": "cb58c475efe31523910b9c2cfdfcb4bce6c76e54bc861fae66c8b0ab087c3e89"
+ },
+ {
+ "path": "fixtures/scenarios/dns-outage-suppression.json",
+ "bytes": 975,
+ "sha256": "60478a6021d3f6efb8bca686e6d565db81dade0b8a6dfc45525f16f2f4c4a5d0"
+ },
+ {
+ "path": "fixtures/scenarios/healthy-baseline.json",
+ "bytes": 1307,
+ "sha256": "7adc54da8f6ecb5b071c0e9f26e779628267a8044a413a7470827902e1d3e385"
+ },
+ {
+ "path": "fixtures/scenarios/pool-capacity-pressure.json",
+ "bytes": 973,
+ "sha256": "56247c3d884bfc01abc6bea03d06d1dcced6f9503ff487097a72fd0cbd2cc3ca"
+ },
+ {
+ "path": "fixtures/scenarios/pool-degraded-scrub.json",
+ "bytes": 873,
+ "sha256": "f1fa684bc319c758763e8ba703e99563c1b6c6b2e199d4c3cbf58f54c09d0b9f"
+ },
+ {
+ "path": "fixtures/scenarios/probe-executor-cases.json",
+ "bytes": 2105,
+ "sha256": "b63558f5174ab5dbb90ca14a88a222065a5dbbd97ba736852368d92e4cdb3749"
+ },
+ {
+ "path": "fixtures/scenarios/probe-scale-300.json",
+ "bytes": 929,
+ "sha256": "a73e8906b9980a8e59eef189a64f3167aaded63168cb250c24f9a6cb4e7c6327"
+ },
+ {
+ "path": "fixtures/scenarios/prometheus-stale.json",
+ "bytes": 1153,
+ "sha256": "46a0d0c4a5eca9db6288bd9064438d341ba9a42466a6985863094eb7947faab9"
+ },
+ {
+ "path": "fixtures/scenarios/service-down-container-running.json",
+ "bytes": 1230,
+ "sha256": "2a05a37186f9adca8c6216cb11e20c5c75e57ad70ba605204cb73d41048d86bf"
+ },
+ {
+ "path": "fixtures/scenarios/share-growth.json",
+ "bytes": 811,
+ "sha256": "7eff0c41976c016d7c03541d40e75ebd5861f420905b94d3d8e87e287c89e43e"
+ },
+ {
+ "path": "fixtures/scenarios/smart-warning.json",
+ "bytes": 867,
+ "sha256": "f01003c4d4d0131979546ef169d490c4cef194cb1364139032f8b136cd029607"
+ },
+ {
+ "path": "fixtures/scenarios/storage-map-heatmap.json",
+ "bytes": 710,
+ "sha256": "81590e9d870524b5c4ed9fa79d89001467fa69b0bd5582ec72c5f850cee07677"
+ },
+ {
+ "path": "fixtures/scenarios/ups-on-battery.json",
+ "bytes": 1137,
+ "sha256": "0acb36fbc27f00243cf9cafb0adcff567fbdbfaa10d7987e4d41af66831975dd"
+ },
+ {
+ "path": "fixtures/scenarios/websocket-slow-client.json",
+ "bytes": 981,
+ "sha256": "8f78f20005eca3d3d75f4711821eaea50bd7789fabe6317adbf3a4dd1fa551d8"
+ },
+ {
+ "path": "go.mod",
+ "bytes": 506,
+ "sha256": "9b66d78faafa917cb8f0059367d87893063110cda9d67fa5ec3b611a8e416f23"
+ },
+ {
+ "path": "go.sum",
+ "bytes": 2987,
+ "sha256": "965ee81707030d0e9e3acb9d500a6718c0cd84bb0b5337375126a1795a26d57b"
+ },
+ {
+ "path": "go.work",
+ "bytes": 17,
+ "sha256": "124e5e32560767b76985cea233ce8d28940328baffe11bbfe474365b3da0f57a"
+ },
+ {
+ "path": "go.work.sum",
+ "bytes": 607,
+ "sha256": "29b62358e0a01cff2628bb03a17ae743826ff3508575cd3b4c59b65de3ddd70b"
+ },
+ {
+ "path": "internal/agentprotocol/protocol_test.go",
+ "bytes": 397,
+ "sha256": "035e818888fb7e6d0dfbf66a575352181a1bc3864ab2c3f07a4e5a239713c068"
+ },
+ {
+ "path": "internal/agentprotocol/protocol.go",
+ "bytes": 1105,
+ "sha256": "6cac1ff17ab279d544782a030870da888cafd869b445573da852674bde8ae95d"
+ },
+ {
+ "path": "internal/agentsource/agentsource.go",
+ "bytes": 10643,
+ "sha256": "9c8168a3ebeb71278fd53d0d72899c8e2c62d36ee3081d8410cf14b7e48d33cb"
+ },
+ {
+ "path": "internal/agentsource/application_test.go",
+ "bytes": 9191,
+ "sha256": "c58573cb635b362f5206a078028b891d2fd26697e36de4cdf1554aa588696173"
+ },
+ {
+ "path": "internal/agentsource/application.go",
+ "bytes": 7730,
+ "sha256": "653ad730e585a17f4c8924fdbf94f7154bfb4aa60a0d391e0f7dfdf361050cb6"
+ },
+ {
+ "path": "internal/agentsource/health_test.go",
+ "bytes": 2431,
+ "sha256": "7029155737e4190b312a06153f6faebdef72a76dfd2e39290b87fbb92a21f51a"
+ },
+ {
+ "path": "internal/agentsource/providers_test.go",
+ "bytes": 16435,
+ "sha256": "9678849fa8684589b32c3f8de5bfcbb50247c4dda0e0a0a45a0171c95acce47e"
+ },
+ {
+ "path": "internal/agentsource/providers.go",
+ "bytes": 6882,
+ "sha256": "bf0680a5fd2645f51f51b9612b9f8d8e99b27f1de56ca73970290848cad2fead"
+ },
+ {
+ "path": "internal/agentstore/contract.go",
+ "bytes": 4231,
+ "sha256": "a2bbd3ccd79513ef8cdd073c8b8c455581412bba1114e7095701f53888116585"
+ },
+ {
+ "path": "internal/agentstore/postgres_integration_test.go",
+ "bytes": 4213,
+ "sha256": "c0d6eb9391ece2325b2648c87289a73822ab4c3e258f1617247d7ace9f43011c"
+ },
+ {
+ "path": "internal/agentstore/postgres_test.go",
+ "bytes": 8851,
+ "sha256": "4de66c1818dee7f126a404ff63fd2f2fe226f03b9e8762aa062e5bbf702a3c48"
+ },
+ {
+ "path": "internal/agentstore/postgres.go",
+ "bytes": 9673,
+ "sha256": "2af188503febaff1cb9db63afef0a0963820d3fdef946f790dcc8211b523a089"
+ },
+ {
+ "path": "internal/alert/alerts.go",
+ "bytes": 4415,
+ "sha256": "a1e3b823f9a447f2d660a311254e677d8fe4970fe218a990162f454bf7cd7d64"
+ },
+ {
+ "path": "internal/alert/grouping_test.go",
+ "bytes": 4128,
+ "sha256": "b72ec739f59c07e3ccd6136742c198c238d4ee9b33ebe2332449bd25394b6f20"
+ },
+ {
+ "path": "internal/alert/grouping.go",
+ "bytes": 7373,
+ "sha256": "e3d277f5328b8e5af4f3ce874341a1a235ed6c79af9a386a42dbd48f084734bc"
+ },
+ {
+ "path": "internal/alert/operations_integration_test.go",
+ "bytes": 3060,
+ "sha256": "7a0f1b9382c6c93bd295ca2460f915f52b8f3d488d8ef1b8a5217d016de3731b"
+ },
+ {
+ "path": "internal/alert/operations_test.go",
+ "bytes": 940,
+ "sha256": "72122b9472960a5f78d64e2e6259efe9f4e5b766ed375d50f8ebc2c69e43ac4f"
+ },
+ {
+ "path": "internal/alert/operations.go",
+ "bytes": 5209,
+ "sha256": "dbad92ec908067830750cfd8e87539734421f150c8b7ecae7202335aaf8f3720"
+ },
+ {
+ "path": "internal/alert/repository_integration_test.go",
+ "bytes": 2652,
+ "sha256": "b12250f8ce2865990e59d7dbd79870cbe47b7ab5e97c8d35adf9a113d98acb4a"
+ },
+ {
+ "path": "internal/alert/repository.go",
+ "bytes": 15627,
+ "sha256": "548c56161276c8310bb71ec8c8346c41d06bdd5ca10610a2741b1562889b4e8f"
+ },
+ {
+ "path": "internal/alert/state_repository_integration_test.go",
+ "bytes": 6443,
+ "sha256": "846032736e0112b59820d58355c8c7104eabedda724eb96cbf551402f3d1393f"
+ },
+ {
+ "path": "internal/alert/state_repository.go",
+ "bytes": 17249,
+ "sha256": "d35351f7e695e682061699395688990843379e4faf06b62ab70bb691f720385a"
+ },
+ {
+ "path": "internal/alert/state_test.go",
+ "bytes": 8014,
+ "sha256": "26199d6e811a0a561429a1d59830c87a6bfa2201972baf0ab4bfa77cbc43ecc4"
+ },
+ {
+ "path": "internal/alert/state.go",
+ "bytes": 7471,
+ "sha256": "2edf81188182139b25afd1c6b4f5bc0d72b1ee9cb39e949cf0931a9f1e996302"
+ },
+ {
+ "path": "internal/alert/types_test.go",
+ "bytes": 3420,
+ "sha256": "61c5e6936063fcadd53f371864cecf095daedf120f9da4c058b017680440e62f"
+ },
+ {
+ "path": "internal/alert/types.go",
+ "bytes": 13854,
+ "sha256": "16d31ce4964ed40078856681568eb77161569c4e28877211f8a099d79a9b1221"
+ },
+ {
+ "path": "internal/alertapi/handler_test.go",
+ "bytes": 4862,
+ "sha256": "e496438b2ba386c1d8f8d851e387cc715a6ceb1a0fbd60439587325ae3c3e88c"
+ },
+ {
+ "path": "internal/alertapi/handler.go",
+ "bytes": 9817,
+ "sha256": "3bda485172e8839096f27b0b6f3a26a1b90442df9c1bbaa9949df86dafe1b608"
+ },
+ {
+ "path": "internal/alertcontrol/expiry.go",
+ "bytes": 281,
+ "sha256": "b289faa229b870efef262cead51a796fc7cd9833186381a06719cd0b82628e1f"
+ },
+ {
+ "path": "internal/alertcontrol/repository_test.go",
+ "bytes": 4243,
+ "sha256": "4979c24ea42a0a1b5567321d1303d30c0b3c002fd35ad3ec310c15a026226005"
+ },
+ {
+ "path": "internal/alertcontrol/repository.go",
+ "bytes": 12742,
+ "sha256": "edce75bc98f081eb9278bd3c656505be094dfa716c250ebd642f68df55f4183a"
+ },
+ {
+ "path": "internal/alertcontrol/runner.go",
+ "bytes": 505,
+ "sha256": "16e42c75ba34e791d545e906489d5230e8c9e09dea84e2a0e017bc12aad6274a"
+ },
+ {
+ "path": "internal/alertcontrol/types_benchmark_test.go",
+ "bytes": 533,
+ "sha256": "a1b6bdebc1d0cd7c686e1d9f2417ac47a7575ce469e47b56d470fe83502dc0f7"
+ },
+ {
+ "path": "internal/alertcontrol/types_test.go",
+ "bytes": 2116,
+ "sha256": "9c4eb4b7919e65f752f8e4753dba9db42a7c4f9c366d74872e7bb239f59b6823"
+ },
+ {
+ "path": "internal/alertcontrol/types.go",
+ "bytes": 7532,
+ "sha256": "ddef3a6aaa8dcd5686c67eba3a8a554a24461a0eb200fbdfd82fea185b44b9e7"
+ },
+ {
+ "path": "internal/alertcontrolapi/handler_test.go",
+ "bytes": 7807,
+ "sha256": "7b17156c9683f1c06777b92c1fb3fd57be855905814160aa8129a6833a4a26d2"
+ },
+ {
+ "path": "internal/alertcontrolapi/handler.go",
+ "bytes": 8832,
+ "sha256": "50cf2efa4b62c5e7ddb7ab2efbb3732cfcd7ed2b3c0ba2a37808c339c00e96bc"
+ },
+ {
+ "path": "internal/alertdefaults/seed_integration_test.go",
+ "bytes": 1489,
+ "sha256": "8575ea10942081ed5efd056dd0cb66c822cb94cea56e6418ebef178baf226657"
+ },
+ {
+ "path": "internal/alertdefaults/seed_test.go",
+ "bytes": 5662,
+ "sha256": "00fd1e63c049fd7dfbc5307453c7fd0c98a3dd180e3c9c21d1cb75c78fb25a3e"
+ },
+ {
+ "path": "internal/alertdefaults/seed.go",
+ "bytes": 3833,
+ "sha256": "9437c591ea63909f83b1014ba850c19c65ad2b4fde56e4a65abf24c5362ff57a"
+ },
+ {
+ "path": "internal/alertdefaults/seed.json",
+ "bytes": 4538,
+ "sha256": "ec609aee4344b1792ad86ce850d4543dcea2a650ac1d47f53d2d5cddfa6779c1"
+ },
+ {
+ "path": "internal/alertopsapi/handler_test.go",
+ "bytes": 5915,
+ "sha256": "1d642b833d91efea919cf026b96cc4dfd8ac3125e45ac29627b0db979179cde6"
+ },
+ {
+ "path": "internal/alertopsapi/handler.go",
+ "bytes": 6974,
+ "sha256": "f6f8696c9f3ca5b5d20768de1ea05b332edeff2ad6cfaf4dc8a27e3e541550b5"
+ },
+ {
+ "path": "internal/alertworker/memory_store.go",
+ "bytes": 1871,
+ "sha256": "43d1174561e5ae636b7c55572faf08769891940d6a477a214cd78632d3c1b7c9"
+ },
+ {
+ "path": "internal/alertworker/postgres_store_integration_test.go",
+ "bytes": 2093,
+ "sha256": "698ade7483f256ced9cf33e2ca1487a6a8914464da6e3ea910b15d5486eb7ff0"
+ },
+ {
+ "path": "internal/alertworker/postgres_store.go",
+ "bytes": 3695,
+ "sha256": "1ff95438299be2d23189650dee174fcf44f25c10d39488f0580cb3341eba2593"
+ },
+ {
+ "path": "internal/alertworker/worker_test.go",
+ "bytes": 6554,
+ "sha256": "f7a94ec064280af27634ca9b0bc79bfb74fc650526646fc534173ebce11714ff"
+ },
+ {
+ "path": "internal/alertworker/worker.go",
+ "bytes": 7153,
+ "sha256": "14d6d275f5d1953345ab613c1192111b375dd041fc657f2a18f84383af8b8b6b"
+ },
+ {
+ "path": "internal/application/types_test.go",
+ "bytes": 3812,
+ "sha256": "df38310c0a8170d287a8cf1b77d92fe48bb2760f57268c383e021aed25380cec"
+ },
+ {
+ "path": "internal/application/types.go",
+ "bytes": 8401,
+ "sha256": "b7b2ff866d510b8ac1da26b2d78dc63d63cad834faba21e63f0bb7e29b56d105"
+ },
+ {
+ "path": "internal/applicationapi/handler_test.go",
+ "bytes": 1904,
+ "sha256": "284fef347d6b66c8df919563a2533db3d37b19c4247846c3f5e668545bb3818a"
+ },
+ {
+ "path": "internal/applicationapi/handler.go",
+ "bytes": 2125,
+ "sha256": "a3f31e56d4a056f45bce0f303c2d8b39e21c10c99047edd9cffe34b5884ce923"
+ },
+ {
+ "path": "internal/array/types_test.go",
+ "bytes": 5202,
+ "sha256": "adc5965083129bca34c81b3cc4862ff30e16d27a93bbbef0cb7f7ec9b79ac0cf"
+ },
+ {
+ "path": "internal/array/types.go",
+ "bytes": 12386,
+ "sha256": "725ab67c4c6a4fb31e08a2dd1fa2e7650613b58aefa9f54128821606763efc3d"
+ },
+ {
+ "path": "internal/arrayapi/handler_test.go",
+ "bytes": 2508,
+ "sha256": "2ebef48705767589cba2a3500273e7368d893c47b3c7cf02988f2a075092a1e4"
+ },
+ {
+ "path": "internal/arrayapi/handler.go",
+ "bytes": 1550,
+ "sha256": "1d519ecce622275ca53c4d641e74aa8296518a7cd685cec18f04e7c6403cf6db"
+ },
+ {
+ "path": "internal/audit/audit_test.go",
+ "bytes": 592,
+ "sha256": "f3a0c0065605b3bbc0b77ee2022e8cc6d1f98d619e54f3c1c6304a86acc13525"
+ },
+ {
+ "path": "internal/audit/audit.go",
+ "bytes": 2352,
+ "sha256": "127c7dcd2a1491061023ab3d1555902e50e1c57339b23f82505ea4d27865cc30"
+ },
+ {
+ "path": "internal/auth/oidc_test.go",
+ "bytes": 7869,
+ "sha256": "dc9dfc579d82f3a47b829da283f0bfdefd10e191d17c28a6e95c10c3af33315b"
+ },
+ {
+ "path": "internal/auth/oidc.go",
+ "bytes": 8839,
+ "sha256": "c2476ab438aee6ab910988b29e27034fdf0d4b3a5526d6f210c35af152cbe807"
+ },
+ {
+ "path": "internal/auth/session_test.go",
+ "bytes": 7353,
+ "sha256": "7834d3431ea3f5556beb3ba4ce49f521401147b90a4f75946b282b9cb7aa6a76"
+ },
+ {
+ "path": "internal/auth/session.go",
+ "bytes": 7234,
+ "sha256": "eaabc3cbeca70b41c885083fdf23b895033c596378a0ea8db3732d841c2fa9e7"
+ },
+ {
+ "path": "internal/authapi/fakeidp_test.go",
+ "bytes": 4802,
+ "sha256": "6c09b839ee07fa1bf744d01a2cd9870c327bea46e779002db4f0d6f3482d2bca"
+ },
+ {
+ "path": "internal/authapi/flowstore_test.go",
+ "bytes": 3829,
+ "sha256": "00a30f19961f7974a258f689d9d0997076b1352420bb3d48afaac35efa892ebf"
+ },
+ {
+ "path": "internal/authapi/flowstore.go",
+ "bytes": 2860,
+ "sha256": "3c78819dbf990d7dabd6b9db8273ae0b403fe72cfd6f0a1d487e895aef7428c8"
+ },
+ {
+ "path": "internal/authapi/handler_test.go",
+ "bytes": 21169,
+ "sha256": "b5e68c765e025b8fb2b8284b19e0486a785524b424419dc87696a4acd766d161"
+ },
+ {
+ "path": "internal/authapi/handler.go",
+ "bytes": 13401,
+ "sha256": "bd382b9e36d36c1c27ff34090a7a05b16fa220c734803f393638989616707994"
+ },
+ {
+ "path": "internal/authapi/wiring_test.go",
+ "bytes": 2483,
+ "sha256": "4d9383bda2a487e1256182047a128963cf6a52b52b91d153929a4bac0c8213a8"
+ },
+ {
+ "path": "internal/backup/manager_integration_test.go",
+ "bytes": 9764,
+ "sha256": "a21b5bfc1aa2be578dd3e656baa225069f1ccb761c801722c556fb68bc0618af"
+ },
+ {
+ "path": "internal/backup/manager_test.go",
+ "bytes": 2319,
+ "sha256": "21047f8e39fa45f0e598a8e431e75556430ba8101b9506317dbda851fe186a82"
+ },
+ {
+ "path": "internal/backup/manager.go",
+ "bytes": 26981,
+ "sha256": "dc4e00a71a85ddd87d82d07a1b2feb1dcf6b3a5aefb0c1592dac093bb4f3a974"
+ },
+ {
+ "path": "internal/backupapi/handler_test.go",
+ "bytes": 1509,
+ "sha256": "c4776b174e1676c08380240e8409b4149e58b04bcfea0aaf060c9d4d127bd8db"
+ },
+ {
+ "path": "internal/backupapi/handler.go",
+ "bytes": 2890,
+ "sha256": "71db8b51b112d2ac99896d3ff5ba58fa9d0d86212601d3417506be3531072883"
+ },
+ {
+ "path": "internal/buildinfo/buildinfo_test.go",
+ "bytes": 560,
+ "sha256": "41cd5138733cd3c8426f4825a538e6331b353e39329514908b4787c59f11a5dc"
+ },
+ {
+ "path": "internal/buildinfo/buildinfo.go",
+ "bytes": 341,
+ "sha256": "a2ecfa288b1a896b58fe8e7998f4ac8e7f1a642d6e987ea81e7f441e9054298f"
+ },
+ {
+ "path": "internal/config/config_test.go",
+ "bytes": 10668,
+ "sha256": "1b860db011d0ff27db355e846ff3ffd4071fcd173175089a89bb97c1c6c2ef1c"
+ },
+ {
+ "path": "internal/config/config.go",
+ "bytes": 18200,
+ "sha256": "cdeede7f50f82c06e2f605d33d607c8bb23e9132aab787fc62adcc34149010c8"
+ },
+ {
+ "path": "internal/container/types_test.go",
+ "bytes": 5297,
+ "sha256": "31f7bab1a770e01183ac1d99f029696a65ad38c3ff99cd4ab0637f23ad42b816"
+ },
+ {
+ "path": "internal/container/types.go",
+ "bytes": 11494,
+ "sha256": "bcc8abd6791af9e0ea5435fe83451f892e4e35d6d2a1af44c327237fdaf6d553"
+ },
+ {
+ "path": "internal/containerapi/handler_test.go",
+ "bytes": 2709,
+ "sha256": "b479aef93eb912076607c6e492ef8aa62b66a1298de055c3362e66133bce4f1a"
+ },
+ {
+ "path": "internal/containerapi/handler.go",
+ "bytes": 2754,
+ "sha256": "eee671b0c1cb70dac11003cb6ab2517218d3e941f3acb72e81dfca27b09dbc9f"
+ },
+ {
+ "path": "internal/correlation/correlation_test.go",
+ "bytes": 1401,
+ "sha256": "5e8881dc2261dc250184f403d1f7e1591adea072b168252568e313daa1ccc2a9"
+ },
+ {
+ "path": "internal/correlation/correlation.go",
+ "bytes": 961,
+ "sha256": "dd350b66503a8dba60e1a9adada723c64b75a5a165da181f88e6338e91539f5d"
+ },
+ {
+ "path": "internal/dashboard/document_test.go",
+ "bytes": 1018,
+ "sha256": "33aef9e42b10f9e304674c998bfa0df2fb7edbd2dfe20effd5592a2c1c498f23"
+ },
+ {
+ "path": "internal/dashboard/document.go",
+ "bytes": 2385,
+ "sha256": "67e80391c5164796c2b025f92073cce47acf3dfd15b93ba826318c205c106794"
+ },
+ {
+ "path": "internal/dashboard/errors.go",
+ "bytes": 235,
+ "sha256": "561e6c905899db27392bda0dd653139ba84d53028dc374291a470bfc80a9282b"
+ },
+ {
+ "path": "internal/dashboard/immutability_integration_test.go",
+ "bytes": 1410,
+ "sha256": "d6c8f927cadbfc830a148999089eb638797a655742c181f480a3a34ceb50ac38"
+ },
+ {
+ "path": "internal/dashboard/repository_integration_test.go",
+ "bytes": 4462,
+ "sha256": "0941e82a07423809fe0c451131bbefa44c277400a3e7f62ecc61a0758af96231"
+ },
+ {
+ "path": "internal/dashboard/repository_scale_integration_test.go",
+ "bytes": 2029,
+ "sha256": "51c0cebe72ba62480238dbc2f2eb1082b4852f385ad1c3e880d4e92ed42115eb"
+ },
+ {
+ "path": "internal/dashboard/repository.go",
+ "bytes": 12103,
+ "sha256": "d3ae8c01db9d9c208757a676b766c5b728e47eb1fbde47955de8f24fc7dced13"
+ },
+ {
+ "path": "internal/dashboard/version.go",
+ "bytes": 1003,
+ "sha256": "cb0ae1c5b4405515b714356de4d94cc590cfddbf0561ebe6da9af96bc89f1e4b"
+ },
+ {
+ "path": "internal/dashboardapi/handler_test.go",
+ "bytes": 4637,
+ "sha256": "2f587cdaee71a992fbc31bbe24b911671288630955d01d592c191356b3c85f27"
+ },
+ {
+ "path": "internal/dashboardapi/handler.go",
+ "bytes": 13841,
+ "sha256": "38ebeb019a8191db34f3ee0e78962355473da3460a4a086fa3e24dfc69c0076a"
+ },
+ {
+ "path": "internal/database/database_test.go",
+ "bytes": 11769,
+ "sha256": "1e3f3bf1344619bd625defa941de3ec1b1b43951713940786d36d159c325905e"
+ },
+ {
+ "path": "internal/database/database.go",
+ "bytes": 3547,
+ "sha256": "5e19e4752d1e541cd20708cd162458b9afb3ac0c4e89d049803ebbf13883eb16"
+ },
+ {
+ "path": "internal/database/migrations/0001_foundation.sql",
+ "bytes": 5235,
+ "sha256": "3233b1d7ffa22b392a439ba2413d55784047f1559aa6c7e51e833b399af97e83"
+ },
+ {
+ "path": "internal/database/migrations/0002_inventory.sql",
+ "bytes": 1560,
+ "sha256": "238e4bef09d34a7a0fccda25cdacc05f19ed9559e684c8ee4dfb21df0cdcf24a"
+ },
+ {
+ "path": "internal/database/migrations/0003_dashboard_immutability.sql",
+ "bytes": 487,
+ "sha256": "2aa8bb11d77e70e1d1ebea000070c14c0c6c001cc7048468d4be62d130a78ebf"
+ },
+ {
+ "path": "internal/database/migrations/0004_dashboard_revision.sql",
+ "bytes": 503,
+ "sha256": "82e63ecc307a34ef7e54f21d4ef6cf4be50327616da3cd8317bf6c6fe21e3739"
+ },
+ {
+ "path": "internal/database/migrations/0005_services_probes.sql",
+ "bytes": 6201,
+ "sha256": "986ad715a2f83d571fab2ee7aa9baed790da9b091e691eb65d22d1135c2efb50"
+ },
+ {
+ "path": "internal/database/migrations/0006_alert_rules.sql",
+ "bytes": 2151,
+ "sha256": "de2ede1a6fb68d301fc81d0dd1431b87498d97e154d31ae5c9307f0ac5818630"
+ },
+ {
+ "path": "internal/database/migrations/0007_alert_evaluator_leases.sql",
+ "bytes": 229,
+ "sha256": "e502486b1661943c7400da63b404280141412689235576b411abf6d3f2e0d862"
+ },
+ {
+ "path": "internal/database/migrations/0008_alert_state.sql",
+ "bytes": 2418,
+ "sha256": "05f6e5e336e6506967e9a52350643592cd62d28bd69c79aa43042c4f3c43df9f"
+ },
+ {
+ "path": "internal/database/migrations/0009_alert_hysteresis.sql",
+ "bytes": 339,
+ "sha256": "9bebfde31d6e79531e40e8d74457286e0e7521646cac66f6793dcb4e93630d80"
+ },
+ {
+ "path": "internal/database/migrations/0010_alert_controls.sql",
+ "bytes": 2377,
+ "sha256": "d68108ebec12a8545b655fdec7d2c6487f713d2f3b5d89d144b54fde082c3fc3"
+ },
+ {
+ "path": "internal/database/migrations/0011_alert_unacknowledge.sql",
+ "bytes": 394,
+ "sha256": "5ad63820ddc8673115eb4c8b3068dbad23fe725e485a7b2a38836c09b01b209e"
+ },
+ {
+ "path": "internal/database/migrations/0012_notifications.sql",
+ "bytes": 2371,
+ "sha256": "c4942bb35c07712a38f1839aab59e53b313662549947cf1c24b2fc2927d4aaca"
+ },
+ {
+ "path": "internal/database/migrations/0013_incidents.sql",
+ "bytes": 2728,
+ "sha256": "315daddbc326ca8d77a8b6904d871e11602f3fcbf6db7c042261e4629ea94869"
+ },
+ {
+ "path": "internal/database/migrations/0014_incident_notes.sql",
+ "bytes": 421,
+ "sha256": "10f5bde656ffdde2107bbbe0217ec59b206bd5c014a62c8650205efd14d82b5b"
+ },
+ {
+ "path": "internal/database/migrations/0015_entity_listing_index.sql",
+ "bytes": 97,
+ "sha256": "acb70c2d43278b3d6084548191e894eed06f9a4c576e55ea10e0c04cc4f4e4c8"
+ },
+ {
+ "path": "internal/database/migrations/0016_agent_snapshots.sql",
+ "bytes": 1188,
+ "sha256": "890602b4bf713e356349f002e116f3c8fd3e78328de1ee3916602c6e78dcf070"
+ },
+ {
+ "path": "internal/database/migrations/0017_worker_runtime.sql",
+ "bytes": 2285,
+ "sha256": "99ad0d9468bdc124002c239903de47bed5ba5badd10781ea898a4dc4d2ea8110"
+ },
+ {
+ "path": "internal/database/migrations/0018_inventory_read_indexes.sql",
+ "bytes": 685,
+ "sha256": "877b87361e73bfc46fa057e10d17b6357308f775edc6bfa532c09600125980c6"
+ },
+ {
+ "path": "internal/database/migrations/0019_capacity_samples.sql",
+ "bytes": 707,
+ "sha256": "00b5d3029c7985920b14191cab1ce1d93ba97ab2c990fcf694f0818de6a323d2"
+ },
+ {
+ "path": "internal/database/migrations/0020_service_certificate_history_index.sql",
+ "bytes": 122,
+ "sha256": "4aac7a3f09f0db6401f363abff19eb9cc719631738046a38adc3468fc7e238d9"
+ },
+ {
+ "path": "internal/datasource/contracts_test.go",
+ "bytes": 2032,
+ "sha256": "19bc66e6fc4235da3490699a5426306f6330240517a2092ffccbe41ec9900209"
+ },
+ {
+ "path": "internal/datasource/contracts.go",
+ "bytes": 5557,
+ "sha256": "311bd138fa132209ad63f85425d8af70bac6142b0e3ff3b22d7e82c1fd82bf8f"
+ },
+ {
+ "path": "internal/discovery/jobs_test.go",
+ "bytes": 1974,
+ "sha256": "c584ccb6051bad0e5ec863f3fd5ba7f7054ec2673b0a63f2f6f3859631aa2257"
+ },
+ {
+ "path": "internal/discovery/jobs.go",
+ "bytes": 4377,
+ "sha256": "0ea95fcf7dad60bbd4b0b8288d52a53c3d2c5a25ecb2155f8784d7518977c3aa"
+ },
+ {
+ "path": "internal/discovery/postgres_store_integration_test.go",
+ "bytes": 3661,
+ "sha256": "7cb631108e1d3626cf2c6bea0738ed24240387b3a7a4d3f5aebd2f7311074858"
+ },
+ {
+ "path": "internal/discovery/postgres_store.go",
+ "bytes": 11910,
+ "sha256": "58b45e66bd0781f651e60e07705498d48e67906248052b8500f2705f339abec5"
+ },
+ {
+ "path": "internal/disk/performance_test.go",
+ "bytes": 2297,
+ "sha256": "79066ab046f7009d9998062e7b32a33a10ce41c9e382e2d3c71f0ccff30ec897"
+ },
+ {
+ "path": "internal/disk/performance.go",
+ "bytes": 6388,
+ "sha256": "e6e2589bae0d40b7b49978a9b6046588741885c2ffab738db4db4d11950f1bb2"
+ },
+ {
+ "path": "internal/disk/smart_test.go",
+ "bytes": 2899,
+ "sha256": "4ef7b6ee12adebb1d0e8a3774889f6ef80419807cbe16d71942d47702dad708a"
+ },
+ {
+ "path": "internal/disk/smart.go",
+ "bytes": 5983,
+ "sha256": "b088433b5825f54487789f10aaeed250347e694c5c69fadd444b9a8bbeb14691"
+ },
+ {
+ "path": "internal/disk/types_test.go",
+ "bytes": 5121,
+ "sha256": "0c1ebefcb59350c9d07be2398f54038e99b72afe93ae749c4d3920657238bff2"
+ },
+ {
+ "path": "internal/disk/types.go",
+ "bytes": 13099,
+ "sha256": "fb9016b856ed751e74e37fb32b586468c267ac834c6cbbeeffeb7ed80ba01e12"
+ },
+ {
+ "path": "internal/diskapi/handler_test.go",
+ "bytes": 2127,
+ "sha256": "2fa83a064a2338fcb869001607621e87a28a5ba500907fb87ddba69f8d101f80"
+ },
+ {
+ "path": "internal/diskapi/handler.go",
+ "bytes": 2497,
+ "sha256": "bfbb212e34935a757e4abc3949306db6ab374b487942c1c63a23aa1d5c05f69a"
+ },
+ {
+ "path": "internal/eventapi/handler_test.go",
+ "bytes": 1600,
+ "sha256": "9726474e31d93d7cfda914b36aefab7ef3205f9545f0d23dab1a0a77456f6cd2"
+ },
+ {
+ "path": "internal/eventapi/handler.go",
+ "bytes": 2894,
+ "sha256": "02e0d8832663b9b13c6679429cb76bb9babd5a97a083cd9dc007e8474a3a8156"
+ },
+ {
+ "path": "internal/eventapi/postgres_integration_test.go",
+ "bytes": 2058,
+ "sha256": "78d51a1e38976a4946ff4324958df684e7117f783feef10ca44327e99dfad8c2"
+ },
+ {
+ "path": "internal/forecast/storage_integration_test.go",
+ "bytes": 3183,
+ "sha256": "3b35adc1537c7b6eb6dae568982ca0ea6e6aacdf3e8d7fe8c12ae2748f84bffc"
+ },
+ {
+ "path": "internal/forecast/storage_test.go",
+ "bytes": 4950,
+ "sha256": "093edeb0f095e1d8c0989b220b0741c59435a911c4bea291021411f4de227954"
+ },
+ {
+ "path": "internal/forecast/storage.go",
+ "bytes": 6290,
+ "sha256": "4427232a168784ff77bad5ab1986b56554fec12177fc2cee82ce166e7a7d5bef"
+ },
+ {
+ "path": "internal/forecast/types_test.go",
+ "bytes": 3712,
+ "sha256": "a333e6e178b66947f3c5cd8bc0cf5e23ace1481f8585daa3185f6553b8d4becb"
+ },
+ {
+ "path": "internal/forecast/types.go",
+ "bytes": 8372,
+ "sha256": "34c747cb68344fcffd6378f59cbbf8a93d4c85b0521d1ccd947683fb0dfa3504"
+ },
+ {
+ "path": "internal/forecastapi/handler_test.go",
+ "bytes": 2520,
+ "sha256": "6b2c776631a7beb31e11ca2a3b0b43860825a779e1bb5ef1fd84edee58cbda67"
+ },
+ {
+ "path": "internal/forecastapi/handler.go",
+ "bytes": 1430,
+ "sha256": "b33fe3c6a9f060f2e41c9607845f3a2fa82850754b9b5ee7b0676ce92f856d7a"
+ },
+ {
+ "path": "internal/freshness/evaluator_test.go",
+ "bytes": 1804,
+ "sha256": "911b9704280ac4697db86629c9ccc004abdba3a286f0fb3872d58908dfb78913"
+ },
+ {
+ "path": "internal/freshness/evaluator.go",
+ "bytes": 1342,
+ "sha256": "b6b930dd973b84f9d190824e94c3b49a1f76e54efea1ec874c4c6d05c534a5f0"
+ },
+ {
+ "path": "internal/host/adapter.go",
+ "bytes": 873,
+ "sha256": "0e5ee1a827ae26132844fece782c29fdd37ae2a24ea9dae274b29e715d1863e5"
+ },
+ {
+ "path": "internal/host/hardware_test.go",
+ "bytes": 2685,
+ "sha256": "d0b12c73547ce1b743f7dd702ab4cef058547570257f8ba4bb9cad94bb42d72d"
+ },
+ {
+ "path": "internal/host/hardware.go",
+ "bytes": 10707,
+ "sha256": "58a19418782a68087c90b57a1505b935919519e65631ee91e3e8304fb7a42f28"
+ },
+ {
+ "path": "internal/host/types_test.go",
+ "bytes": 4275,
+ "sha256": "e6f604c8b9b5125a19b80b7b7eb7afb34c2ff7458354b9904cd13f5ee0686ba7"
+ },
+ {
+ "path": "internal/host/types.go",
+ "bytes": 16763,
+ "sha256": "9b375a29685ae55bcdd444c9832e16eace8b2cc5c218be9013eb80a085447653"
+ },
+ {
+ "path": "internal/hostapi/handler_test.go",
+ "bytes": 1953,
+ "sha256": "132e6daf40fb6c29b8f5dc1b58fc33ea3ef136342d25343f3f3601c0e5488961"
+ },
+ {
+ "path": "internal/hostapi/handler.go",
+ "bytes": 1412,
+ "sha256": "d1fe9399a0004e0f4a40ee05ba9bc2338623aa9a0760a3d55ce44c2618a6054b"
+ },
+ {
+ "path": "internal/hostcollect/clock_linux.go",
+ "bytes": 1451,
+ "sha256": "4780bcc664623fc145d37489ffab180956f714cdc32a8d647fb2f936b07aebe5"
+ },
+ {
+ "path": "internal/hostcollect/clock_other.go",
+ "bytes": 346,
+ "sha256": "7e64527c6c835981cab75f1b45db08cc441e601b3020a75da0209ff38edf87a9"
+ },
+ {
+ "path": "internal/hostcollect/collector_test.go",
+ "bytes": 14925,
+ "sha256": "075f95ce9c85928a5c1fa23f1aa7bd0d6d4e25518a65562276428a22f1b88d75"
+ },
+ {
+ "path": "internal/hostcollect/collector.go",
+ "bytes": 12213,
+ "sha256": "3634f8a3715723074632a052b71dbc5540f120dbc1c741485825b17d4cc9f389"
+ },
+ {
+ "path": "internal/hostcollect/cpu.go",
+ "bytes": 4973,
+ "sha256": "dae0df25c85d32eee7fc9e0d5225bd55e65dc1674c82ae84e1f394412c2c87c2"
+ },
+ {
+ "path": "internal/hostcollect/errors.go",
+ "bytes": 365,
+ "sha256": "43f0f6e97efe60d916abd698bf8d723bfbeae8703d27012c2abb223b56f3aee6"
+ },
+ {
+ "path": "internal/hostcollect/filesystem.go",
+ "bytes": 4319,
+ "sha256": "af20d83cebd614fe058d5d9d98c5bd7bfca95caa9f01ad51fe2e9ce490e85596"
+ },
+ {
+ "path": "internal/hostcollect/loadavg.go",
+ "bytes": 1105,
+ "sha256": "8b62e4d56f4338883ac6a8e7a0a13368673ee2cbd808d6f70e562a7a2acce980"
+ },
+ {
+ "path": "internal/hostcollect/memory.go",
+ "bytes": 1930,
+ "sha256": "13961e06cdfe144f9440c1b869326eaafa353365cdd2c09a4bfa8b24afc5c99e"
+ },
+ {
+ "path": "internal/hostcollect/network.go",
+ "bytes": 2772,
+ "sha256": "7ea9f536f10da3d5023449a292b57104e22c65829a9da770f90318d1614cb9c6"
+ },
+ {
+ "path": "internal/hostcollect/parse_test.go",
+ "bytes": 6792,
+ "sha256": "bc7494b945f608516a47fce06d449e6891674aba4e261b5ee1b4f16f73dd4e44"
+ },
+ {
+ "path": "internal/hostcollect/process_test.go",
+ "bytes": 9359,
+ "sha256": "b3d80fee267b250be3f8c5678ecc108319d53b96b4459f2a4b4460480ac3831b"
+ },
+ {
+ "path": "internal/hostcollect/process.go",
+ "bytes": 10464,
+ "sha256": "0348a78ce53424a0b73dd880247c9d690792ed6393915111f5a0828b87aba5ef"
+ },
+ {
+ "path": "internal/hostcollect/procfs.go",
+ "bytes": 1708,
+ "sha256": "9111539220409e5a757cef22043e3b18fc6ff4692422588c594c6055c6dc9b32"
+ },
+ {
+ "path": "internal/hostcollect/statfs_linux.go",
+ "bytes": 1134,
+ "sha256": "43ca6c407ef8f3d1ffa23d865035dfaeaeca52dd660913df056f701bebb4595c"
+ },
+ {
+ "path": "internal/hostcollect/statfs_other.go",
+ "bytes": 359,
+ "sha256": "74771f19829dc681362ae769924533c3152e39b066d40f5f16d9372f38934796"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/1/cmdline",
+ "bytes": 20,
+ "sha256": "8f43111c5dbdb75d6121c113f27e41ed65ddd91f79a4d280a7793644a1e2244d"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/1/stat",
+ "bytes": 171,
+ "sha256": "d9b44cbd31b1c9c8da40f982f49d4154bc8e3810c9e4429ca0bbf59fee396292"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/1/status",
+ "bytes": 157,
+ "sha256": "a19e6bad7677a3c02888e758935b3469482bec3d755b20b9638121b44faededd"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/1234/cmdline",
+ "bytes": 75,
+ "sha256": "0cd56c146bd42f84c829d9fd06d52de5192b15988d7742a1c700e20dd1070eef"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/1234/stat",
+ "bytes": 167,
+ "sha256": "aae554ac6968f668443d4e5283fe31ca245c80034c42ac1e13277347c148cc98"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/1234/status",
+ "bytes": 78,
+ "sha256": "4ffc7f7da787054a34e31d5d303ff95f8edfdbe8143400bfb4ab3b85e5630a06"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/2/cmdline",
+ "bytes": 0,
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/2/stat",
+ "bytes": 136,
+ "sha256": "f7fc6c59fa1ccf495b9608c420e472b4ed405bd11bfceb02e1900a4e4b99fd72"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/2/status",
+ "bytes": 69,
+ "sha256": "7e9467884cf5359546aa4a24b33236111de0af34a18d4873d6ead17c81c56081"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/3131/cmdline",
+ "bytes": 14,
+ "sha256": "c6a59b7eb746472a2493455caa2f2a4b18da4f890f25ec59581cbca9b14f9407"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/3131/status",
+ "bytes": 30,
+ "sha256": "514ea4b40d9cee959efb14e2ffc043cfaf5aa7cda745a7045a97bea5095e9e92"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/4567/cmdline",
+ "bytes": 29311,
+ "sha256": "8a1cfbc4849942a3c77ec5637a732d31ba17e4ef1b7d9bd91da7adfc66e54b66"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/4567/stat",
+ "bytes": 177,
+ "sha256": "981dbc669634a96080605a175bec9e79fd579d773bf0a862bca0520577d7eb7d"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/4567/status",
+ "bytes": 83,
+ "sha256": "9ca027a28df726b1db16e333495f36d1e1e4bc385fdfcf220b9f5c35974538c8"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/5555/stat",
+ "bytes": 145,
+ "sha256": "e1e0b7429c1647d083e80dba70a59682170b395289ddc7f5d73ef8f50c5b2946"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/9999/stat",
+ "bytes": 23,
+ "sha256": "cffb37e40f1d88418d5e3880fcd5757639295bd8a74a21ab03e33876756fb650"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/loadavg",
+ "bytes": 28,
+ "sha256": "48315a4d11f9e94aef6aacc868cdde5b37c5be9f319d10f94c0e3199ad62a9be"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/meminfo",
+ "bytes": 442,
+ "sha256": "2865496fbe33eb0d29b5dd8bff079e8c90e0517a0ffdf607d45b3ef5dee70be2"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/mounts",
+ "bytes": 598,
+ "sha256": "d5706ae3825221be30a255cab0e6d674ad8cf926f44fea7d639b4cdf43d2c4bd"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/net/dev",
+ "bytes": 721,
+ "sha256": "d30e93c506d3e26dedadb795613bb96f87b0100fb60167d97c3e25b2c930c578"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/self/stat",
+ "bytes": 89,
+ "sha256": "a6ddedb1d76b0e3b8b8e3e50ac011adae1b6602c7f079f1729bc159ff7d2d88d"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/stat",
+ "bytes": 357,
+ "sha256": "2d56a487ae39f8fe095ae9e661b9c26e283873a65556803129d1b537afffb6cd"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/sys/kernel/hostname",
+ "bytes": 6,
+ "sha256": "427face3de91cfff25342007697ad3f00efaa38a6b47b1680e513af7711b6b62"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/sys/kernel/osrelease",
+ "bytes": 14,
+ "sha256": "c8b508db3dc3d6b06deabb7e0e7c8a457ce7c0885f61dfb8b0d453db8125e7b7"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-healthy/uptime",
+ "bytes": 21,
+ "sha256": "0021c8078eaa878bb62b0ceebdaae6494b46baf8f19d4e9b6167cc16faac1761"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-messy/loadavg",
+ "bytes": 11,
+ "sha256": "53c7cfd2becc641568baf46f09f8b595c5ee68fc2237c63a5060d7671d6c0e2f"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-messy/meminfo",
+ "bytes": 277,
+ "sha256": "06a8b6f816c25f72d803a727a490c3df1f0bdae6adbe343b8cdeae12c6a9f11c"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-messy/mounts",
+ "bytes": 100,
+ "sha256": "316d18e13d7c4caab87c3cae3fcb8d4ae1331c1535e30af940cca8646872f85a"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-messy/net/dev",
+ "bytes": 200,
+ "sha256": "7b423a538fe6b92e1d53683f789a2ca4cc82b71482336ffca3a90c3d4acb81e0"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-messy/stat",
+ "bytes": 119,
+ "sha256": "56532f620f6856f0bdbd4ab4b331dbb42db15a372d274a3c32ed6271e6c74e70"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-messy/sys/kernel/hostname",
+ "bytes": 26,
+ "sha256": "e93f54f0261b79debaa48a5e18d32aab1aaa02e87ada4ebf187e301adb9edb16"
+ },
+ {
+ "path": "internal/hostcollect/testdata/proc-messy/uptime",
+ "bytes": 9,
+ "sha256": "2f9d23efc183016965f402b0ca2bfc1edb5830759a169288d8bc19dd99abe9c5"
+ },
+ {
+ "path": "internal/hostcollect/testdata/sys-healthy/class/net/br0/operstate",
+ "bytes": 8,
+ "sha256": "7f51b4fb44dbc72708fac0a474600c2e9d8af4ce3a8b8f1680330454f6a8d68f"
+ },
+ {
+ "path": "internal/hostcollect/testdata/sys-healthy/class/net/eth0/operstate",
+ "bytes": 3,
+ "sha256": "6dcab36746762397d531bb3d0e00c31b7aea21ab3371c1149e3ca1ba20417b61"
+ },
+ {
+ "path": "internal/hostcollect/uptime.go",
+ "bytes": 1016,
+ "sha256": "f4e7f2870141e8a43cb03ab35dc7bf0cb105deade33b2a67b73440be59b46f20"
+ },
+ {
+ "path": "internal/incident/owner_notes.go",
+ "bytes": 2627,
+ "sha256": "6fa9b45af434a28bd4656ab3918f8293322f29268a4caaf1ea1adb7e26c527b0"
+ },
+ {
+ "path": "internal/incident/repository_integration_test.go",
+ "bytes": 4614,
+ "sha256": "7a858e40bb023d14ae1be24fff1a71de0879d63fab3bf0f7877f8e59964dd24c"
+ },
+ {
+ "path": "internal/incident/repository.go",
+ "bytes": 12651,
+ "sha256": "8218b8b213f85e8f0964ca5f412c3d09c2fea6bac64a7e41ca72bda0312d99af"
+ },
+ {
+ "path": "internal/incident/types_test.go",
+ "bytes": 3020,
+ "sha256": "26af528a09d9ecce639abc2cd2fb09f602358990f2717d78ce4a18624c05643c"
+ },
+ {
+ "path": "internal/incident/types.go",
+ "bytes": 8939,
+ "sha256": "04218fe5e62fb76235fecf0c8ad1e60477a092d2c970ebe51fe038c53eaec361"
+ },
+ {
+ "path": "internal/incidentapi/handler_test.go",
+ "bytes": 5127,
+ "sha256": "b85345f576ce6ac3f1f23ebaa098e9224b9d6592029ac4741015101b31a2ef99"
+ },
+ {
+ "path": "internal/incidentapi/handler.go",
+ "bytes": 9115,
+ "sha256": "555c4468d469f68bc6ae87da9ab8eb10c0a63c5bd32c98c80e5a323ebb294a40"
+ },
+ {
+ "path": "internal/inventory/readmodel_test.go",
+ "bytes": 1284,
+ "sha256": "2b6efec56ef58389153f727e537ded2d226ff29185fb5e49ff76ba9e53fea72e"
+ },
+ {
+ "path": "internal/inventory/readmodel.go",
+ "bytes": 13423,
+ "sha256": "3ef38e65ec5efddf5b0f8e4ff3632145fd23e4cfcbf27fd02b03cd2b33053281"
+ },
+ {
+ "path": "internal/inventory/repository_integration_test.go",
+ "bytes": 12194,
+ "sha256": "81199126e45f6cb6f0dca39d2387db1c798eeae75bbf6623a6a8988de0b0f69c"
+ },
+ {
+ "path": "internal/inventory/repository.go",
+ "bytes": 13331,
+ "sha256": "812b84675a8a6450384c93863288f3d2ab7daf021733442597c15cc21289538b"
+ },
+ {
+ "path": "internal/inventory/types.go",
+ "bytes": 1724,
+ "sha256": "378de71ca82945e6ae9073e012f97680a3a1b0e20fd40cee08c9332bfd832bc4"
+ },
+ {
+ "path": "internal/inventoryapi/handler_test.go",
+ "bytes": 4678,
+ "sha256": "81b6147cdb0eb05fad09d42e6aa9c08edfc9f242f92fa5ef38006266e39b702c"
+ },
+ {
+ "path": "internal/inventoryapi/handler.go",
+ "bytes": 4706,
+ "sha256": "7c1b08dc0963bdd3afaa0a48f7d84dbcdc3cbb4ab916ec28904c8d732e49581e"
+ },
+ {
+ "path": "internal/lifecycle/types_test.go",
+ "bytes": 2312,
+ "sha256": "97a005bce391f485e3c34bf9be0d3e3eca1fb2dd4b651195cf7c8fd3aee0c910"
+ },
+ {
+ "path": "internal/lifecycle/types.go",
+ "bytes": 6990,
+ "sha256": "0c36637f9c83b38ab5df15bc0986794353581920174d0d489881742d9d3e0e8e"
+ },
+ {
+ "path": "internal/live/backpressure_test.go",
+ "bytes": 528,
+ "sha256": "af1faa94da2293ac9bf46a32fe13608f9fb32886dae3f4abb77fdd2a29302690"
+ },
+ {
+ "path": "internal/live/live_test.go",
+ "bytes": 7992,
+ "sha256": "b8819e98f74d7a9ed2dc54d54ed45dfc4cbfb7f8ed1bc0037cc789309d0d2c0b"
+ },
+ {
+ "path": "internal/live/live.go",
+ "bytes": 15481,
+ "sha256": "2878be9a5946e43930cb04ed6a7cbc6160a6878fa6328ccdd8f3cd65d92b2bab"
+ },
+ {
+ "path": "internal/live/registry_test.go",
+ "bytes": 4818,
+ "sha256": "417247458190ccbe9aeea59d26f0008000198d9a5204a6a827887a87e578b6f1"
+ },
+ {
+ "path": "internal/live/registry.go",
+ "bytes": 5527,
+ "sha256": "899812ad3adbf968680c9097df848415e53b6dfda525af82a9d139937b733dcb"
+ },
+ {
+ "path": "internal/livesampler/sampler_test.go",
+ "bytes": 12375,
+ "sha256": "f851c3be0fef331d8d291d1bdd25676414f5c89fd400e53b46bcdd3610444dc2"
+ },
+ {
+ "path": "internal/livesampler/sampler.go",
+ "bytes": 11759,
+ "sha256": "ca70872683779f664f110f885f6f1ab6a9a676656b8c517c2ecb216a07d2af1c"
+ },
+ {
+ "path": "internal/m7gate/close_test.go",
+ "bytes": 3451,
+ "sha256": "176088071ebdad829b5c2da76da4571f5119f20351be05ee6f6f4526c4311701"
+ },
+ {
+ "path": "internal/metriccatalog/catalog_test.go",
+ "bytes": 4618,
+ "sha256": "3ade65debc1b20d5ad811c4d91f6225851f1ea0b815e7dda849dbd4333e5887c"
+ },
+ {
+ "path": "internal/metriccatalog/catalog.go",
+ "bytes": 11513,
+ "sha256": "e56e1bad26fedfdd31dca69fa57a3d4ba394627006ed156a11f78fe74c927e46"
+ },
+ {
+ "path": "internal/metriccatalog/seed.json",
+ "bytes": 11759,
+ "sha256": "ee0de854435b396efab1886a233303afff4555c13861c35390665f9e27facbab"
+ },
+ {
+ "path": "internal/metricquery/handler_test.go",
+ "bytes": 5387,
+ "sha256": "593eb9101056b40e813d14eb18245560013e1c2afbf01954d0cc122452623fa4"
+ },
+ {
+ "path": "internal/metricquery/handler.go",
+ "bytes": 4274,
+ "sha256": "15939b31bb3d46c1ea3c2d641cf804fa589e167344f6e3945c698d050f187bfd"
+ },
+ {
+ "path": "internal/metricquery/service_test.go",
+ "bytes": 9216,
+ "sha256": "b480ebd1e4a4bcaba619177582879a00756e7da2739d7fb636faa25c2c6f756a"
+ },
+ {
+ "path": "internal/metricquery/service.go",
+ "bytes": 10515,
+ "sha256": "592074dcd728269c2a969f372ba0f0f2a3cb43df174d689020334a7c6f221541"
+ },
+ {
+ "path": "internal/metricsapi/handler_test.go",
+ "bytes": 1771,
+ "sha256": "0e38c081c810ef3f58e6fbbb6fa23689219a4c753ec271e5e7c953a9f448a314"
+ },
+ {
+ "path": "internal/metricsapi/handler.go",
+ "bytes": 1026,
+ "sha256": "57911b874dac21f1c434b15a56b5e250cc10fb97df2ee6c9136743324b859223"
+ },
+ {
+ "path": "internal/network/provider_test.go",
+ "bytes": 1080,
+ "sha256": "728136e48d6b8cf54caedc87c076b8fb0fa5bbe89e27267cc68039a440ce9df3"
+ },
+ {
+ "path": "internal/network/provider.go",
+ "bytes": 1410,
+ "sha256": "abf38eb1b5603dd1f76074f506f4b9cda269de334f4b5cef4ae66e1cef3e03bf"
+ },
+ {
+ "path": "internal/network/types_test.go",
+ "bytes": 6743,
+ "sha256": "171782befc08e371a70fc4663572deddb36b0f001b5fec6a9bed63d5a7dceaa5"
+ },
+ {
+ "path": "internal/network/types.go",
+ "bytes": 13236,
+ "sha256": "e6bf8d5fdfd2a22ce8b57bd608d451cbf80a1991206ccb53c73684687206eed0"
+ },
+ {
+ "path": "internal/networkapi/handler_test.go",
+ "bytes": 1842,
+ "sha256": "d7d286b1bf3190998fbb66fc1ed9f217e7bc3e50f7256e691232d95ada9d78e1"
+ },
+ {
+ "path": "internal/networkapi/handler.go",
+ "bytes": 1392,
+ "sha256": "227b5d84bcac2cd4055076ddaf15cc9b7e9a1fb45a88b79e921508da64ecc454"
+ },
+ {
+ "path": "internal/notification/dispatcher_test.go",
+ "bytes": 2572,
+ "sha256": "3645527924103622042c90788bef785fd5e057f664fa7a4f7a4f6bc0ffdf95af"
+ },
+ {
+ "path": "internal/notification/dispatcher.go",
+ "bytes": 2105,
+ "sha256": "ba9907c42fbd416500ca8678143c1f5a6ccddb2b92e306d2569a6341f1de780c"
+ },
+ {
+ "path": "internal/notification/repository_integration_test.go",
+ "bytes": 9669,
+ "sha256": "394984cbecd366584650ffb775e04abb409aaf4d1fd96b014dab347422dafcc6"
+ },
+ {
+ "path": "internal/notification/repository.go",
+ "bytes": 17216,
+ "sha256": "90dc173b21fbf6813453ea5feb27101041134105ffe96c88e2eb7ee5151052e2"
+ },
+ {
+ "path": "internal/notification/types_test.go",
+ "bytes": 3316,
+ "sha256": "bd18277bca4b8c1a2308005eef4ad3085e159747a82a3d81ceb14c2bcc9cfd0e"
+ },
+ {
+ "path": "internal/notification/types.go",
+ "bytes": 5809,
+ "sha256": "e1b0eeb8565c6f4c4fce931ac49f98e31da8b049a1533c4d19275d555cb585a7"
+ },
+ {
+ "path": "internal/notification/webhook_test.go",
+ "bytes": 5476,
+ "sha256": "4839c21771ae33e27703c067197e5040e397ee92021cbeb0b6f1960c44d8cdb9"
+ },
+ {
+ "path": "internal/notification/webhook.go",
+ "bytes": 6022,
+ "sha256": "f383969cc6a3f4988955bba04050581fa3d33fa6d0e9e0359943f776a3160810"
+ },
+ {
+ "path": "internal/observability/metrics_test.go",
+ "bytes": 3093,
+ "sha256": "06936f936abc32298884e1eeb2572a088c25aadf9eb2624e63e4d66f2a0aa4f6"
+ },
+ {
+ "path": "internal/observability/metrics.go",
+ "bytes": 8094,
+ "sha256": "0887d65b6c214eec6dd7e859cbe061af02a2148bf2334cd271e94da688cc78a1"
+ },
+ {
+ "path": "internal/onboarding/default-dashboard.json",
+ "bytes": 7114,
+ "sha256": "c1b91a9e24f8b3d1eb70becf22daa7390682dfaf020543df0b8f6e6289c88ed7"
+ },
+ {
+ "path": "internal/onboarding/repository_integration_test.go",
+ "bytes": 1839,
+ "sha256": "89b5de6b20cc5f072ae98f73a904875c45174f384f676579162a0ad042af6a5d"
+ },
+ {
+ "path": "internal/onboarding/service_test.go",
+ "bytes": 2297,
+ "sha256": "f96e189a39bc6405ca83db1e67bbceb84047b3d1bab9d11505d7058f00c98961"
+ },
+ {
+ "path": "internal/onboarding/service.go",
+ "bytes": 8194,
+ "sha256": "4822fd06f0aae3c099222c2f8dcad69718bd7055462567910fdfc8aa3565eb91"
+ },
+ {
+ "path": "internal/onboarding/store.go",
+ "bytes": 1615,
+ "sha256": "50dd7e016aaad4cde89e8ef3e3813d520729e06c47b6cb98f0505b142b673581"
+ },
+ {
+ "path": "internal/onboarding/types.go",
+ "bytes": 904,
+ "sha256": "f28a6038024a7796eca3cb83b397b86a4eb065938a18bdcc0ac64f16f9710e98"
+ },
+ {
+ "path": "internal/onboardingapi/handler_test.go",
+ "bytes": 2486,
+ "sha256": "91a0bcad4fdcf1df10ddddc05b8db2bdd01d565409dbd314427acca8133cb0b2"
+ },
+ {
+ "path": "internal/onboardingapi/handler.go",
+ "bytes": 2938,
+ "sha256": "223593f11362eae9afa7e50c75a5cfa974a7435c040ec22ee394d3b0e75946e9"
+ },
+ {
+ "path": "internal/pool/types_test.go",
+ "bytes": 6626,
+ "sha256": "95aaf1d655edda2020c14144fb4687697289b630f229e27f86f5875c98ea34ec"
+ },
+ {
+ "path": "internal/pool/types.go",
+ "bytes": 18442,
+ "sha256": "220f7b25e9b6395550d57fdbf4763d08542edaae4856ea9ccc871fb737920b59"
+ },
+ {
+ "path": "internal/poolapi/handler_test.go",
+ "bytes": 2467,
+ "sha256": "8a61c30af13776c8be8faf15224fd703292dbdb559dc6a3ee07d4e6267230c5d"
+ },
+ {
+ "path": "internal/poolapi/handler.go",
+ "bytes": 2497,
+ "sha256": "84550697e39b0837eadf149d3c064be3cb48632d5b8a2620023f3d860d2552a0"
+ },
+ {
+ "path": "internal/probe/executor_test.go",
+ "bytes": 10407,
+ "sha256": "c76f330b5f4b32d9299bd5bdf8a2a173db813497c4f5b70916d0c64a686c102c"
+ },
+ {
+ "path": "internal/probe/executor.go",
+ "bytes": 9486,
+ "sha256": "4449c9e4c6e808b59ecda1d8685d3f9dda57c55b09a06a08cec982c9098a0f7c"
+ },
+ {
+ "path": "internal/probe/policy_test.go",
+ "bytes": 4211,
+ "sha256": "771b8c9f0f8f167de5b7323294e3971a8696a65cd4b78fba6e2c1ad6c4fc4340"
+ },
+ {
+ "path": "internal/probe/policy.go",
+ "bytes": 10167,
+ "sha256": "4ba0485cf3e4a6ea350c1387021693cc233710796506ef7aebf445ef40540826"
+ },
+ {
+ "path": "internal/probe/scenario_test.go",
+ "bytes": 1661,
+ "sha256": "57f71fdee0e85ae74660705a63a35e88b89fe8b0de725f4b3419002400f7080d"
+ },
+ {
+ "path": "internal/probe/scheduler_test.go",
+ "bytes": 4907,
+ "sha256": "feab7038a241767498f421288061284f58e814c6dd4cd79d04dd9872f8d6cc07"
+ },
+ {
+ "path": "internal/probe/scheduler.go",
+ "bytes": 8204,
+ "sha256": "02ce19518a8263421538666e1da60f29a645ec8cfe238118a7fcf1aed3d88c45"
+ },
+ {
+ "path": "internal/probe/types_test.go",
+ "bytes": 739,
+ "sha256": "0f18a0bdd5f5a8b51c10e865f723e20f4993e8383c2d737a2f4f042d93407cdc"
+ },
+ {
+ "path": "internal/probe/types.go",
+ "bytes": 3633,
+ "sha256": "2700ddaa38f8b035d7a8f7aaed5d6ae1532becc5c7de081997da7cde7962c79a"
+ },
+ {
+ "path": "internal/problem/problem_test.go",
+ "bytes": 1260,
+ "sha256": "c53c4908cc79f43f9bfa60e5949241c249af7e36ac95d2a988c6acfe949ddd76"
+ },
+ {
+ "path": "internal/problem/problem.go",
+ "bytes": 1215,
+ "sha256": "1a4d59b9d377d689875890bb1a54869bafeb708ce8d40aec227052fcc6fc0546"
+ },
+ {
+ "path": "internal/process/types_test.go",
+ "bytes": 3177,
+ "sha256": "2db28ad36269b4e51579750b48a033c61b8efc92a01f0920497b022ce5546e26"
+ },
+ {
+ "path": "internal/process/types.go",
+ "bytes": 8266,
+ "sha256": "3d1e36818b56c4153c2c8ee7fbab38af5161d3741a7078051dbef37a75be6609"
+ },
+ {
+ "path": "internal/processapi/handler_test.go",
+ "bytes": 2308,
+ "sha256": "411523f3eb5dff226484f030fb8bd12f8d542c219c65970a3e0d6268bada4262"
+ },
+ {
+ "path": "internal/processapi/handler.go",
+ "bytes": 2140,
+ "sha256": "f3bbc1546f149e69e94d0ffa16f0af600ff7564d47b89c98638383b9301a51ed"
+ },
+ {
+ "path": "internal/prometheus/client_test.go",
+ "bytes": 4634,
+ "sha256": "62adbe5991b36647871448cf6427bb09a0c189772ab1106c47462c4fb183561c"
+ },
+ {
+ "path": "internal/prometheus/client.go",
+ "bytes": 6495,
+ "sha256": "d9890c7b5503896ac4c050d1ef6abaa2d58f52c3c28b902b550220c570ed29e0"
+ },
+ {
+ "path": "internal/promqlbinding/binding_test.go",
+ "bytes": 5620,
+ "sha256": "db04a8731d48bf7b19b94c8f94736c41482ea91c121482ffd8f7814e23c0a6a8"
+ },
+ {
+ "path": "internal/promqlbinding/binding.go",
+ "bytes": 6157,
+ "sha256": "702d83f7b19447ef36d53673902623437cf60003a76d2e57a958fc628e0e67b4"
+ },
+ {
+ "path": "internal/queryplan/planner_test.go",
+ "bytes": 4499,
+ "sha256": "f2a6ee32c4d921c1d9c926f1c86346c69c11ab1cfc23bb872cc0c65a331339c4"
+ },
+ {
+ "path": "internal/queryplan/planner.go",
+ "bytes": 9167,
+ "sha256": "18b4499da5035d73a6c573afe2c3c43f666ad799ea915ae2844233ec33c56a72"
+ },
+ {
+ "path": "internal/reconciliation/container_identity_test.go",
+ "bytes": 5723,
+ "sha256": "457ea25465f470ae360760cf76af2b0672032ba1260bd8dae34c9fe6a5772bc5"
+ },
+ {
+ "path": "internal/reconciliation/container_identity.go",
+ "bytes": 7373,
+ "sha256": "329e5ce7bcb3d03d57b45b165a1d53bfcf1df67ee16f52f5ed2ab80ec271406c"
+ },
+ {
+ "path": "internal/reconciliation/engine_test.go",
+ "bytes": 3563,
+ "sha256": "575a9c650b3ab3a95cf150808051c30810aaf1e3f22eebaf64f1918ce895eaa0"
+ },
+ {
+ "path": "internal/reconciliation/engine.go",
+ "bytes": 3761,
+ "sha256": "410a0857f8a884fec0dc9a0bba173d0f48a60d796d633d266cee57197883f3e0"
+ },
+ {
+ "path": "internal/redaction/redaction_test.go",
+ "bytes": 760,
+ "sha256": "fb6707f7dc247d36fc6cd856a1221336976d07e3df347cd9db0f3d110a413fc5"
+ },
+ {
+ "path": "internal/redaction/redaction.go",
+ "bytes": 983,
+ "sha256": "bced74081f750e17072233f05719cdcc6de82479adbd7c00a521e1b38e06785e"
+ },
+ {
+ "path": "internal/reverseproxy/http_client_test.go",
+ "bytes": 2302,
+ "sha256": "2ab401bf1473ed28661a42fdb061b316387c00a1881d004bae3849256244d439"
+ },
+ {
+ "path": "internal/reverseproxy/http_client.go",
+ "bytes": 4141,
+ "sha256": "afcf5034f5d71b11a763449b9e3b1106574c6b051a9b7bed0d613d0e38455cc9"
+ },
+ {
+ "path": "internal/reverseproxy/types_test.go",
+ "bytes": 4560,
+ "sha256": "5ee916c7984ebbe1d9ebcf2aac2502701c49418dcdc8b5a1f471bbf6b1976735"
+ },
+ {
+ "path": "internal/reverseproxy/types.go",
+ "bytes": 14608,
+ "sha256": "df59a97d9f3dc0e826081b9faefa73df88776e379d074dd5cdd4bfb9de1f5260"
+ },
+ {
+ "path": "internal/reverseproxyapi/handler_test.go",
+ "bytes": 2416,
+ "sha256": "5e9e7c816549bd179ee0b48c76755d26311fd3afe935e69d50c2d34753f0cf3b"
+ },
+ {
+ "path": "internal/reverseproxyapi/handler.go",
+ "bytes": 1682,
+ "sha256": "19b23d6fad08626787da801a34c0458ec2b3efc93e963e926d2a9c46cb8924c6"
+ },
+ {
+ "path": "internal/runtimeconfig/agent_test.go",
+ "bytes": 4872,
+ "sha256": "9a420f0d1f39ec6b57f50b4737dddb40c0c2e5acbe94d12d19deffd8733d16c1"
+ },
+ {
+ "path": "internal/runtimeconfig/agent.go",
+ "bytes": 5627,
+ "sha256": "1d1ec4172324a27e5bea5a1d224f87c91bd7a2821b3100e0a3d400bc6be4f281"
+ },
+ {
+ "path": "internal/runtimeconfig/config_test.go",
+ "bytes": 744,
+ "sha256": "9e6ea8e70cd79fb23c2d4e17d63d70427e37568b956c8074cd090a9c652ec5eb"
+ },
+ {
+ "path": "internal/runtimeconfig/config.go",
+ "bytes": 3063,
+ "sha256": "57a8a05abc0c981a6557ff8a4925b934cb372744ab85b8f8f52cb2310c77dbd1"
+ },
+ {
+ "path": "internal/service/dependency_repository_integration_test.go",
+ "bytes": 5664,
+ "sha256": "621bc44075aace0a2f6b92dd27b62bb55780dcf2a92bd92a83575e67aad7e37d"
+ },
+ {
+ "path": "internal/service/dependency_repository.go",
+ "bytes": 9773,
+ "sha256": "5881ed4a9df3ac9dc9c79673e5d58dd2f57658ecbdc162c8d0034ccc69989d8a"
+ },
+ {
+ "path": "internal/service/dependency_test.go",
+ "bytes": 1911,
+ "sha256": "67d2f4a337310c0f262272bd9a0181bfefcabfa9c403f63111cef0edcaa84550"
+ },
+ {
+ "path": "internal/service/dependency.go",
+ "bytes": 4306,
+ "sha256": "899890bb6f81319110d000bf9cb503b3a1929197e0b24d9e0439d471ad369c08"
+ },
+ {
+ "path": "internal/service/health_test.go",
+ "bytes": 963,
+ "sha256": "3d3ba174ce28a3039d0c80a3de2603722d5c0d33ea64722a681a22fba2b37fe5"
+ },
+ {
+ "path": "internal/service/health.go",
+ "bytes": 701,
+ "sha256": "cb36170685d8a52724cae381d83353139ce129c6b15cb5550c31fec3b77adef5"
+ },
+ {
+ "path": "internal/service/postgres_integration_test.go",
+ "bytes": 3574,
+ "sha256": "664194ff90d87a9b58ddc725fdffc7d619089729aa9e60d64c86edace93c2eb7"
+ },
+ {
+ "path": "internal/service/postgres.go",
+ "bytes": 8178,
+ "sha256": "9ea4c7c02529f8041c0c3a57f1b38a98cb55cbd7b14f9ad9ef4d737ffc9d470b"
+ },
+ {
+ "path": "internal/service/scenario_test.go",
+ "bytes": 2259,
+ "sha256": "40afb4ad69b8ff0eff978d7e23f099e68028a148f8cdc0c341a5969106fd9b02"
+ },
+ {
+ "path": "internal/service/signal_test.go",
+ "bytes": 452,
+ "sha256": "bc1c83b09cce3d59e784de15ce8da39332e629beff4420c2054e8e819492185c"
+ },
+ {
+ "path": "internal/service/signal.go",
+ "bytes": 335,
+ "sha256": "978bdc7824821f54df16b39b8036ff5b86798d0485b010f2a5bcaa182f343c40"
+ },
+ {
+ "path": "internal/service/status_test.go",
+ "bytes": 8317,
+ "sha256": "f359e66c44aea0adf187f6970af22fad1a7181401286728f98ffa60f526bce9a"
+ },
+ {
+ "path": "internal/service/status.go",
+ "bytes": 13334,
+ "sha256": "9a9216d9104c524397998a39fe5329b7e2e49de811df6d49d8f0671e3ae948a9"
+ },
+ {
+ "path": "internal/service/topology_reverseproxy_test.go",
+ "bytes": 899,
+ "sha256": "f406e8d6de3b470e9705b387227792c4f4671448de5ede88f0d698785e847f57"
+ },
+ {
+ "path": "internal/service/topology_test.go",
+ "bytes": 4459,
+ "sha256": "7238599b2aee8322bbb17da48a00bfb73717ec43760e0e0862f8bf72f30ef1be"
+ },
+ {
+ "path": "internal/service/topology.go",
+ "bytes": 5737,
+ "sha256": "9587dd9cd8650a97d8336f4072b7abda3ac8df6e54aa4878fea195bd04ec5861"
+ },
+ {
+ "path": "internal/service/types_test.go",
+ "bytes": 725,
+ "sha256": "c8bce6fe1b29374c01f6ce599035e6b58b7d42a2da9bc7038dfe685f2845100a"
+ },
+ {
+ "path": "internal/service/types.go",
+ "bytes": 2332,
+ "sha256": "1e1431da21d36853b05fd4e3607983cd172c5f8a5f601893eccdc046770c4e9c"
+ },
+ {
+ "path": "internal/serviceapi/handler_test.go",
+ "bytes": 7474,
+ "sha256": "185727e5baa78182bd4e3fdb0a93da8afb3904773f44306421d7d7372ab1480d"
+ },
+ {
+ "path": "internal/serviceapi/handler.go",
+ "bytes": 8308,
+ "sha256": "686719607459377050eb521c92c3958329ea2754485f8e02dabd0b477820160a"
+ },
+ {
+ "path": "internal/servicedefaults/seed_integration_test.go",
+ "bytes": 2835,
+ "sha256": "08987da86e908cc4e7a63dbb252d7df3da360dea6ddcc2faa340db892ce3bafa"
+ },
+ {
+ "path": "internal/servicedefaults/seed_test.go",
+ "bytes": 2461,
+ "sha256": "632130860bfb9c53ced64e8c85a477f53b914c63c573c2958d24eea0b9f7e0cc"
+ },
+ {
+ "path": "internal/servicedefaults/seed.go",
+ "bytes": 9315,
+ "sha256": "9d832c68dfacc37e2a30555dbb420290d91f942a805957c292a78ba8b02eabef"
+ },
+ {
+ "path": "internal/share/types_test.go",
+ "bytes": 3063,
+ "sha256": "186052ce31117119273d952c80c3bf2899f0d3f4f5393d6f8d9029ebd7b17b33"
+ },
+ {
+ "path": "internal/share/types.go",
+ "bytes": 12164,
+ "sha256": "3fcfbcde3d127ff5521d1484e20ec6a294896a0c3ec7400b05904fa01b418c0b"
+ },
+ {
+ "path": "internal/shareapi/handler_test.go",
+ "bytes": 2316,
+ "sha256": "da3e00604990112b0e930edd3ed78529a8c4da8ff9187ca34ddc0359bf5d900f"
+ },
+ {
+ "path": "internal/shareapi/handler.go",
+ "bytes": 2525,
+ "sha256": "657e0579d7f90d8529570f3aac592d5d2340e8df73bbc9a29c81776dabc31bab"
+ },
+ {
+ "path": "internal/storagescenarios/scenario_test.go",
+ "bytes": 4998,
+ "sha256": "cfec18d1e061023af6c15c19db97b1d1057fbfb95db9acce0ee392db89b8726a"
+ },
+ {
+ "path": "internal/systemstatus/status_test.go",
+ "bytes": 9040,
+ "sha256": "9f6def1236572dc626b35251a83e418c59f6f3f59b89313eaef7decc658faaa7"
+ },
+ {
+ "path": "internal/systemstatus/status.go",
+ "bytes": 14100,
+ "sha256": "d2105bd5a0469bd9f032450211f7dca33df85960e69b9f7698cdb0a8626e4847"
+ },
+ {
+ "path": "internal/systemstatusapi/handler_test.go",
+ "bytes": 1900,
+ "sha256": "341b366b9b80290bbc99fd541eb06bf65e1dd1563ffb8a902ca5e98aa01bd34d"
+ },
+ {
+ "path": "internal/systemstatusapi/handler.go",
+ "bytes": 2227,
+ "sha256": "11d62adf8cc82fec18c007d5f1eab29bca601f21c1cac309328cd5e93ca4f5d3"
+ },
+ {
+ "path": "internal/unraid/array_test.go",
+ "bytes": 2501,
+ "sha256": "6fc4bf7cf608d8e0fb3d80c3ce00e48af9c60b7adc4234abfcb8a9ca06f550e9"
+ },
+ {
+ "path": "internal/unraid/array.go",
+ "bytes": 6389,
+ "sha256": "7d607e6573429d388c3a4c64832cd621244e8dc3d8eec723091ca553d360811c"
+ },
+ {
+ "path": "internal/unraid/client_test.go",
+ "bytes": 2992,
+ "sha256": "e50b970e1d305a730cf04abcce36e3267ee41acc3c9deb377fe8e3058855baaf"
+ },
+ {
+ "path": "internal/unraid/client.go",
+ "bytes": 4498,
+ "sha256": "91249496210fc255e62e2e3e172ca5391018a2d668a628580d417e49681fc0d8"
+ },
+ {
+ "path": "internal/unraid/containers_test.go",
+ "bytes": 4436,
+ "sha256": "ea7c83b8160a3448ad69ce595872846918badae52b2897a12e5b6228cd153a83"
+ },
+ {
+ "path": "internal/unraid/containers.go",
+ "bytes": 3673,
+ "sha256": "3101aa7a81b25371d52b7eea466bea1ffdc0de824d0baf0cf29f23223adafc7c"
+ },
+ {
+ "path": "internal/unraid/pools_test.go",
+ "bytes": 2301,
+ "sha256": "2d5857a37a6b05f510f97531e8acb69f26bbc64ab3e1bb8affce02611d9e3257"
+ },
+ {
+ "path": "internal/unraid/pools.go",
+ "bytes": 2300,
+ "sha256": "beb61f6914531114ba07b8571eac136675fc7020cc9396cf5675031698859e16"
+ },
+ {
+ "path": "internal/unraid/shares_test.go",
+ "bytes": 1079,
+ "sha256": "507e71d4475de242a090222e5afd166506b7887f8504727cb80ee9744287f766"
+ },
+ {
+ "path": "internal/unraid/shares.go",
+ "bytes": 2476,
+ "sha256": "ab9f9a2706d72d8715695999b935ebaf507545cf7c3f6194f7f77bb66554fd9b"
+ },
+ {
+ "path": "internal/widget/registry_test.go",
+ "bytes": 631,
+ "sha256": "59e2492c0a68cdf0442120f25ff8ab7f95725d7af7570ac22c58b5f8f792fc52"
+ },
+ {
+ "path": "internal/widget/registry.go",
+ "bytes": 3127,
+ "sha256": "78638aa79f6cf6df773d19f2b0fd9b40ba17d5d0e1973557ec52f63373bc55cd"
+ },
+ {
+ "path": "internal/widgetapi/handler_test.go",
+ "bytes": 3160,
+ "sha256": "06909b1c871449744ec1d4360a9590d6c48b74f5d2b0c94ac262fd56898fc9b6"
+ },
+ {
+ "path": "internal/widgetapi/handler.go",
+ "bytes": 2809,
+ "sha256": "ff0996aafc3c7481e8759aa039ee7f484471f5ce94454e62614f207e661740e2"
+ },
+ {
+ "path": "internal/widgetpreview/preview_test.go",
+ "bytes": 1818,
+ "sha256": "027a894194f4db654c91cb82c95025baf2edbe537b1ffbddbefdb0756348d2fb"
+ },
+ {
+ "path": "internal/widgetpreview/preview.go",
+ "bytes": 5940,
+ "sha256": "9fc08702b2d594e7494cd3c7225829457ba3ba6ddcd3bb315016095ff197d2bc"
+ },
+ {
+ "path": "internal/workerruntime/alertjob.go",
+ "bytes": 18242,
+ "sha256": "606975a9fc5783ab99af0550349b4392eb5bbc2ef428b958f95935d9eca29fde"
+ },
+ {
+ "path": "internal/workerruntime/discoveryjob.go",
+ "bytes": 26387,
+ "sha256": "124baa10539b073dba44c62d77f1189a61419a611741dea255753efee24b05ed"
+ },
+ {
+ "path": "internal/workerruntime/inventory_discovery_integration_test.go",
+ "bytes": 4332,
+ "sha256": "e1a4bba16dbb2fd9d88528d5ceec781d8bbb69d8a91974d53f726ebc747e094d"
+ },
+ {
+ "path": "internal/workerruntime/jobs_test.go",
+ "bytes": 26790,
+ "sha256": "1fb4327b2ed7d085596f79cd03510ed538adf74c6f1213b9bc0e92a1ed329f79"
+ },
+ {
+ "path": "internal/workerruntime/lease.go",
+ "bytes": 10371,
+ "sha256": "55de7f47dc35323fdfa4d31bbd518cff6b9314b399cb33b81010332bd38491f0"
+ },
+ {
+ "path": "internal/workerruntime/metricsource_test.go",
+ "bytes": 2707,
+ "sha256": "e9602aedfa6c527ba080c4ef32d81844cd4db0e2a9356922fd563961576661ec"
+ },
+ {
+ "path": "internal/workerruntime/notificationjob.go",
+ "bytes": 4176,
+ "sha256": "ce4a6222bdf6d0647351d45c8237e11deac068e512a7e1b21b1322941fb1428b"
+ },
+ {
+ "path": "internal/workerruntime/postgres_integration_test.go",
+ "bytes": 7774,
+ "sha256": "a5db0c0075a92887e556e5979618ed721cb52bc0eb8a69b0f0aadbf8a90df9b2"
+ },
+ {
+ "path": "internal/workerruntime/probejob.go",
+ "bytes": 11687,
+ "sha256": "58128b3b6f96b2d59a7d22579d868f855135289774a8b93b4535dfabdf4e639e"
+ },
+ {
+ "path": "internal/workerruntime/runtime_test.go",
+ "bytes": 19146,
+ "sha256": "af2b145e73027772430a1c73de8c6620329aff7095a5ebeebd70d0eef953d268"
+ },
+ {
+ "path": "internal/workerruntime/runtime.go",
+ "bytes": 19088,
+ "sha256": "2b6e088c0904810a7f160564b43006627d2b043e6720e9acb7cb0316b2428b4b"
+ },
+ {
+ "path": "internal/workerruntime/schedule.go",
+ "bytes": 3303,
+ "sha256": "938b8bf3de9bbe2e5754f47eccfe1ff9c16b0fb884145bc751f467b23c708603"
+ },
+ {
+ "path": "internal/workerruntime/status.go",
+ "bytes": 3415,
+ "sha256": "f6212a551bfe4b1afd95823e1d14cf804bf478d41913f8a13e85962049be87a1"
+ },
+ {
+ "path": "LICENSE",
+ "bytes": 34523,
+ "sha256": "0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0"
+ },
+ {
+ "path": "Makefile",
+ "bytes": 784,
+ "sha256": "0c3b2573977c40dd96a34bc215c76264689a00e5a8594407c1020f47742ea3de"
+ },
+ {
+ "path": "PACKAGE_VERSION",
+ "bytes": 6,
+ "sha256": "acb57a7135b2d7d6e665f67f056e21353023b93835f54def1ae523bf76f1bfb3"
+ },
+ {
+ "path": "package.json",
+ "bytes": 658,
+ "sha256": "9675238f7793c66b5554261ad7c21efd0347bff43f675c816d2810371335824c"
+ },
+ {
+ "path": "pnpm-lock.yaml",
+ "bytes": 60519,
+ "sha256": "64a540c83307eab5a83bdabd5fd128ead91d166a155b3c51568318999756c1f2"
+ },
+ {
+ "path": "pnpm-workspace.yaml",
+ "bytes": 38,
+ "sha256": "6a7030c7d39d70711fd0a50f8a08d5a62bfc5e75ee513bb8529d9f153c21673c"
+ },
+ {
+ "path": "README.md",
+ "bytes": 4690,
+ "sha256": "5dfc28281b63f0bd9c58e322115926a1aab104f33662d1aaccbf88fb428b2a0e"
+ },
+ {
+ "path": "requirements-dev.txt",
+ "bytes": 19,
+ "sha256": "756cc9e506ae4ee1a6f6c0507088b5cfc0dc8ba350fb2d2d46f1ffa72033adb6"
+ },
+ {
+ "path": "scripts/bootstrap.ps1",
+ "bytes": 397,
+ "sha256": "61dea33e004f79fcfc14d09043912badef897665a9a7d5787c01202830185215"
+ },
+ {
+ "path": "scripts/build.ps1",
+ "bytes": 748,
+ "sha256": "e7f213dcbab9bfb47a2f2a259b17f79352d5519f614fa5b1a5ad6eeb030e7015"
+ },
+ {
+ "path": "scripts/export-public-source.mjs",
+ "bytes": 6373,
+ "sha256": "cd344471a1ee82668a845d1d1cd38704197df3a10fa90d4807a233207a1d1cb1"
+ },
+ {
+ "path": "scripts/integration-smoke.ps1",
+ "bytes": 10781,
+ "sha256": "c7c14a2d4418fdfc12d7bec6f71288c45ecb2e370447cb356830d9c0638199d1"
+ },
+ {
+ "path": "scripts/lint.ps1",
+ "bytes": 616,
+ "sha256": "3dae378474c7336a98cbe607f0d23757655d47f79ea5cf73ec9114c1d7b66d49"
+ },
+ {
+ "path": "scripts/production-smoke.ps1",
+ "bytes": 5292,
+ "sha256": "071c108bc64825747c22c642dc03d2be04ae171f375e280d61193abf781a8f3c"
+ },
+ {
+ "path": "scripts/public-verify.ps1",
+ "bytes": 1730,
+ "sha256": "60260a455045a02b52b3f40969e9a72c6aaacf29f6f66800cd5a8718bf4cf4a0"
+ },
+ {
+ "path": "scripts/run-trivy-fs-scan.sh",
+ "bytes": 1283,
+ "sha256": "40ee184ebed01f534a087b85593986833d948d0e76eb906638130bf2c61836ab"
+ },
+ {
+ "path": "scripts/test.ps1",
+ "bytes": 593,
+ "sha256": "f042bb3cc90e0a60ec5c153ce6ac23908e1f2a3b5d285e3248b7ee002ec6e9aa"
+ },
+ {
+ "path": "scripts/validate-public-source.mjs",
+ "bytes": 1704,
+ "sha256": "3f921525d90f5e3f509dd5b84c45b95d42869fb1a261bc88c7887d39ed20f1ef"
+ },
+ {
+ "path": "scripts/wallboard-soak.ps1",
+ "bytes": 4591,
+ "sha256": "730dc902c28c965f6cefce29b5738fe01bf9c5bebca963e82a909139d106bac3"
+ },
+ {
+ "path": "SECURITY.md",
+ "bytes": 1706,
+ "sha256": "9a0002a2fda4d245dd48c9b4483bbeebb01192b7ec0edfe47b8eb6cc4f090377"
+ },
+ {
+ "path": "specs/alert-rule-set.schema.json",
+ "bytes": 439,
+ "sha256": "6c355e894e9581f90bdfa8e7f42765247d2e0b501907904b7de10d03230eed08"
+ },
+ {
+ "path": "specs/alert-rule.schema.json",
+ "bytes": 3358,
+ "sha256": "ec7b641d0ca7fb1f302ade3c62a2ec4f74c51527958983dc63ea0a2b61339281"
+ },
+ {
+ "path": "specs/api-routes.json",
+ "bytes": 13627,
+ "sha256": "a02c47d8c6c2676ef93c2e13263aee72fdf950c96ef31a7e0210141e3efe6863"
+ },
+ {
+ "path": "specs/capability.schema.json",
+ "bytes": 1183,
+ "sha256": "b2d12876cad6616bcdd3ddc09dd30b8f28a8e781aca48cbe624f9bda88fb78b3"
+ },
+ {
+ "path": "specs/dashboard.schema.json",
+ "bytes": 2564,
+ "sha256": "ee3a0e63430815a69b340e11ca47510a36fd6f35377c7e35ceed32df2207e368"
+ },
+ {
+ "path": "specs/entity.schema.json",
+ "bytes": 3235,
+ "sha256": "133663dea96883e2b075f3e98c45f7177cdfecdb5975270de509119283eb21ff"
+ },
+ {
+ "path": "specs/event.schema.json",
+ "bytes": 1550,
+ "sha256": "63687e38ae42fc9a69d4d16d3e0084e3483c82d4d59e3d5eeea42b2a2b0a4dd1"
+ },
+ {
+ "path": "specs/live-message.schema.json",
+ "bytes": 6199,
+ "sha256": "8c0eb95cee7246d75d3397e56485d15d6e511a5cf18fd5ce96f3dc7e19615825"
+ },
+ {
+ "path": "specs/metric-catalog.schema.json",
+ "bytes": 459,
+ "sha256": "371c5a313e865b0901aaf155f48046de38394340074e046f583247fa9c108eb1"
+ },
+ {
+ "path": "specs/metric-definition.schema.json",
+ "bytes": 3997,
+ "sha256": "e7688f4af41edb7550716a6f59dada9ad494ea12133055d35d2bbf5ac7c466fd"
+ },
+ {
+ "path": "specs/probe.schema.json",
+ "bytes": 2387,
+ "sha256": "2d124d33000a8cac24c35e098d2417e7b77aadf6f40366bba2850de69701d82a"
+ },
+ {
+ "path": "specs/simulator-scenario.schema.json",
+ "bytes": 2289,
+ "sha256": "163efbc7c3a9aa88c9cb8cbfdfb5630d666ac769fe7c4463974a7cd60c225d9b"
+ },
+ {
+ "path": "specs/task-ledger.schema.json",
+ "bytes": 2044,
+ "sha256": "fb740a247691b202b350e488856d7fae7f1b4590e484214260f42eb9a244440c"
+ },
+ {
+ "path": "specs/widget-instance.schema.json",
+ "bytes": 4943,
+ "sha256": "b6987c270484bc37af508eabd3f58cb145e9879d4cde87e5dec3a430ce83ca3b"
+ },
+ {
+ "path": "tools/analyze-wallboard-soak.mjs",
+ "bytes": 3102,
+ "sha256": "c077c019ae465a84050be7952efbc37212b3a08b7afd3e18c3c53c9547933346"
+ },
+ {
+ "path": "tools/check_api_contract.py",
+ "bytes": 3086,
+ "sha256": "43c3d097e662975da7f00f19b49f3b2fb0c65ca30d128b186f6d4aaba8a16133"
+ },
+ {
+ "path": "tools/check_secrets.py",
+ "bytes": 1398,
+ "sha256": "2487e38874d25a6f8bb076b3cf99d6f9ce9528c2feb53af10e4854f8134f0563"
+ },
+ {
+ "path": "tools/check_wiring.py",
+ "bytes": 12042,
+ "sha256": "b2f12bd542aa90d2e46de40dc29724a82c4a727a9d4a96309c3fc8a5672350f5"
+ },
+ {
+ "path": "tools/deadman_check.py",
+ "bytes": 1933,
+ "sha256": "2ea6eddb522a4586573e7fcf4fd8d836d79344ff927d1b1333512e856f6a60c4"
+ },
+ {
+ "path": "tools/integrationfixture/main.go",
+ "bytes": 4154,
+ "sha256": "11b6e1861342a1aa00f713198e5d98e54ebc532447e678954ef18182678a81b2"
+ },
+ {
+ "path": "tools/validate_contracts.py",
+ "bytes": 3355,
+ "sha256": "c6f86decbead6cf481691f7f0f58269842de8fe698ad38d6158ebf812f3209f6"
+ },
+ {
+ "path": "tools/wallboard-soak-analysis.mjs",
+ "bytes": 5518,
+ "sha256": "a8aee8733210c0b681dc434d22f1dacd87a7bbaed234dbb6549a6f23e4b7e934"
+ },
+ {
+ "path": "tools/wallboard-soak.mjs",
+ "bytes": 13027,
+ "sha256": "422e6293cbfa782ea306dbec2ecaa6cd116e8d8506d6a26db81a8be527ee0b63"
+ },
+ {
+ "path": "tools/wiring_allowlist.json",
+ "bytes": 503,
+ "sha256": "413720757d12cbab530ebb8347ddfc32418f20f6934843750e441569c6a3359a"
+ }
+ ]
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..299834c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,92 @@
+# ITWorx Pulse
+
+ITWorx Pulse is een self-hosted observabilityplatform voor Unraid. Het brengt host-, container-, storage-, netwerk- en servicetelemetrie samen in één operatorgerichte interface, met configureerbare dashboards, alerts, incidenten en wallboards.
+
+Pulse is operationeel **read-only**: het observeert en verklaart, maar start, stopt, verwijdert of repareert geen infrastructuur. Ontbrekende of verouderde telemetrie verschijnt als `Onbekend`, nooit als vals groen.
+
+Actuele release: **v1.5.0**.
+
+## Wat je als gebruiker krijgt
+
+- een overzicht dat meteen toont wat gezond, gedegradeerd, kritisch of onbekend is en waarom;
+- detailpagina's voor host, processen, containers, applicaties, array, disks, pools, shares, services en netwerk;
+- versieerbare dashboards met afzonderlijke desktop-, tablet-, mobiele en wallboardlayouts;
+- begrensde historische en live Prometheus-query's zonder dat gebruikers zelf PromQL moeten schrijven;
+- alerts met pending/recovery, hysterese, suppressie, silences en maintenance;
+- incidentgroepering, tijdlijnen, notities, ownership en audit;
+- Authentik/OIDC-login met viewer-, operator-, editor- en administratorrollen;
+- controleerbare backups en een clean-room herstelprocedure;
+- hardened non-root containers en een minimale read-only collectorgrens.
+
+## Platform in één oogopslag
+
+```text
+Browser
+ └─ HTTPS / OIDC / REST / WebSocket
+ └─ Pulse Web + API
+ ├─ PostgreSQL configuratie, inventory, dashboards, alerts en audit
+ ├─ Prometheus begrensde historische en live metrics
+ ├─ Pulse Worker discovery, probes, alerting en notifications
+ └─ Pulse Agent minimale read-only Unraid- en hostobservatie
+```
+
+De web/API-laag krijgt geen Docker-socket of host-roottoegang. Alleen de agent ontvangt de expliciet geconfigureerde read-only bronnen die nodig zijn voor observatie.
+
+## Snel lokaal proberen
+
+Vereisten: Go 1.26.6, Node.js 24+, pnpm 10.33+, Python 3, PowerShell 7 en Docker Compose.
+
+```powershell
+Copy-Item .env.example .env
+pwsh -NoProfile -File scripts/bootstrap.ps1
+docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up --build
+```
+
+Open daarna `http://localhost:18080`. De ontwikkelstack gebruikt expliciete mock-authenticatie en geïsoleerde lokale data; gebruik hiervoor nooit productiecredentials of productiedata.
+
+Stop en verwijder alleen deze lokale stack met:
+
+```powershell
+docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml down --volumes
+```
+
+## Valideren
+
+De private engineeringrepository gebruikt een uitgebreidere evidencegate. Een publieke source-export valideert de productcode met:
+
+```powershell
+pwsh -NoProfile -File scripts/public-verify.ps1
+```
+
+Deze gate controleert Go-tests/vet, frontendtests/typecheck/build, contracten, wiring, secretmarkers, image-digests en de publieke source boundary. De optionele Docker-integratiesmoke staat in `scripts/integration-smoke.ps1`.
+
+## Productie
+
+Begin bij [`docs/PUBLIC_DEPLOYMENT.md`](docs/PUBLIC_DEPLOYMENT.md). Productie vereist onder meer:
+
+- HTTPS en een Authentik/OIDC-provider;
+- een private PostgreSQL-database en externe secrets;
+- een expliciete Prometheusbron;
+- een least-privilege Unraid API-token en gecontroleerde CA-mount wanneer de agent de Unraid API gebruikt;
+- een operator-owned backupdirectory;
+- validatie van poorten, netwerken, mounts en rollback vóór de eerste start.
+
+De voorbeeldconfiguratie faalt bewust dicht wanneer verplichte productie-instellingen ontbreken.
+
+## Documentatie
+
+| Onderwerp | Document |
+|---|---|
+| Productscope en gebruikersflows | [`docs/product/PRODUCT_REQUIREMENTS.md`](docs/product/PRODUCT_REQUIREMENTS.md) |
+| Architectuur | [`docs/architecture/SYSTEM_ARCHITECTURE.md`](docs/architecture/SYSTEM_ARCHITECTURE.md) |
+| API en WebSocketcontract | [`docs/architecture/API_CONTRACT.md`](docs/architecture/API_CONTRACT.md) |
+| Securitymodel | [`docs/architecture/SECURITY_THREAT_MODEL.md`](docs/architecture/SECURITY_THREAT_MODEL.md) |
+| Lokale ontwikkeling | [`docs/operations/DEVELOPMENT_SETUP.md`](docs/operations/DEVELOPMENT_SETUP.md) |
+| Backup en herstel | [`docs/operations/BACKUP_RESTORE.md`](docs/operations/BACKUP_RESTORE.md) |
+| Publicatiegrens | [`docs/PUBLIC_SOURCE_BOUNDARY.md`](docs/PUBLIC_SOURCE_BOUNDARY.md) |
+
+## Bijdragen en security
+
+Lees [`CONTRIBUTING.md`](CONTRIBUTING.md) voordat je een wijziging indient. Meld kwetsbaarheden niet in een publieke issue; volg [`SECURITY.md`](SECURITY.md).
+
+First-party broncode in deze repository is gelicentieerd onder **AGPL-3.0-or-later**; zie [`LICENSE`](LICENSE). Componenten en assets van derden behouden hun eigen licentievoorwaarden.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..be9e2fe
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,19 @@
+# Security Policy
+
+## Supported code
+
+Security fixes target the current release line on `master`. Older releases may receive a fix when the same issue is still relevant and a safe backport is practical.
+
+## Reporting vulnerabilities
+
+Report suspected vulnerabilities privately. Do not open a public issue containing access tokens, OIDC secrets, session material, private dashboards, host inventories, alert payloads, infrastructure topology, backup contents, database credentials, production telemetry, or exploit-sensitive evidence.
+
+Include the affected release or commit, component, minimal reproduction conditions using synthetic telemetry where possible, expected and observed behaviour, and impact. Call out effects on authentication/RBAC, query bounds, WebSocket subscriptions, agent isolation, backup/restore, deployment, secret handling, or the read-only product boundary.
+
+Email reports to `security@itworx.tech`. This monitored mailbox is the permanent private reporting channel for the project.
+
+## Security boundary
+
+Pulse is an observability product. Contributions must not silently introduce mutation of monitored Unraid, storage, container, service, or network resources. Unknown or stale telemetry remains explicit, authentication fails closed, and runtime containers retain their documented least-privilege boundaries.
+
+Never commit live `.env` files, production credentials, private backups, unredacted production data, or operator-specific infrastructure evidence. Agent instructions and engineering evidence retained in the canonical private repository are development context, not executable production authority; public source archives exclude that context through `.gitattributes`.
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..8f3c26b
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ ITWorx Pulse
+
+
+
+
+
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..7d17800
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@itworx/pulse-web",
+ "private": true,
+ "version": "1.5.0",
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite",
+ "lint": "tsc --noEmit",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage",
+ "test:e2e": "playwright test"
+ },
+ "dependencies": {
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@axe-core/playwright": "^4.12.1",
+ "@playwright/test": "^1.62.1",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.3",
+ "@types/node": "^26.2.0",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "@vitejs/plugin-react": "6.0.5",
+ "@vitest/coverage-v8": "^4.1.10",
+ "jsdom": "^30.0.1",
+ "typescript": "7.0.2",
+ "vite": "8.2.0",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
new file mode 100644
index 0000000..6a313ef
--- /dev/null
+++ b/apps/web/playwright.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const realStackBaseURL = process.env.PULSE_E2E_REAL_BASE_URL;
+const localWebPort = process.env.PULSE_E2E_WEB_PORT || '4173';
+const localBaseURL = `http://127.0.0.1:${localWebPort}`;
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ outputDir: '../../test-results/playwright',
+ reporter: [['list'], ['html', { outputFolder: '../../playwright-report', open: 'never' }]],
+ timeout: 30_000,
+ expect: { timeout: 5_000 },
+ fullyParallel: true,
+ forbidOnly: Boolean(process.env.CI),
+ retries: process.env.CI ? 1 : 0,
+ use: {
+ baseURL: realStackBaseURL || localBaseURL,
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ },
+ webServer: realStackBaseURL ? undefined : {
+ command: `pnpm dev --host 127.0.0.1 --port ${localWebPort} --strictPort`,
+ url: `${localBaseURL}/healthz`,
+ reuseExistingServer: false,
+ timeout: 120_000,
+ },
+ projects: [
+ { name: 'desktop-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
+ { name: 'tablet-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1024, height: 768 } } },
+ { name: 'mobile-chromium', use: { ...devices['Pixel 7'], viewport: { width: 390, height: 844 } } },
+ { name: 'wallboard-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } } },
+ ],
+});
diff --git a/apps/web/public/pulse-icon.svg b/apps/web/public/pulse-icon.svg
new file mode 100644
index 0000000..f964baf
--- /dev/null
+++ b/apps/web/public/pulse-icon.svg
@@ -0,0 +1,21 @@
+
+ ITWorx Pulse
+ Een blauwe Pulse-letter P met een operationele signaalgolf.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/AlertControlsPanel.tsx b/apps/web/src/AlertControlsPanel.tsx
new file mode 100644
index 0000000..017217d
--- /dev/null
+++ b/apps/web/src/AlertControlsPanel.tsx
@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+
+type Matcher = { ruleIds?: string[]; entityIds?: string[]; entityTypes?: string[]; severities?: string[]; labels?: Record };
+type Silence = { id: string; name: string; reason: string; owner: string; matchers: Matcher; startsAt: string; expiresAt: string; state: string; revision: number };
+type Maintenance = { id: string; name: string; reason: string; selector: Matcher; startsAt: string; endsAt: string; state: string; revision: number };
+type ControlForm = { name: string; reason: string; matcher: string; startsAt: string; expiresAt: string };
+
+function localDate(offsetHours: number) { const date = new Date(Date.now() + offsetHours * 3600000); const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); return local.toISOString().slice(0, 16); }
+function toUTC(value: string) { return new Date(value).toISOString(); }
+function matcherFor(value: string, key: 'severity' | 'entityType'): Matcher { const values = value.split(',').map((item) => item.trim()).filter(Boolean); return key === 'severity' ? { severities: values } : { entityTypes: values }; }
+function stateLabel(value: string) { return value === 'active' ? copy.alerts.controlActive : value === 'scheduled' ? copy.alerts.controlScheduled : value === 'expired' ? copy.alerts.controlExpired : copy.alerts.controlRevoked; }
+
+export function AlertControlsPanel() {
+ const [silences, setSilences] = useState([]);
+ const [maintenance, setMaintenance] = useState([]);
+ const [silenceForm, setSilenceForm] = useState({ name: '', reason: '', matcher: 'critical', startsAt: localDate(0), expiresAt: localDate(1) });
+ const [maintenanceForm, setMaintenanceForm] = useState({ name: '', reason: '', matcher: 'host', startsAt: localDate(0), expiresAt: localDate(1) });
+ const [preview, setPreview] = useState('');
+ const [message, setMessage] = useState('');
+
+ async function load() {
+ const [silenceResponse, maintenanceResponse] = await Promise.all([fetch('/api/v1/alert-silences?limit=100'), fetch('/api/v1/maintenance-windows?limit=100')]);
+ if (silenceResponse.ok) setSilences(((await silenceResponse.json()) as { items?: Silence[] }).items ?? []);
+ if (maintenanceResponse.ok) setMaintenance(((await maintenanceResponse.json()) as { items?: Maintenance[] }).items ?? []);
+ }
+ useEffect(() => { void load(); }, []);
+
+ async function createSilence() {
+ setMessage('');
+ const response = await fetch('/api/v1/alert-silences', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: silenceForm.name, reason: silenceForm.reason, owner: 'current-user', matchers: matcherFor(silenceForm.matcher, 'severity'), startsAt: toUTC(silenceForm.startsAt), expiresAt: toUTC(silenceForm.expiresAt) }) });
+ if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
+ setSilenceForm({ ...silenceForm, name: '', reason: '' }); setMessage(copy.alerts.controlSaved); await load();
+ }
+ async function createMaintenance() {
+ setMessage('');
+ const response = await fetch('/api/v1/maintenance-windows', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: maintenanceForm.name, reason: maintenanceForm.reason, selector: matcherFor(maintenanceForm.matcher, 'entityType'), startsAt: toUTC(maintenanceForm.startsAt), endsAt: toUTC(maintenanceForm.expiresAt) }) });
+ if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
+ setMaintenanceForm({ ...maintenanceForm, name: '', reason: '' }); setMessage(copy.alerts.controlSaved); await load();
+ }
+ async function revoke(kind: 'silence' | 'maintenance', id: string, revision: number) {
+ if (!window.confirm(copy.alerts.confirmRevoke)) return;
+ const endpoint = kind === 'silence' ? '/api/v1/alert-silences/' : '/api/v1/maintenance-windows/';
+ const response = await fetch(endpoint + encodeURIComponent(id) + '/revoke?revision=' + revision, { method: 'POST' });
+ if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
+ await load();
+ }
+ async function previewMatcher() {
+ const response = await fetch('/api/v1/alert-silences/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ matcher: matcherFor(silenceForm.matcher, 'severity'), signals: [{ instanceId: 'preview-signal', severity: silenceForm.matcher.split(',')[0].trim() }] }) });
+ if (!response.ok) { setPreview(copy.alerts.controlPreviewError); return; }
+ const data = await response.json() as { preview?: { matchedCount?: number } };
+ setPreview(copy.alerts.controlPreview + ': ' + String(data.preview?.matchedCount ?? 0));
+ }
+
+ return
+ {copy.alerts.controls}
{copy.alerts.controlsTitle} {copy.alerts.controlHistory}
+ {copy.alerts.controlsIntro}
+
+ {message && {message}
}
+ {copy.alerts.silenceHistory} {silences.length === 0 ?
{copy.alerts.noControls}
:
{silences.map((item) => {item.name} {stateLabel(item.state)} · {item.reason} {item.state === 'active' && void revoke('silence', item.id, item.revision)}>{copy.alerts.revoke} } )} }
{copy.alerts.maintenanceHistory} {maintenance.length === 0 ?
{copy.alerts.noControls}
:
{maintenance.map((item) => {item.name} {stateLabel(item.state)} · {item.reason} {item.state === 'active' && void revoke('maintenance', item.id, item.revision)}>{copy.alerts.revoke} } )} }
+ ;
+}
diff --git a/apps/web/src/AlertOperationsPanel.tsx b/apps/web/src/AlertOperationsPanel.tsx
new file mode 100644
index 0000000..51f28f1
--- /dev/null
+++ b/apps/web/src/AlertOperationsPanel.tsx
@@ -0,0 +1,96 @@
+import { useEffect, useMemo, useState } from 'react';
+
+import { copy } from './copy';
+import { plural, presentReason, presentStatus } from './presentation';
+
+type AlertItem = { id: string; state: string; retainedState: string; ruleName: string; severity: string; entityName?: string; reason: string; revision: number; acknowledgedBy?: string; updatedAt?: string; occurrences?: Array<{ eventType: string; from: string; to: string; observedAt: string; reason: string }> };
+type AlertView = 'active' | 'critical' | 'acknowledged';
+
+const stateOrder: Record = { firing: 0, pending: 1, acknowledged: 2, silenced: 3, suppressed: 4, resolved: 5 };
+const severityOrder: Record = { critical: 0, degraded: 1, attention: 2, warning: 3, info: 4, unknown: 5 };
+
+function alertTone(severity: string): 'critical' | 'attention' | 'ready' | 'unknown' {
+ if (severity === 'critical') return 'critical';
+ if (severity === 'degraded' || severity === 'attention' || severity === 'warning') return 'attention';
+ if (severity === 'info') return 'ready';
+ return 'unknown';
+}
+
+function isActive(item: AlertItem): boolean { return item.state !== 'resolved'; }
+
+export function AlertOperationsPanel() {
+ const [items, setItems] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [message, setMessage] = useState('');
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [view, setView] = useState('active');
+
+ async function load(signal?: AbortSignal) {
+ try {
+ const response = await fetch('/api/v1/alerts?limit=100', { signal });
+ if (!response.ok) throw new Error('alerts');
+ setItems(((await response.json()) as { items?: AlertItem[] }).items ?? []);
+ setState('ready');
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState('error');
+ }
+ }
+
+ useEffect(() => {
+ const controller = new AbortController();
+ void load(controller.signal);
+ return () => controller.abort();
+ }, []);
+
+ const ordered = useMemo(() => items.slice().sort((left, right) =>
+ (severityOrder[left.severity] ?? 9) - (severityOrder[right.severity] ?? 9)
+ || (stateOrder[left.state] ?? 9) - (stateOrder[right.state] ?? 9)
+ || (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '')
+ || left.ruleName.localeCompare(right.ruleName, 'nl-BE')
+ || left.id.localeCompare(right.id)), [items]);
+ const activeCount = items.filter(isActive).length;
+ const criticalCount = items.filter((item) => isActive(item) && item.severity === 'critical').length;
+ const acknowledgedCount = items.filter((item) => item.state === 'acknowledged').length;
+ const filtered = ordered.filter((item) => view === 'critical' ? isActive(item) && item.severity === 'critical' : view === 'acknowledged' ? item.state === 'acknowledged' : isActive(item));
+ const visible = filtered.slice(0, 20);
+
+ async function choose(item: AlertItem) {
+ const response = await fetch('/api/v1/alerts/' + encodeURIComponent(item.id) + '?occurrenceLimit=50');
+ if (response.ok) setSelected(((await response.json()) as { alert: AlertItem }).alert);
+ }
+
+ async function operate(item: AlertItem, acknowledge: boolean) {
+ if (!window.confirm(acknowledge ? copy.alerts.confirmAcknowledge : copy.alerts.confirmUnacknowledge)) return;
+ setMessage('');
+ const response = await fetch('/api/v1/alerts/' + encodeURIComponent(item.id) + '/' + (acknowledge ? 'acknowledge' : 'unacknowledge'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'If-Match': String(item.revision), 'Idempotency-Key': (globalThis.crypto?.randomUUID?.() ?? String(Date.now())) },
+ body: '{}',
+ });
+ if (!response.ok) { setMessage(copy.alerts.operationError); return; }
+ setMessage(copy.alerts.operationSaved);
+ await load();
+ if (selected?.id === item.id) await choose(item);
+ }
+
+ return
+
+ setView('active')}>{copy.alerts.activeAlerts} {activeCount} {copy.alerts.activeAlertsDetail}
+ setView('critical')}>{copy.alerts.criticalAlerts} {criticalCount} {copy.alerts.criticalAlertsDetail}
+ setView('acknowledged')}>{copy.alerts.acknowledgedAlerts} {acknowledgedCount} {copy.alerts.acknowledgedAlertsDetail}
+
+
+
{copy.alerts.operations}
{copy.alerts.alertList} {copy.alerts.historyPreserved}
+
{copy.alerts.operationsIntro}
+ {state === 'loading' ?
{copy.alerts.operationsLoading}
: state === 'error' ?
{copy.alerts.operationsError}
: visible.length === 0 ?
{copy.alerts.noActiveAlerts}
:
{visible.map((item) =>
+ {item.severity === 'critical' ? '!' : '•'} {presentStatus(item.severity)}
+ void choose(item)}>{item.ruleName || copy.alerts.unnamed} {presentStatus(item.state)}{item.entityName ? ' · ' + item.entityName : ''}
+ void operate(item, item.state !== 'acknowledged')}>{item.state === 'acknowledged' ? copy.alerts.unacknowledge : copy.alerts.acknowledge}
+ )} }
+ {filtered.length > visible.length &&
{copy.alerts.resultLimit.replace('{count}', String(visible.length)).replace('{total}', String(filtered.length))}
}
+ {selected &&
{selected.ruleName} {presentStatus(selected.state)} · {presentReason(selected.reason)}
{selected.occurrences?.length ?? 0} {plural(selected.occurrences?.length ?? 0, copy.alerts.occurrence, copy.alerts.occurrences)} · {copy.alerts.revision}: {selected.revision} }
+ {message &&
{message}
}
+
+ ;
+}
diff --git a/apps/web/src/AlertRulesPage.tsx b/apps/web/src/AlertRulesPage.tsx
new file mode 100644
index 0000000..d2fbd69
--- /dev/null
+++ b/apps/web/src/AlertRulesPage.tsx
@@ -0,0 +1,261 @@
+import { useEffect, useMemo, useState } from 'react';
+import { copy } from './copy';
+import { AlertControlsPanel } from './AlertControlsPanel';
+import { AlertOperationsPanel } from './AlertOperationsPanel';
+import { queryValue, replaceListQuery } from './listQuery';
+import { presentMetric, presentReason, presentStatus, presentUnit } from './presentation';
+
+type InputType = 'metric' | 'entity-status' | 'event' | 'datasource-health';
+type Condition = { inputType: InputType; metric?: string; operator: string; threshold: number | string | null; recoveryThreshold?: number | null; aggregation?: string; windowSeconds?: number };
+type Rule = {
+ id: string; schemaVersion: number; name: string; enabled: boolean; severity: string; scope: Record;
+ condition: Condition; evaluationIntervalSeconds: number; pendingSeconds: number; resolveSeconds: number; cooldownSeconds?: number;
+ unknownBehavior: string; groupBy: string[]; suppressWhen: string[]; message: { titleKey: string; bodyKey: string };
+ revision: number; currentVersion: number; updatedAt?: string;
+};
+type DraftRule = Omit;
+type Preview = { wouldFire: boolean; state: string; reason: string };
+type MetricDefinition = { semanticName: string; unit: string; defaultAggregation?: string };
+
+const causes = [
+ { value: 'host.unreachable', label: copy.alerts.causeHost },
+ { value: 'dns.failure', label: copy.alerts.causeDns },
+ { value: 'source.unavailable', label: copy.alerts.causeSource },
+];
+const orderedOperators = new Set(['>', '>=', '<', '<=']);
+const supportedOperators = new Set(['>', '>=', '<', '<=', '==', '!=', 'matches', 'absent']);
+const alertSections = ['operations', 'rules', 'controls'] as const;
+type AlertSection = typeof alertSections[number];
+
+function newRule(): DraftRule {
+ return {
+ id: globalThis.crypto?.randomUUID?.() ?? '00000000-0000-4000-8000-000000000000',
+ schemaVersion: 1, name: '', enabled: false, severity: 'attention', scope: {},
+ condition: { inputType: 'metric', metric: '', operator: '>', threshold: 80, aggregation: 'avg', windowSeconds: 60 },
+ evaluationIntervalSeconds: 30, pendingSeconds: 60, resolveSeconds: 120, cooldownSeconds: 300,
+ unknownBehavior: 'retain-firing-as-unknown', groupBy: [], suppressWhen: [],
+ message: { titleKey: 'alerts.rule.title', bodyKey: 'alerts.rule.body' },
+ };
+}
+
+export function AlertRulesPage() {
+ const [section, setSection] = useState(() => queryValue('section', alertSections, 'operations') as AlertSection);
+ const [rules, setRules] = useState([]);
+ const [draft, setDraft] = useState(newRule);
+ const [selectedId, setSelectedId] = useState(null);
+ const [state, setState] = useState<'loading' | 'ready' | 'error' | 'unauthorized'>('loading');
+ const [message, setMessage] = useState('');
+ const [preview, setPreview] = useState(null);
+ const [previewValue, setPreviewValue] = useState('90');
+ const [busy, setBusy] = useState(false);
+ const [metrics, setMetrics] = useState([]);
+ const [metricState, setMetricState] = useState<'loading' | 'ready' | 'error'>('loading');
+
+ const selected = useMemo(() => rules.find((rule) => rule.id === selectedId), [rules, selectedId]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ fetch('/api/v1/alert-rules?limit=100', { signal: controller.signal })
+ .then((response) => {
+ if (response.status === 401 || response.status === 403) { setState('unauthorized'); throw new Error('unauthorized'); }
+ if (!response.ok) throw new Error('alert-rules');
+ return response.json() as Promise<{ items?: Rule[] }>;
+ })
+ .then((data) => {
+ const items = data.items ?? [];
+ setRules(items);
+ if (items[0]) { setSelectedId(items[0].id); setDraft(toDraft(items[0])); }
+ setState('ready');
+ })
+ .catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ if (error instanceof Error && error.message === 'unauthorized') return;
+ setState('error');
+ });
+ return () => controller.abort();
+ }, []);
+
+ useEffect(() => {
+ replaceListQuery({ section: section === 'operations' ? '' : section });
+ }, [section]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ fetch('/api/v1/metrics/catalog', { signal: controller.signal })
+ .then((response) => {
+ if (!response.ok) throw new Error('metric-catalog');
+ return response.json() as Promise<{ metrics?: MetricDefinition[] }>;
+ })
+ .then((data) => {
+ setMetrics((data.metrics ?? []).slice().sort((a, b) => presentMetric(a.semanticName).localeCompare(presentMetric(b.semanticName), 'nl-BE')));
+ setMetricState('ready');
+ })
+ .catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setMetricState('error');
+ });
+ return () => controller.abort();
+ }, []);
+
+ function chooseRule(rule: Rule) {
+ setSelectedId(rule.id);
+ setDraft(toDraft(rule));
+ setPreview(null);
+ setMessage('');
+ }
+
+ function updateField(key: K, value: DraftRule[K]) {
+ setDraft((current) => ({ ...current, [key]: value }));
+ }
+
+ function updateCondition(key: K, value: Condition[K]) {
+ setDraft((current) => ({ ...current, condition: { ...current.condition, [key]: value } }));
+ }
+
+ function chooseInputType(inputType: InputType) {
+ const presets: Record = {
+ metric: { inputType, metric: '', operator: '>', threshold: 80, recoveryThreshold: null, aggregation: 'avg', windowSeconds: 60 },
+ event: { inputType, operator: '>=', threshold: 3, recoveryThreshold: null, aggregation: 'count', windowSeconds: 900 },
+ 'entity-status': { inputType, operator: '==', threshold: 'degraded', recoveryThreshold: null, aggregation: 'none', windowSeconds: 60 },
+ 'datasource-health': { inputType, operator: '==', threshold: 'stale', recoveryThreshold: null, aggregation: 'none', windowSeconds: 120 },
+ };
+ setDraft((current) => ({ ...current, condition: presets[inputType] }));
+ }
+
+ function chooseOperator(operator: string) {
+ setDraft((current) => ({
+ ...current,
+ condition: {
+ ...current.condition,
+ operator,
+ threshold: operator === 'absent' ? null : operator === 'matches' ? String(current.condition.threshold ?? '') : current.condition.threshold,
+ recoveryThreshold: orderedOperators.has(operator) ? current.condition.recoveryThreshold : null,
+ },
+ }));
+ }
+
+ async function save() {
+ const validation = validateDraft(draft, metrics);
+ if (validation) { setMessage(validation); return; }
+ setBusy(true); setMessage('');
+ try {
+ const selectedRule = selectedId ? rules.find((rule) => rule.id === selectedId) : undefined;
+ const response = await fetch(selectedRule ? '/api/v1/alert-rules/' + encodeURIComponent(selectedRule.id) : '/api/v1/alert-rules', {
+ method: selectedRule ? 'PUT' : 'POST',
+ headers: { 'Content-Type': 'application/json', ...(selectedRule ? { 'If-Match': String(selectedRule.revision) } : {}) },
+ body: JSON.stringify(draft),
+ });
+ if (!response.ok) throw new Error(response.status === 409 ? copy.alerts.conflict : copy.alerts.saveError);
+ const data = await response.json() as { rule: Rule };
+ setRules((current) => selectedRule ? current.map((rule) => rule.id === data.rule.id ? data.rule : rule) : [data.rule, ...current]);
+ setSelectedId(data.rule.id);
+ setDraft(toDraft(data.rule));
+ setMessage(copy.alerts.saved);
+ } catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.saveError); }
+ finally { setBusy(false); }
+ }
+
+ function toggleCause(value: string, checked: boolean) {
+ updateField('suppressWhen', checked
+ ? [...new Set([...draft.suppressWhen, value])]
+ : draft.suppressWhen.filter((cause) => cause !== value));
+ }
+
+ async function toggle(enabled: boolean) {
+ if (!selected) return;
+ if (!window.confirm(enabled ? copy.alerts.confirmRuleEnable : copy.alerts.confirmRuleDisable)) return;
+ setBusy(true); setMessage('');
+ try {
+ const response = await fetch('/api/v1/alert-rules/' + encodeURIComponent(selected.id) + '/' + (enabled ? 'enable' : 'disable'), {
+ method: 'POST', headers: { 'If-Match': String(selected.revision) },
+ });
+ if (!response.ok) throw new Error(response.status === 409 ? copy.alerts.conflict : copy.alerts.toggleError);
+ const data = await response.json() as { rule: Rule };
+ setRules((current) => current.map((rule) => rule.id === data.rule.id ? data.rule : rule));
+ setDraft(toDraft(data.rule));
+ setMessage(copy.alerts.stateSaved);
+ } catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.toggleError); }
+ finally { setBusy(false); }
+ }
+
+ async function testPreview() {
+ setBusy(true); setMessage('');
+ try {
+ const response = await fetch('/api/v1/alert-rules/' + encodeURIComponent(draft.id) + '/test', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rule: draft, value: parsePreviewValue(previewValue) }),
+ });
+ if (!response.ok) throw new Error(copy.alerts.previewError);
+ const data = await response.json() as { preview: Preview };
+ setPreview(data.preview);
+ } catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.previewError); }
+ finally { setBusy(false); }
+ }
+
+ if (state === 'loading') return ;
+ if (state === 'unauthorized') return ! {copy.alerts.unauthorizedTitle} {copy.alerts.unauthorizedDetail}
;
+ if (state === 'error') return × {copy.alerts.errorTitle} {copy.alerts.errorDetail}
;
+
+ return <>{copy.alerts.eyebrow}
{copy.alerts.title} {copy.alerts.intro}
+
+ setSection('operations')}>{copy.alerts.sectionOperations} {copy.alerts.sectionOperationsDetail}
+ setSection('rules')}>{copy.alerts.sectionRules} {copy.alerts.sectionRulesDetail}
+ setSection('controls')}>{copy.alerts.sectionControls} {copy.alerts.sectionControlsDetail}
+
+ {section === 'operations' && }
+ {section === 'rules' &&
+
+
{copy.alerts.rules}
{copy.alerts.ruleList} { setSelectedId(null); setDraft(newRule()); setPreview(null); }}>{copy.alerts.newRule}
+ {rules.length === 0 ?
{copy.alerts.noRules}
:
{rules.map((rule) => chooseRule(rule)}>{rule.name || copy.alerts.unnamed} {presentStatus(rule.severity)} · v{rule.currentVersion} · {rule.enabled ? copy.alerts.enabled : copy.alerts.disabled} {rule.enabled ? copy.alerts.enabled : copy.alerts.disabled} )} }
+
+
+
{copy.alerts.editor}
{selected ? copy.alerts.editRule : copy.alerts.createRule} {selected &&
v{selected.currentVersion} }
+
+
{copy.alerts.name} updateField('name', event.target.value)} maxLength={160} />
+
{copy.alerts.severity} updateField('severity', event.target.value)}>{copy.presentation.status.attention} {copy.presentation.status.degraded} {copy.presentation.status.critical}
+
{copy.alerts.inputType} chooseInputType(event.target.value as InputType)}>{copy.alerts.inputMetric} {copy.alerts.inputEvent} {copy.alerts.inputEntityStatus} {copy.alerts.inputDatasourceHealth} {copy.alerts.inputTypeHelp}
+ {draft.condition.inputType === 'metric' &&
{copy.alerts.metric} { const metric = metrics.find((item) => item.semanticName === event.target.value); updateCondition('metric', event.target.value); if (metric?.defaultAggregation) updateCondition('aggregation', metric.defaultAggregation); }}>{metricState === 'loading' ? copy.alerts.metricLoading : metricState === 'error' ? copy.alerts.metricUnavailable : copy.alerts.chooseMetric} {metrics.map((metric) => {presentMetric(metric.semanticName)} ({presentUnit(metric.unit)}) )} {copy.alerts.metricHelp} }
+
{copy.alerts.operator} chooseOperator(event.target.value)}>{copy.alerts.greaterThan} {copy.alerts.greaterThanOrEqual} {copy.alerts.lessThan} {copy.alerts.lessThanOrEqual} {copy.alerts.equalTo} {copy.alerts.notEqualTo} {copy.alerts.matches} {copy.alerts.absent}
+
{copy.alerts.threshold} updateCondition('threshold', event.target.type === 'number' ? Number(event.target.value) : event.target.value)} /> {orderedOperators.has(draft.condition.operator) &&
{copy.alerts.recoveryThreshold} updateCondition('recoveryThreshold', event.target.value === '' ? null : Number(event.target.value))} />{copy.alerts.recoveryThresholdHelp} }
+
{copy.alerts.interval} updateField('evaluationIntervalSeconds', Number(event.target.value))} />
+
{copy.alerts.pending} updateField('pendingSeconds', Number(event.target.value))} />
+
{copy.alerts.resolve} updateField('resolveSeconds', Number(event.target.value))} /> {copy.alerts.cooldown} updateField('cooldownSeconds', Number(event.target.value))} />{copy.alerts.cooldownHelp}
+
{copy.alerts.suppressWhen} {causes.map((cause) => toggleCause(cause.value, event.target.checked)} />{cause.label} )}{copy.alerts.suppressWhenHelp}
+
{copy.alerts.unknownBehavior} updateField('unknownBehavior', event.target.value)}>{copy.alerts.unknownRetain} {copy.alerts.unknownBecome} {copy.alerts.unknownIgnore}
+
{copy.alerts.technicalDetails}
{copy.alerts.titleKey} {draft.message.titleKey}
{copy.alerts.bodyKey} {draft.message.bodyKey} {draft.suppressWhen.map((cause) =>
{copy.alerts.suppressWhen} {cause} )}
+
+
void save()}>{copy.alerts.save} {selected && void toggle(!selected.enabled)}>{selected.enabled ? copy.alerts.disable : copy.alerts.enable} }
+ {message &&
{message}
}
+
{copy.alerts.preview}
{copy.alerts.previewTitle} {copy.alerts.previewDetail}
{copy.alerts.sampleValue} setPreviewValue(event.target.value)} /> void testPreview()}>{copy.alerts.runPreview} {preview &&
{presentStatus(preview.state)}: {presentReason(preview.reason)}
}
+
+ }
+ {section === 'controls' && }
+ >;
+}
+
+function toDraft(rule: Rule): DraftRule {
+ const { revision: _revision, currentVersion: _version, updatedAt: _updatedAt, ...draft } = rule;
+ return { ...draft, cooldownSeconds: rule.cooldownSeconds ?? 0, condition: { ...draft.condition, recoveryThreshold: rule.condition.recoveryThreshold ?? null } };
+}
+
+export function validateDraft(draft: DraftRule, metrics: MetricDefinition[]): string {
+ if (!draft.name.trim()) return copy.alerts.invalidName;
+ if (draft.condition.inputType === 'metric' && (!draft.condition.metric || !metrics.some((metric) => metric.semanticName === draft.condition.metric))) return copy.alerts.invalidMetric;
+ if (draft.condition.inputType !== 'metric' && draft.condition.metric) return copy.alerts.invalidMetric;
+ if (!supportedOperators.has(draft.condition.operator)) return copy.alerts.invalidThreshold;
+ const threshold = draft.condition.threshold;
+ if (draft.condition.operator === 'absent' ? threshold !== null : orderedOperators.has(draft.condition.operator) ? typeof threshold !== 'number' || !Number.isFinite(threshold) : (typeof threshold !== 'number' || !Number.isFinite(threshold)) && (typeof threshold !== 'string' || !threshold.trim() || threshold.length > 160)) return copy.alerts.invalidThreshold;
+ if (draft.condition.operator === 'matches') {
+ try { new RegExp(String(threshold)); } catch { return copy.alerts.invalidThreshold; }
+ }
+ const recovery = draft.condition.recoveryThreshold;
+ if (recovery != null && (typeof threshold !== 'number' || !Number.isFinite(recovery) || !orderedOperators.has(draft.condition.operator) || ((draft.condition.operator === '>' || draft.condition.operator === '>=') && recovery >= threshold) || ((draft.condition.operator === '<' || draft.condition.operator === '<=') && recovery <= threshold))) return copy.alerts.invalidThreshold;
+ const times = [draft.evaluationIntervalSeconds, draft.pendingSeconds, draft.resolveSeconds, draft.cooldownSeconds ?? 0];
+ if (draft.evaluationIntervalSeconds < 5 || draft.evaluationIntervalSeconds > 3600 || times.some((value) => !Number.isInteger(value) || value < 0 || value > 2_592_000)) return copy.alerts.invalidTiming;
+ return '';
+}
+
+function parsePreviewValue(value: string): number | string {
+ const numeric = Number(value);
+ return value.trim() !== '' && Number.isFinite(numeric) ? numeric : value;
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
new file mode 100644
index 0000000..fdd50dd
--- /dev/null
+++ b/apps/web/src/App.tsx
@@ -0,0 +1,807 @@
+import { Component, Suspense, lazy, type CSSProperties, type ErrorInfo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { MetricWidget, metricStatus, RankedListWidget, StatusGridWidget, type MetricWidgetProps, type RankedListItem, type StatusGridItem } from './MetricWidgets';
+import { DashboardRuntimeWidget, type RuntimeState } from './DashboardRuntimeWidget';
+import { copy } from './copy';
+import { routeFromLocation, type RoutePath } from './routes';
+import { HostPage } from './HostPage';
+import { ArrayPage } from './ArrayPage';
+import { DiskDetailPage, DiskPage } from './DiskPage';
+import { PoolPage } from './PoolPage';
+import { SharePage } from './SharePage';
+import { StoragePage } from './StoragePage';
+import { CapacityPage } from './CapacityPage';
+import { StorageMapWidget, TemperatureHeatmap, type HeatmapPoint, type StorageMapNode } from './StorageVisuals';
+import { ContainerDetailPage, ContainerPage } from './ContainerPage';
+import { ApplicationPage } from './ApplicationPage';
+import { ServicePage } from './ServicePage';
+import type { TopologyData } from './TopologyPage';
+import { NetworkPage, NetworkHealthWidget, type NetworkData } from './NetworkPage';
+import { IncidentPage } from './IncidentPage';
+import { OnboardingPage } from './OnboardingPage';
+import { SystemStatusPage } from './SystemStatusPage';
+import { InventoryPage } from './InventoryPage';
+import { EventsPage } from './EventsPage';
+import { NotFoundPage } from './NotFoundPage';
+import { formatDateTime } from './locale';
+import { installSessionWatcher, onUnauthenticated } from './auth';
+import { AuthNoticeBanner, SignInButton } from './SignIn';
+import { aggregateStatus, refreshSystemStatus, statusProblems, useSystemStatus } from './systemStatus';
+import { operationalStorageState, presentComponent, presentReason, presentStatus } from './presentation';
+import { wallboardColumns, wallboardPlacement, wallboardSlideIndex } from './wallboardLayout';
+import { OperationalSignalPath, type OperationalSignalStage } from './OperationalSignalPath';
+import { containerSignalTone, signalToneFromState, signalToneRank, sourceSignalTone, worstSignalTone, type SignalTone } from './overviewSignals';
+
+// Heavy, rarely-used surfaces. The wallboard and mobile personas never execute
+// the editor stack, the alert-rule editor, the topology graph or the process
+// explorer, so those stay out of the initial chunk (FRONTEND_STANDARDS "Charts").
+const DashboardEditor = lazy(() => import('./DashboardEditor').then((module) => ({ default: module.DashboardEditor })));
+const AlertRulesPage = lazy(() => import('./AlertRulesPage').then((module) => ({ default: module.AlertRulesPage })));
+const TopologyPage = lazy(() => import('./TopologyPage').then((module) => ({ default: module.TopologyPage })));
+const TopologyWidget = lazy(() => import('./TopologyPage').then((module) => ({ default: module.TopologyWidget })));
+const ProcessPage = lazy(() => import('./ProcessPage').then((module) => ({ default: module.ProcessPage })));
+
+const navigation = [
+ { path: '/', label: copy.navigation.overview, icon: '⌂', group: copy.navigation.groups.command },
+ { path: '/dashboards', label: copy.navigation.dashboards, icon: '▦', group: copy.navigation.groups.command },
+ { path: '/host', label: copy.navigation.host, icon: '▣', group: copy.navigation.groups.infrastructure },
+ { path: '/array', label: copy.navigation.array, icon: '▥', group: copy.navigation.groups.infrastructure },
+ { path: '/disks', label: copy.navigation.disks, icon: '◉', group: copy.navigation.groups.infrastructure },
+ { path: '/pools', label: copy.navigation.pools, icon: '◫', group: copy.navigation.groups.infrastructure },
+ { path: '/shares', label: copy.navigation.shares, icon: '⇄', group: copy.navigation.groups.infrastructure },
+ { path: '/storage', label: copy.navigation.storage, icon: '▤', group: copy.navigation.groups.infrastructure },
+ { path: '/capacity', label: copy.navigation.capacity, icon: '⌁', group: copy.navigation.groups.infrastructure },
+ { path: '/network', label: copy.navigation.network, icon: '⌘', group: copy.navigation.groups.infrastructure },
+ { path: '/processes', label: copy.navigation.processes, icon: '≋', group: copy.navigation.groups.workloads },
+ { path: '/containers', label: copy.navigation.containers, icon: '⬡', group: copy.navigation.groups.workloads },
+ { path: '/applications', label: copy.navigation.applications, icon: '◆', group: copy.navigation.groups.workloads },
+ { path: '/services', label: copy.navigation.services, icon: '◉', group: copy.navigation.groups.services },
+ { path: '/topology', label: copy.navigation.topology, icon: '⌬', group: copy.navigation.groups.services },
+ { path: '/alerts', label: copy.navigation.alerts, icon: '!', group: copy.navigation.groups.response },
+ { path: '/events', label: copy.navigation.events, icon: '≡', group: copy.navigation.groups.response },
+ { path: '/incidents', label: copy.navigation.incidents, icon: '△', group: copy.navigation.groups.response },
+ { path: '/inventory', label: copy.navigation.inventory, icon: '▥', group: copy.navigation.groups.manage },
+ { path: '/wallboard', label: copy.navigation.wallboard, icon: '▰', group: copy.navigation.groups.manage },
+ { path: '/settings', label: copy.navigation.settings, icon: '⚙', group: copy.navigation.groups.manage },
+ { path: '/status', label: copy.navigation.status, icon: '♥', group: copy.navigation.groups.manage },
+ { path: '/onboarding', label: copy.navigation.onboarding, icon: '→', group: copy.navigation.groups.manage },
+] as const satisfies ReadonlyArray<{ path: RoutePath; label: string; icon: string; group: string }>;
+
+const mobilePrimaryPaths = new Set(['/', '/incidents', '/containers', '/storage']);
+const mobilePrimaryNavigation = ['/', '/incidents', '/containers', '/storage'].map((path) => navigation.find((item) => item.path === path)).filter((item): item is NavigationItem => Boolean(item));
+const mobileMoreNavigation = navigation.filter((item) => !mobilePrimaryPaths.has(item.path));
+type NavigationItem = (typeof navigation)[number];
+const navigationGroups = Array.from(new Set(navigation.map((item) => item.group)));
+function NavigationLink({ item, label = item.label }: { item: NavigationItem; label?: string }) {
+ const active = routeFromLocation(window.location.pathname) === item.path;
+ return { event.preventDefault(); navigate(item.path); }}>{item.icon} {label} ;
+}
+function DesktopNavigation({ route }: { route: RoutePath }) {
+ const activeGroup = navigation.find((item) => route === item.path || (item.path !== '/' && route.startsWith(item.path + '/')))?.group ?? navigationGroups[0];
+ const [openGroups, setOpenGroups] = useState>(() => new Set([navigationGroups[0], activeGroup]));
+ useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]);
+ return {navigationGroups.map((group) => {
+ const items = navigation.filter((item) => item.group === group);
+ return { const open = event.currentTarget.open; setOpenGroups((current) => { if (current.has(group) === open) return current; const next = new Set(current); if (open) next.add(group); else next.delete(group); return next; }); }}>{group} ⌄ ;
+ })} ;
+}
+function navigate(path: string) {
+ window.history.pushState({}, '', path);
+ window.dispatchEvent(new PopStateEvent('popstate'));
+}
+
+function StatusBadge({ label, tone = 'unknown' }: { label: string; tone?: 'unknown' | 'ready' }) {
+ return {tone === 'ready' ? '✓' : '?'} {label} ;
+}
+
+function PageIntro({ eyebrow, title, intro }: { eyebrow: string; title: string; intro: string }) {
+ return ;
+}
+
+type OverviewSource = { state?: string; freshness?: string; reason?: string };
+type OverviewHost = { identity?: { name?: string }; cpu?: { totalPercent?: number }; memory?: { utilizationPercent?: number }; source?: OverviewSource };
+type OverviewContainer = { id?: string; state?: string; health?: string; intentionalStop?: boolean };
+type OverviewPool = { id: string; name: string; state: string; capacitySeverity?: string; utilizationPercent: number };
+type OverviewService = { id: string; name: string; state: string };
+type OverviewIncident = { id: string; title: string; severity: string; startedAt: string };
+type OverviewContainerSnapshot = { source?: OverviewSource; containers?: OverviewContainer[]; total?: number; nextCursor?: string };
+type OverviewPoolSnapshot = { source?: OverviewSource; pools?: OverviewPool[]; total?: number };
+type OverviewServiceSnapshot = { capabilityState?: string; configurationState?: string; reason?: string; services?: OverviewService[]; total?: number };
+type OverviewResourceState = 'loading' | 'ready' | 'unavailable' | 'unauthorized' | 'forbidden';
+type OverviewResource = 'host' | 'containers' | 'pools' | 'services' | 'incidents';
+type OverviewData = {
+ host?: OverviewHost;
+ containers: OverviewContainer[];
+ containerSource?: OverviewSource;
+ containerTotal: number;
+ containersPartial: boolean;
+ pools: OverviewPool[];
+ poolSource?: OverviewSource;
+ poolTotal: number;
+ poolsPartial: boolean;
+ services: OverviewService[];
+ serviceTotal: number;
+ servicesPartial: boolean;
+ serviceCapability?: string;
+ serviceConfiguration?: string;
+ incidents: OverviewIncident[];
+ incidentsPartial: boolean;
+ resources: Record;
+};
+
+const loadingOverviewResources: Record = {
+ host: 'loading', containers: 'loading', pools: 'loading', services: 'loading', incidents: 'loading',
+};
+
+const overviewInitialData: OverviewData = {
+ containers: [], containerTotal: 0, containersPartial: false,
+ pools: [], poolTotal: 0, poolsPartial: false,
+ services: [], serviceTotal: 0, servicesPartial: false,
+ incidents: [], incidentsPartial: false,
+ resources: loadingOverviewResources,
+};
+
+const OVERVIEW_REFRESH_MS = 30_000;
+const OVERVIEW_REQUEST_TIMEOUT_MS = 10_000;
+const CONTAINER_PAGE_LIMIT = 100;
+const CONTAINER_MAX_PAGES = 3;
+
+type ReadResult = { state: OverviewResourceState; data?: T };
+
+function collectionExtent(reported: number | undefined, count: number): { total: number; partial: boolean } {
+ const valid = Number.isInteger(reported) && (reported ?? -1) >= count;
+ const total = valid ? reported as number : count;
+ return { total, partial: !valid || total > count };
+}
+
+function useOverviewData(): { data: OverviewData; refresh: () => void } {
+ const [data, setData] = useState(overviewInitialData);
+ const [generation, setGeneration] = useState(0);
+ const refresh = useCallback(() => {
+ setData((current) => ({ ...current, resources: { ...loadingOverviewResources } }));
+ setGeneration((current) => current + 1);
+ }, []);
+ useEffect(() => {
+ const controller = new AbortController();
+ const read = async (url: string): Promise> => {
+ const requestController = new AbortController();
+ const abortRequest = () => requestController.abort();
+ if (controller.signal.aborted) abortRequest();
+ else controller.signal.addEventListener('abort', abortRequest, { once: true });
+ const timeout = window.setTimeout(abortRequest, OVERVIEW_REQUEST_TIMEOUT_MS);
+ try {
+ const response = await fetch(url, { signal: requestController.signal, cache: 'no-store' });
+ if (requestController.signal.aborted) return { state: 'unavailable' };
+ if (response.status === 401) return { state: 'unauthorized' };
+ if (response.status === 403) return { state: 'forbidden' };
+ return response.ok ? { state: 'ready', data: await response.json() as T } : { state: 'unavailable' };
+ } catch {
+ return { state: 'unavailable' };
+ } finally {
+ window.clearTimeout(timeout);
+ controller.signal.removeEventListener('abort', abortRequest);
+ }
+ };
+
+ const readContainers = async (): Promise> => {
+ const items: OverviewContainer[] = [];
+ const seen = new Set();
+ let source: OverviewSource | undefined;
+ let expectedTotal: number | undefined;
+ let after = '';
+ let complete = false;
+ let inconsistent = false;
+ for (let page = 0; page < CONTAINER_MAX_PAGES; page += 1) {
+ const params = new URLSearchParams({ limit: String(CONTAINER_PAGE_LIMIT), sort: 'name' });
+ if (after) params.set('after', after);
+ const result = await read('/api/v1/containers?' + params);
+ if (result.state !== 'ready' || !result.data) return { state: result.state };
+ const pageItems = result.data.containers ?? [];
+ const reportedTotal = result.data.total;
+ if (!Number.isInteger(reportedTotal) || (reportedTotal ?? -1) < pageItems.length) {
+ inconsistent = true;
+ } else if (expectedTotal == null) {
+ expectedTotal = reportedTotal as number;
+ } else {
+ if (reportedTotal !== expectedTotal) inconsistent = true;
+ expectedTotal = Math.max(expectedTotal, reportedTotal as number);
+ }
+ if (!source || signalToneRank[sourceSignalTone(result.data.source)] < signalToneRank[sourceSignalTone(source)]) source = result.data.source;
+ for (const item of pageItems) {
+ const id = item.id?.trim();
+ if (!id) {
+ inconsistent = true;
+ items.push(item);
+ } else if (seen.has(id)) {
+ inconsistent = true;
+ } else {
+ seen.add(id);
+ items.push(item);
+ }
+ }
+ const next = result.data.nextCursor?.trim() ?? '';
+ if (!next) {
+ complete = true;
+ break;
+ }
+ if (next === after) {
+ inconsistent = true;
+ break;
+ }
+ after = next;
+ }
+ const total = Math.max(expectedTotal ?? 0, items.length);
+ return { state: 'ready', data: { source, items, total, partial: !complete || inconsistent || items.length < total } };
+ };
+
+ void read('/api/v1/host').then((result) => {
+ if (controller.signal.aborted) return;
+ setData((current) => ({ ...current, host: result.data, resources: { ...current.resources, host: result.state } }));
+ });
+ void readContainers().then((result) => {
+ if (controller.signal.aborted) return;
+ setData((current) => ({ ...current, containers: result.data?.items ?? [], containerSource: result.data?.source, containerTotal: result.data?.total ?? 0, containersPartial: result.data?.partial ?? false, resources: { ...current.resources, containers: result.state } }));
+ });
+ void read('/api/v1/pools?limit=64').then((result) => {
+ if (controller.signal.aborted) return;
+ const items = result.data?.pools ?? [];
+ const extent = collectionExtent(result.data?.total, items.length);
+ setData((current) => ({ ...current, pools: items, poolSource: result.data?.source, poolTotal: extent.total, poolsPartial: result.state === 'ready' && extent.partial, resources: { ...current.resources, pools: result.state } }));
+ });
+ void read('/api/v1/services?limit=100').then((result) => {
+ if (controller.signal.aborted) return;
+ const items = result.data?.services ?? [];
+ const extent = collectionExtent(result.data?.total, items.length);
+ setData((current) => ({ ...current, services: items, serviceTotal: extent.total, servicesPartial: result.state === 'ready' && extent.partial, serviceCapability: result.data?.capabilityState, serviceConfiguration: result.data?.configurationState, resources: { ...current.resources, services: result.state } }));
+ });
+ void read<{ items?: OverviewIncident[] }>('/api/v1/incidents?limit=100&status=open').then((result) => {
+ if (controller.signal.aborted) return;
+ const items = result.data?.items ?? [];
+ setData((current) => ({ ...current, incidents: items, incidentsPartial: result.state === 'ready' && items.length >= 100, resources: { ...current.resources, incidents: result.state } }));
+ });
+ return () => controller.abort();
+ }, [generation]);
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ if (document.visibilityState === 'visible') setGeneration((current) => current + 1);
+ }, OVERVIEW_REFRESH_MS);
+ return () => window.clearInterval(timer);
+ }, []);
+ return { data, refresh };
+}
+
+function signalResourceLabel(state: OverviewResourceState, tone: SignalTone): string {
+ if (state === 'loading') return copy.overview.signalPathLoading;
+ if (state === 'unauthorized') return copy.overview.signalPathUnauthorized;
+ if (state === 'forbidden') return copy.overview.signalPathForbidden;
+ if (state === 'unavailable') return copy.overview.signalPathUnavailable;
+ return presentStatus(tone === 'attention' ? 'attention' : tone);
+}
+
+function resourceStateDetail(state: OverviewResourceState): string | undefined {
+ if (state === 'loading') return copy.overview.resourceLoadingDetail;
+ if (state === 'unauthorized') return copy.overview.resourceUnauthorizedDetail;
+ if (state === 'forbidden') return copy.overview.resourceForbiddenDetail;
+ if (state === 'unavailable') return copy.overview.resourceUnavailableDetail;
+ return undefined;
+}
+
+function thresholdTone(values: Array): SignalTone {
+ const usable = values.filter((value): value is number => value != null && Number.isFinite(value));
+ if (usable.length !== values.length) return 'unknown';
+ if (usable.some((value) => value >= 95)) return 'critical';
+ if (usable.some((value) => value >= 85)) return 'attention';
+ return 'healthy';
+}
+
+function metric(value: number | undefined, suffix = '%'): string {
+ return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + suffix;
+}
+
+function boundedRatio(known: number, total: number, partial: boolean): string {
+ return `${partial ? '≥' : ''}${known}/${total}`;
+}
+
+function signalCollectionLabel(state: OverviewResourceState, tone: SignalTone, options: { partial?: boolean; notConfigured?: boolean; emptyLabel?: string } = {}): string {
+ if (state !== 'ready') return signalResourceLabel(state, tone);
+ if (tone === 'critical' || tone === 'attention' || tone === 'stale') return presentStatus(tone);
+ if (options.notConfigured) return copy.overview.signalPathNotConfigured;
+ if (options.partial) return copy.overview.signalPathPartial;
+ if (options.emptyLabel) return options.emptyLabel;
+ return presentStatus(tone);
+}
+
+function OverviewPage() {
+ const snapshot = useSystemStatus();
+ const status = aggregateStatus(snapshot);
+ const { data: overview, refresh: refreshOverview } = useOverviewData();
+ const resourcesLoading = snapshot.state === 'loading' || Object.values(overview.resources).some((state) => state === 'loading');
+ const overviewNeedsAuthentication = snapshot.state === 'unauthorized' || Object.values(overview.resources).some((state) => state === 'unauthorized');
+ const systemResourceState: OverviewResourceState = snapshot.state === 'ready' ? 'ready' : snapshot.state === 'loading' ? 'loading' : snapshot.state === 'unauthorized' ? 'unauthorized' : snapshot.state === 'forbidden' ? 'forbidden' : 'unavailable';
+ const poolRank: Record = { critical: 0, faulted: 0, degraded: 1, attention: 2, unknown: 3 };
+ const poolProblems = (overview.resources.pools === 'ready' && sourceSignalTone(overview.poolSource) === 'healthy' ? overview.pools : [])
+ .map((pool) => ({ pool, state: operationalStorageState(pool.state, pool.capacitySeverity) }))
+ .filter(({ state }) => state !== 'healthy' && state !== 'normal')
+ .sort((a, b) => (poolRank[a.state] ?? 4) - (poolRank[b.state] ?? 4))
+ .map(({ pool, state }) => ({ id: 'pool:' + pool.id, label: `${pool.name}: ${presentStatus(state)}`, reason: state === 'critical' || state === 'faulted' ? copy.overview.poolCapacityCritical : state === 'attention' ? copy.overview.poolCapacityAttention : presentReason('source_health_unknown') }));
+ const resourceLabels: Record = { host: copy.navigation.host, containers: copy.overview.signalWorkloads, pools: copy.overview.signalStorage, services: copy.navigation.services, incidents: copy.overview.signalIncidents };
+ const resourceProblems = (Object.keys(overview.resources) as OverviewResource[]).flatMap((resource) => {
+ const state = overview.resources[resource];
+ if (state !== 'unavailable' && state !== 'unauthorized' && state !== 'forbidden') return [];
+ return [{ id: `resource:${resource}`, label: `${resourceLabels[resource]}: ${signalResourceLabel(state, 'unknown')}`, reason: resourceStateDetail(state) ?? copy.overview.resourceUnavailableDetail }];
+ });
+ const systemProblems = systemResourceState === 'ready' || systemResourceState === 'loading' ? [] : [{ id: 'resource:system-status', label: `${copy.overview.sources}: ${signalResourceLabel(systemResourceState, 'unknown')}`, reason: resourceStateDetail(systemResourceState) ?? copy.overview.resourceUnavailableDetail }];
+ const partialProblems: Array<{ id: string; label: string; reason: string }> = [];
+ if (overview.containersPartial) partialProblems.push({ id: 'partial:containers', label: `${copy.overview.signalWorkloads}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
+ if (overview.poolsPartial) partialProblems.push({ id: 'partial:pools', label: `${copy.overview.signalStorage}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
+ if (overview.servicesPartial) partialProblems.push({ id: 'partial:services', label: `${copy.navigation.services}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
+ if (overview.incidentsPartial) partialProblems.push({ id: 'partial:incidents', label: `${copy.overview.signalIncidents}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
+ const problems = [...systemProblems, ...resourceProblems, ...partialProblems, ...poolProblems, ...statusProblems(snapshot.status)].slice(0, 10);
+ const poolUsage = overview.pools.length ? Math.max(...overview.pools.map((pool) => pool.utilizationPercent)) : undefined;
+ const runningContainers = overview.containers.filter((item) => item.state?.toLowerCase() === 'running').length;
+ const availableServices = overview.services.filter((item) => item.state?.toLowerCase() === 'up').length;
+ const sourceLags = snapshot.status?.sourceLag ?? [];
+ const sourceTones = sourceLags.map((lag) => signalToneFromState(lag.state));
+ const healthySources = sourceTones.filter((tone) => tone === 'healthy').length;
+ const sourceTone = snapshot.state !== 'ready' || sourceTones.length === 0 ? 'unknown' : status.stale ? 'stale' : worstSignalTone(sourceTones);
+ const hostSourceTone = sourceSignalTone(overview.host?.source);
+ const hostTone = overview.resources.host !== 'ready' || !overview.host ? 'unknown' : hostSourceTone !== 'healthy' ? hostSourceTone : thresholdTone([overview.host.cpu?.totalPercent, overview.host.memory?.utilizationPercent]);
+ const poolSourceTone = sourceSignalTone(overview.poolSource);
+ const knownStorageTone = overview.pools.length ? worstSignalTone(overview.pools.map((pool) => signalToneFromState(operationalStorageState(pool.state, pool.capacitySeverity)))) : 'unknown';
+ const storageTone = overview.resources.pools !== 'ready' ? 'unknown' : poolSourceTone !== 'healthy' ? poolSourceTone : overview.poolsPartial ? worstSignalTone([knownStorageTone, 'unknown']) : overview.poolTotal === 0 ? 'unknown' : knownStorageTone;
+ const containerSourceTone = sourceSignalTone(overview.containerSource);
+ const knownWorkloadTone = overview.containerTotal === 0 ? 'healthy' : worstSignalTone(overview.containers.map(containerSignalTone));
+ const workloadTone = overview.resources.containers !== 'ready' ? 'unknown' : containerSourceTone !== 'healthy' ? containerSourceTone : overview.containersPartial ? worstSignalTone([knownWorkloadTone, 'unknown']) : knownWorkloadTone;
+ const serviceConfigured = overview.serviceCapability === 'available' && overview.serviceConfiguration === 'configured';
+ const knownServiceTone = overview.services.length ? worstSignalTone(overview.services.map((service) => signalToneFromState(service.state))) : 'unknown';
+ const serviceTone = overview.resources.services !== 'ready' || !serviceConfigured ? 'unknown' : overview.servicesPartial ? worstSignalTone([knownServiceTone, 'unknown']) : overview.serviceTotal === 0 ? 'unknown' : knownServiceTone;
+ const incidentTone = overview.resources.incidents !== 'ready' ? 'unknown' : overview.incidents.length === 0 ? 'healthy' : overview.incidents.some((incident) => signalToneFromState(incident.severity) === 'critical') ? 'critical' : 'attention';
+ const orderedIncidents = [...overview.incidents].sort((left, right) => signalToneRank[signalToneFromState(left.severity)] - signalToneRank[signalToneFromState(right.severity)] || right.startedAt.localeCompare(left.startedAt) || left.id.localeCompare(right.id));
+ const highestIncident = orderedIncidents[0];
+ const incidentSeverity = highestIncident ? presentStatus(highestIncident.severity) : copy.overview.noOpenIncidents;
+ const hostUsable = overview.resources.host === 'ready' && hostSourceTone === 'healthy';
+ const poolsUsable = overview.resources.pools === 'ready' && poolSourceTone === 'healthy';
+ const containersUsable = overview.resources.containers === 'ready' && containerSourceTone === 'healthy';
+ const servicesUsable = overview.resources.services === 'ready' && serviceConfigured;
+ const signalStages: OperationalSignalStage[] = [
+ { id: 'sources', label: copy.overview.sources, icon: '◉', tone: sourceTone, statusLabel: signalResourceLabel(systemResourceState, sourceTone), primaryLabel: copy.overview.connectedSources, primaryValue: snapshot.state === 'ready' ? `${healthySources}/${sourceTones.length || '—'}` : '—', secondaryLabel: copy.overview.freshness, secondaryValue: signalResourceLabel(systemResourceState, sourceTone), detail: copy.overview.signalPathSourcesDetail, route: '/status' },
+ { id: 'host', label: copy.navigation.host, icon: '▣', tone: hostTone, statusLabel: signalResourceLabel(overview.resources.host, hostTone), primaryLabel: copy.overview.cpu, primaryValue: hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—', secondaryLabel: copy.overview.memoryShort, secondaryValue: hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—', detail: copy.overview.signalPathHostDetail, route: '/host' },
+ { id: 'storage', label: copy.overview.signalStorage, icon: '▤', tone: storageTone, statusLabel: signalCollectionLabel(overview.resources.pools, storageTone, { partial: overview.poolsPartial }), primaryLabel: copy.overview.storage, primaryValue: poolsUsable ? (overview.poolsPartial && poolUsage != null ? `≥${metric(poolUsage)}` : metric(poolUsage)) : '—', secondaryLabel: copy.navigation.pools, secondaryValue: poolsUsable ? String(overview.poolTotal) : '—', detail: copy.overview.signalPathStorageDetail, route: '/storage' },
+ { id: 'workloads', label: copy.overview.signalWorkloads, icon: '⬡', tone: workloadTone, statusLabel: signalCollectionLabel(overview.resources.containers, workloadTone, { partial: overview.containersPartial, emptyLabel: overview.containerTotal === 0 ? copy.overview.signalPathNoWorkloads : undefined }), primaryLabel: copy.overview.activeContainers, primaryValue: containersUsable ? boundedRatio(runningContainers, overview.containerTotal, overview.containersPartial) : '—', secondaryLabel: copy.overview.total, secondaryValue: containersUsable ? String(overview.containerTotal) : '—', detail: copy.overview.signalPathWorkloadsDetail, route: '/containers' },
+ { id: 'services', label: copy.navigation.services, icon: '◇', tone: serviceTone, statusLabel: signalCollectionLabel(overview.resources.services, serviceTone, { partial: overview.servicesPartial, notConfigured: overview.serviceConfiguration === 'not_configured' }), primaryLabel: copy.overview.available, primaryValue: servicesUsable ? boundedRatio(availableServices, overview.serviceTotal, overview.servicesPartial) : '—', secondaryLabel: copy.overview.total, secondaryValue: servicesUsable ? String(overview.serviceTotal) : '—', detail: copy.overview.signalPathServicesDetail, route: '/services' },
+ { id: 'incidents', label: copy.overview.signalIncidents, icon: '△', tone: incidentTone, statusLabel: signalCollectionLabel(overview.resources.incidents, incidentTone, { partial: overview.incidentsPartial }), primaryLabel: copy.overview.openIncidents, primaryValue: overview.resources.incidents === 'ready' ? `${overview.incidents.length}${overview.incidentsPartial ? '+' : ''}` : '—', secondaryLabel: copy.overview.highestSeverity, secondaryValue: overview.resources.incidents === 'ready' ? incidentSeverity : '—', detail: copy.overview.signalPathIncidentsDetail, route: '/incidents' },
+ ];
+ const hasSignalAttention = signalStages.some((stage) => stage.tone === 'critical' || stage.tone === 'attention');
+ const hasSignalUncertainty = signalStages.some((stage) => stage.tone === 'stale' || stage.tone === 'unknown');
+ const heading = hasSignalAttention || problems.length > 0 || (poolUsage != null && poolUsage >= 90) ? copy.overview.attentionTitle : hasSignalUncertainty ? copy.overview.unknownTitle : copy.overview.title;
+ return
+
+
+ {sourceLags.slice(0, 6).map((source) => {source.state === 'healthy' ? '✓' : source.state === 'degraded' ? '!' : '?'} {presentComponent(source.sourceId)} {presentStatus(source.state)} )}
+ {sourceLags.length === 0 && ? {copy.overview.sourceLag} {copy.overview.unknown} }
+
+
+ {copy.overview.cpu}
{hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'} {hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}
+ {copy.overview.memory}
{hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'} {hostUsable ? overview.host?.identity?.name ?? copy.overview.unknown : signalResourceLabel(overview.resources.host, hostTone)}
+ {copy.overview.storage}
{poolsUsable ? (overview.poolsPartial && poolUsage != null ? `≥${metric(poolUsage)}` : metric(poolUsage)) : '—'} {poolsUsable ? `${overview.poolTotal} ${copy.navigation.pools.toLowerCase()}` : signalResourceLabel(overview.resources.pools, storageTone)}
+ {copy.overview.services}
{servicesUsable ? boundedRatio(availableServices, overview.serviceTotal, overview.servicesPartial) : '—'} {overview.resources.services !== 'ready' ? signalResourceLabel(overview.resources.services, serviceTone) : !serviceConfigured ? copy.overview.signalPathNotConfigured : overview.servicesPartial ? copy.overview.signalPathPartial : copy.overview.available}
+
+
+ {copy.overview.actionQueue}
{copy.overview.problems} {problems.length} {problems.length ? {problems.map((problem, index) => {index === 0 ? '!' : '?'} {problem.label} {problem.reason} )} : {resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}
}{overviewNeedsAuthentication && } { refreshSystemStatus(); refreshOverview(); }}>{copy.overview.retry} navigate('/status')}>{copy.overview.openStatus}
+
+ {copy.overview.storagePools}
{copy.overview.capacity} navigate('/pools')}>{copy.overview.viewAll} {overview.resources.pools !== 'ready' ? {resourceStateDetail(overview.resources.pools)}
: poolSourceTone !== 'healthy' ? {copy.overview.resourceStaleDetail}
: overview.pools.length ? {overview.pools.slice(0, 5).map((pool) => { const state = operationalStorageState(pool.state, pool.capacitySeverity); return {pool.name} {presentStatus(state)} · device-health {presentStatus(pool.state).toLowerCase()} {metric(pool.utilizationPercent)} ; })} : {copy.overview.noPools}
}
+ Nu
{copy.navigation.containers} navigate('/containers')}>{copy.overview.viewAll} {containersUsable ? `${overview.containersPartial ? '≥' : ''}${runningContainers}` : '—'} {copy.overview.running} {containersUsable && !overview.containersPartial ? Math.max(0, overview.containerTotal - runningContainers) : '—'} {copy.overview.other}
{overview.resources.containers !== 'ready' ? resourceStateDetail(overview.resources.containers) : containerSourceTone !== 'healthy' ? copy.overview.resourceStaleDetail : overview.containersPartial ? copy.overview.resourcePartialDetail : overview.containerTotal > 0 ? `${runningContainers} van ${overview.containerTotal} ${copy.overview.containersRunning}.` : copy.overview.signalPathNoWorkloads}
{copy.overview.cpu} {hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'}
{copy.overview.memory} {hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'}
Bron {hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}
+ {copy.overview.recentIncidents}
{copy.navigation.incidents} navigate('/incidents')}>{copy.overview.viewAll} {overview.resources.incidents !== 'ready' ? {resourceStateDetail(overview.resources.incidents)}
: orderedIncidents.length ? {orderedIncidents.slice(0, 5).map((incident) => {incident.title} {formatDateTime(incident.startedAt)} )} : {copy.overview.noIncidents}
}
+
+
;
+}
+
+type ApiRecord = Record;
+type DashboardSummary = { id: string; slug: string; name: string; description: string; scope: string; revision: number; currentVersion: number };
+type DashboardWidget = { id: string; type: string; title: string; description?: string; data?: ApiRecord; visualization?: ApiRecord; behavior?: ApiRecord; layouts?: ApiRecord };
+type DashboardViewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
+type CrossFilter = { key: string; value: string; label: string; sourceWidgetId: string };
+class ApiError extends Error { constructor(readonly status: number) { super('api request failed'); } }
+function field(record: ApiRecord | undefined, name: string): T | undefined {
+ if (!record) return undefined;
+ const upper = name.charAt(0).toUpperCase() + name.slice(1);
+ return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
+}
+function summaryFromApi(record: ApiRecord): DashboardSummary {
+ return { id: String(field(record, 'id') ?? ''), slug: String(field(record, 'slug') ?? ''), name: String(field(record, 'name') ?? copy.dashboards.unnamed), description: String(field(record, 'description') ?? ''), scope: String(field(record, 'scope') ?? 'unknown'), revision: Number(field(record, 'revision') ?? 0), currentVersion: Number(field(record, 'currentVersion') ?? 0) };
+}
+async function getJSON(url: string, signal: AbortSignal): Promise {
+ const response = await fetch(url, { signal, cache: 'no-store' });
+ if (!response.ok) throw new ApiError(response.status);
+ return response.json() as Promise;
+}
+
+function DashboardsPage() {
+ const [state, setState] = useState<'loading' | 'error' | 'unauthorized' | 'empty' | 'ready'>('loading');
+ const [items, setItems] = useState([]);
+ const [reload, setReload] = useState(0);
+ useEffect(() => {
+ const controller = new AbortController();
+ // Rotation is a background replacement once a dashboard is visible. Keep
+ // the current view (and any identical shared live subscription) mounted
+ // until the next document arrives instead of bouncing through `loading`.
+ setState((current) => current === 'ready' ? current : 'loading');
+ getJSON<{ items?: ApiRecord[] }>('/api/v1/dashboards?limit=100', controller.signal).then((data) => {
+ const next = (data.items ?? []).map(summaryFromApi).filter((item) => item.id !== '');
+ setItems(next); setState(next.length === 0 ? 'empty' : 'ready');
+ }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState(error instanceof ApiError && error.status === 401 ? 'unauthorized' : 'error');
+ });
+ return () => controller.abort();
+ }, [reload]);
+ const stateContent = state === 'loading' ? {copy.dashboards.loading}
:
+ state === 'unauthorized' ? {copy.dashboards.unauthorizedTitle} {copy.dashboards.unauthorizedDetail}
:
+ state === 'error' ? {copy.dashboards.errorTitle} {copy.dashboards.errorDetail}
setReload((value) => value + 1)}>{copy.dashboards.retry} :
+ state === 'empty' ? ▦ {copy.dashboards.empty} {copy.dashboards.emptyDetail}
:
+ {items.map((item) => navigate('/dashboards/' + encodeURIComponent(item.id))}>{item.name} {item.description || item.slug} {copy.dashboards.version} {item.currentVersion} )} ;
+ return <>{copy.dashboards.catalog}
{copy.dashboards.listTitle} {stateContent} {state === 'ready' && {items.length} {copy.dashboards.available}
}>;
+}
+
+class WidgetBoundary extends Component<{ title: string; children: ReactNode }, { failed: boolean }> {
+ state = { failed: false };
+ static getDerivedStateFromError(): { failed: boolean } { return { failed: true }; }
+ componentDidCatch(_error: Error, _info: ErrorInfo): void {}
+ render() { return this.state.failed ? {copy.dashboards.widgetError}
{this.props.title} {copy.dashboards.widgetErrorDetail}
: this.props.children; }
+}
+const widgetLabels: Record = { stat: copy.widgets.stat, timeseries: copy.widgets.timeseries, gauge: copy.widgets.gauge, 'ranked-list': copy.widgets.rankedList, 'status-grid': copy.widgets.statusGrid, table: copy.widgets.table, heatmap: copy.widgets.heatmap, 'event-timeline': copy.widgets.eventTimeline, 'storage-map': copy.widgets.storageMap, topology: copy.widgets.topology, 'service-matrix': copy.widgets.serviceMatrix, 'alert-summary': copy.widgets.alertSummary, text: copy.widgets.text, 'query-inspector': copy.widgets.queryInspector };
+
+// Installed before any client captures the global `fetch`, so every API call in
+// the app funnels its 401s through one place.
+installSessionWatcher();
+function responsiveViewport(): 'desktop' | 'tablet' | 'mobile' { return window.innerWidth <= 700 ? 'mobile' : window.innerWidth <= 900 ? 'tablet' : 'desktop'; }
+function widgetFilter(widget: DashboardWidget): { key: string; value: string; label: string } {
+ const data = widget.data ?? {};
+ const scope = field(data, 'scope') ?? {};
+ const entityType = field(scope, 'entityType');
+ const sourceType = String(field(data, 'sourceType') ?? 'unknown');
+ return entityType ? { key: 'entityType', value: entityType, label: entityType } : { key: 'sourceType', value: sourceType, label: sourceType };
+}
+function filterAllowed(document: ApiRecord, key: string, value: string): boolean {
+ const safeSources = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'];
+ if (key === 'sourceType') return safeSources.includes(value);
+ const variables = (field(document, 'variables') ?? []) as ApiRecord[];
+ return variables.some((variable) => { const options = field(variable, 'options') ?? []; return options.includes(value); });
+}
+function filterFromURL(document: ApiRecord): CrossFilter | null {
+ const params = new URLSearchParams(window.location.search);
+ const key = params.get('filterKey');
+ const value = params.get('filterValue');
+ if (!key || !value || !filterAllowed(document, key, value)) return null;
+ return { key, value, label: value, sourceWidgetId: '' };
+}
+function compatibleWithFilter(widget: DashboardWidget, filter: CrossFilter | null): boolean {
+ if (!filter || widget.id === filter.sourceWidgetId) return true;
+ const next = widgetFilter(widget);
+ return next.key === filter.key && next.value === filter.value;
+}
+function wallboardSlideFor(widget: DashboardWidget): number {
+ const layout = field(widget.layouts ?? {}, 'wallboard') ?? field(widget.layouts ?? {}, 'desktop') ?? {};
+ return wallboardSlideIndex(field(layout, 'y'));
+}
+function widgetLayoutStyle(layout: ApiRecord, viewport: DashboardViewport): CSSProperties {
+ const columns = viewport === 'wallboard' ? wallboardColumns : viewport === 'tablet' ? 8 : viewport === 'mobile' ? 1 : 18;
+ const width = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, Number(field(layout, 'w') ?? 6)));
+ if (viewport !== 'wallboard') return { '--widget-span': String(width) } as CSSProperties;
+ const placement = wallboardPlacement(layout);
+ return { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties;
+}
+function DashboardWidgetView({ widget, viewport, onFilter, metric }: { widget: DashboardWidget; viewport: DashboardViewport; onFilter: (widget: DashboardWidget) => void; metric?: MetricWidgetProps }) {
+ if (!widget.id || !widget.title) return {copy.dashboards.widgetError}
{copy.widgets.unknown} {copy.dashboards.widgetErrorDetail}
;
+ const behavior = widget.behavior ?? {};
+ if (field(behavior, 'hidden')) return null;
+ const activeLayout = field(widget.layouts ?? {}, viewport) ?? field(widget.layouts ?? {}, 'desktop') ?? {};
+ if (field(activeLayout, 'visible') === false) return null;
+ const typeLabel = widgetLabels[widget.type] ?? copy.widgets.unknown;
+ const source = String(field(widget.data ?? {}, 'sourceType') ?? copy.dashboards.unknown);
+ const status = metric ? metricStatus(metric) : { label: copy.dashboards.unknown, tone: 'unknown' as const };
+ const metricKind = metric && (widget.type === 'stat' || widget.type === 'timeseries' || widget.type === 'gauge' || widget.type === 'query-inspector');
+ const widgetItems = (field(widget.data ?? {}, 'items') ?? []) as ApiRecord[];
+ const rankedItems: RankedListItem[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), value: String(field(item, 'value') ?? ''), detail: field(item, 'detail') } )).filter((item) => item.id !== '' && item.label !== '');
+ const statusItems: StatusGridItem[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), status: String(field(item, 'status') ?? 'unknown'), reason: field(item, 'reason') } )).filter((item) => item.id !== '' && item.label !== '');
+ const storageNodes: StorageMapNode[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), kind: String(field(item, 'kind') ?? 'storage'), state: String(field(item, 'status') ?? 'unknown'), detail: field(item, 'detail'), href: field(item, 'href') })).filter((item) => item.id !== '' && item.label !== '');
+ const topologyData = field(widget.data ?? {}, 'topology');
+ const networkData = field(widget.data ?? {}, 'network');
+ const heatmapPoints: HeatmapPoint[] = widgetItems.map((item) => { const value = Number(field(item, 'value')); return { id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), observedAt: String(field(item, 'observedAt') ?? new Date(0).toISOString()), value: Number.isFinite(value) ? value : null, status: String(field(item, 'status') ?? 'unknown'), href: field(item, 'href') }; }).filter((item) => item.id !== '' && item.label !== '');
+ return {widget.description && {widget.description}
}{metricKind ? : widget.type === 'ranked-list' && rankedItems.length > 0 ? onFilter(widget)} /> : widget.type === 'status-grid' && statusItems.length > 0 ? onFilter(widget)} /> : widget.type === 'storage-map' && storageNodes.length > 0 ? : widget.type === 'topology' && topologyData ? {copy.topology.loading}
}> : widget.type === 'network' && networkData ? : widget.type === 'heatmap' && heatmapPoints.length > 0 ? : onFilter(widget)}>{widget.type === 'text' ? 'T' : '◌'} {copy.dashboards.noData} {source} · {copy.dashboards.dataPending} };
+}
+function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0, onWallboardSlideCount, onRuntimeState }: { dashboardId: string; wallboard?: boolean; wallboardSlide?: number; onWallboardSlideCount?: (count: number) => void; onRuntimeState?: (state: RuntimeState) => void }) {
+ const [state, setState] = useState<'loading' | 'error' | 'unauthorized' | 'ready'>('loading');
+ const [summary, setSummary] = useState(null);
+ const [document, setDocument] = useState({});
+ const [widgets, setWidgets] = useState([]);
+ const [editing, setEditing] = useState(false);
+ const [crossFilter, setCrossFilter] = useState(null);
+ const [runtimeStates, setRuntimeStates] = useState>({});
+ const hasWallboardContent = useRef(false);
+ const systemSnapshot = useSystemStatus();
+ const storageKey = 'pulse.dashboard.view.' + dashboardId;
+ const [timeRange, setTimeRange] = useState(() => wallboard ? 'live' : window.localStorage.getItem(storageKey + '.range') ?? '1h');
+ const [filter, setFilter] = useState(() => window.localStorage.getItem(storageKey + '.filter') ?? '');
+ const updateRuntimeState = useCallback((id: string, next: RuntimeState) => setRuntimeStates((current) => current[id] === next ? current : { ...current, [id]: next }), []);
+ useEffect(() => {
+ const controller = new AbortController();
+ const replacingVisibleWallboard = wallboard && hasWallboardContent.current;
+ if (!replacingVisibleWallboard) setState('loading');
+ getJSON<{ dashboard: ApiRecord; version: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(dashboardId), controller.signal).then((data) => {
+ const rawDocument = field(data.version, 'document') ?? {};
+ setDocument(rawDocument);
+ setCrossFilter(filterFromURL(rawDocument));
+ setSummary(summaryFromApi(data.dashboard));
+ setWidgets((field(rawDocument, 'widgets') ?? []) as DashboardWidget[]);
+ setRuntimeStates({});
+ if (wallboard) hasWallboardContent.current = true;
+ setState('ready');
+ }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ if (error instanceof ApiError && error.status === 401) {
+ setState('unauthorized');
+ return;
+ }
+ // A wallboard is a continuous operational display. During a transient
+ // failed rotation, keep the last verified document visible and let the
+ // next bounded rotation/refresh retry. Never apply this to first load or
+ // authentication failure.
+ if (!replacingVisibleWallboard) setState('error');
+ });
+ return () => controller.abort();
+ }, [dashboardId, wallboard]);
+ useEffect(() => { window.localStorage.setItem(storageKey + '.range', timeRange); }, [storageKey, timeRange]);
+ useEffect(() => { window.localStorage.setItem(storageKey + '.filter', filter); }, [storageKey, filter]);
+ useEffect(() => {
+ const values = Object.values(runtimeStates);
+ const aggregate: RuntimeState = values.includes('usable') ? 'usable' : values.includes('error') ? 'error' : values.length > 0 && values.every((value) => value === 'empty') ? 'empty' : 'loading';
+ onRuntimeState?.(aggregate);
+ }, [onRuntimeState, runtimeStates]);
+ useEffect(() => { if (wallboard) setRuntimeStates({}); }, [wallboard, wallboardSlide]);
+ const wallboardWidgets = widgets.filter((widget) => {
+ const layout = field(widget.layouts ?? {}, 'wallboard') ?? field(widget.layouts ?? {}, 'desktop') ?? {};
+ return field(widget.behavior ?? {}, 'hidden') !== true && field(layout, 'visible') !== false;
+ });
+ const wallboardSlideCount = Math.max(1, ...wallboardWidgets.map((widget) => wallboardSlideFor(widget) + 1));
+ useEffect(() => { if (wallboard) onWallboardSlideCount?.(wallboardSlideCount); }, [onWallboardSlideCount, wallboard, wallboardSlideCount]);
+ if (state === 'loading') return {copy.dashboards.loading} ;
+ if (state === 'unauthorized') return ;
+ if (state === 'error' || !summary) return ;
+ if (editing) return }> setEditing(false)} onSaved={(revision, nextDocument) => { setDocument(nextDocument); setWidgets((field(nextDocument, 'widgets') ?? []) as DashboardWidget[]); setSummary({ ...summary, revision, currentVersion: summary.currentVersion + 1 }); setEditing(false); }} />;
+ const viewport: DashboardViewport = wallboard ? 'wallboard' : responsiveViewport();
+ const normalized = filter.trim().toLowerCase();
+ const shown = widgets.filter((widget) => { const activeLayout = field(widget.layouts ?? {}, viewport) ?? field(widget.layouts ?? {}, 'desktop') ?? {}; return field(widget.behavior ?? {}, 'hidden') !== true && field(activeLayout, 'visible') !== false && (!wallboard || wallboardSlideFor(widget) === Math.min(wallboardSlide, wallboardSlideCount - 1)) && (!normalized || widget.title.toLowerCase().includes(normalized)) && compatibleWithFilter(widget, crossFilter); });
+ const systemStatus = aggregateStatus(systemSnapshot);
+ const applyCrossFilter = (widget: DashboardWidget) => { const next = widgetFilter(widget); const filter = { ...next, sourceWidgetId: widget.id }; setCrossFilter(filter); const params = new URLSearchParams(window.location.search); params.set('filterKey', filter.key); params.set('filterValue', filter.value); window.history.replaceState({}, '', window.location.pathname + '?' + params.toString()); };
+ const clearCrossFilter = () => { setCrossFilter(null); const params = new URLSearchParams(window.location.search); params.delete('filterKey'); params.delete('filterValue'); const query = params.toString(); window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '')); };
+
+ const usableCount = Object.values(runtimeStates).filter((value) => value === 'usable').length;
+ return {!wallboard && navigate('/dashboards')}>← {copy.dashboards.back} }{copy.dashboards.viewMode}
{wallboard ?
{summary.name} :
{summary.name} }
{summary.description || copy.dashboards.noDescription}
{wallboard && {copy.wallboard.readOnly} }{copy.dashboards.version} {summary.currentVersion} {!wallboard && setEditing(true)}>{copy.dashboards.edit} }
{!wallboard && {copy.dashboards.timeRange} setTimeRange(event.target.value)}>{copy.dashboards.live} 15 {copy.dashboards.minutes} 1 {copy.dashboards.hour} 6 {copy.dashboards.hours} 24 {copy.dashboards.hours} 7 {copy.dashboards.days} {copy.dashboards.filter} setFilter(event.target.value)} placeholder={copy.dashboards.filterPlaceholder} /> {crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView} {usableCount} van {shown.length} {copy.dashboards.widgetsWithData} {crossFilter && {copy.dashboards.clearFilter} }
}{shown.length === 0 ? {copy.dashboards.noMatchingWidgets} {copy.dashboards.clearFilterHint}
: <>{shown.map((widget) => {['semantic-metric', 'inventory', 'events'].includes(String(field(widget.data, 'sourceType') ?? '')) ? applyCrossFilter(widget)} /> : } )}
>} ;
+}
+
+type WallboardPriorityData = { serviceProblems: number; openIncidents: number; loading: boolean; unavailable: boolean };
+function useWallboardPriorityData(): WallboardPriorityData {
+ const [value, setValue] = useState({ serviceProblems: 0, openIncidents: 0, loading: true, unavailable: false });
+ useEffect(() => {
+ let active = true;
+ let controller: AbortController | null = null;
+ const load = async () => {
+ controller?.abort();
+ const current = new AbortController();
+ controller = current;
+ try {
+ const [servicesResponse, incidentsResponse] = await Promise.all([
+ fetch('/api/v1/services?limit=100', { signal: current.signal, cache: 'no-store' }),
+ fetch('/api/v1/incidents?limit=100&status=open', { signal: current.signal, cache: 'no-store' }),
+ ]);
+ if (!servicesResponse.ok || !incidentsResponse.ok) throw new Error('priority');
+ const services = await servicesResponse.json() as { services?: Array<{ state?: string }> };
+ const incidents = await incidentsResponse.json() as { items?: unknown[] };
+ if (active) setValue({ serviceProblems: (services.services ?? []).filter((item) => item.state !== 'up').length, openIncidents: (incidents.items ?? []).length, loading: false, unavailable: false });
+ } catch (error: unknown) {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ if (active) setValue((currentValue) => ({ ...currentValue, loading: false, unavailable: true }));
+ }
+ };
+ void load();
+ const timer = window.setInterval(load, 30000);
+ return () => { active = false; controller?.abort(); window.clearInterval(timer); };
+ }, []);
+ return value;
+}
+
+function WallboardPage() {
+ const [state, setState] = useState<'loading' | 'ready' | 'error' | 'unauthorized' | 'empty'>('loading');
+ const [items, setItems] = useState([]);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [activeSlide, setActiveSlide] = useState(0);
+ const [slideCount, setSlideCount] = useState(1);
+ const [lastUpdated, setLastUpdated] = useState();
+ const [transport, setTransport] = useState<'connected' | 'reconnecting' | 'unavailable'>('reconnecting');
+ const [dataState, setDataState] = useState('loading');
+ const [fullscreen, setFullscreen] = useState(false);
+ const [shift, setShift] = useState(0);
+ const systemSnapshot = useSystemStatus();
+ const system = aggregateStatus(systemSnapshot);
+ const storage = systemSnapshot.status?.components.find((component) => component.id === 'storage');
+ const priority = useWallboardPriorityData();
+ const params = new URLSearchParams(window.location.search);
+ const intervalSeconds = Math.min(300, Math.max(10, Number(params.get('interval') ?? 30) || 30));
+ const refreshSeconds = Math.min(300, Math.max(10, Number(params.get('refresh') ?? 30) || 30));
+
+ useEffect(() => onUnauthenticated(() => { setTransport('unavailable'); setState('unauthorized'); }), []);
+
+ useEffect(() => {
+ let active = true;
+ let inFlight: AbortController | null = null;
+ const load = async () => {
+ inFlight?.abort();
+ const controller = new AbortController();
+ inFlight = controller;
+ try {
+ const response = await fetch('/api/v1/dashboards?limit=100', { signal: controller.signal, cache: 'no-store' });
+ if (!response.ok) throw new Error('wallboard');
+ const data = await response.json() as { items?: ApiRecord[] };
+ const next = (data.items ?? []).map(summaryFromApi).filter((item) => item.id !== '').sort((a, b) => a.id.localeCompare(b.id));
+ if (!active) return;
+ setItems(next);
+ setActiveIndex((value) => next.length === 0 ? 0 : Math.min(value, next.length - 1));
+ setLastUpdated(new Date().toISOString());
+ setTransport('connected');
+ setState(next.length === 0 ? 'empty' : 'ready');
+ } catch (error: unknown) {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ if (!active) return;
+ setTransport('unavailable');
+ setState((value) => value === 'ready' || value === 'unauthorized' ? value : 'error');
+ } finally {
+ if (inFlight === controller) inFlight = null;
+ }
+ };
+ void load();
+ const refresh = window.setInterval(() => { setTransport('reconnecting'); void load(); }, refreshSeconds * 1000);
+ return () => { active = false; inFlight?.abort(); window.clearInterval(refresh); };
+ }, []);
+
+ useEffect(() => {
+ if (items.length === 0) return undefined;
+ const rotation = window.setInterval(() => setActiveSlide((value) => {
+ if (value + 1 < slideCount) return value + 1;
+ if (items.length > 1) setActiveIndex((dashboard) => (dashboard + 1) % items.length);
+ return 0;
+ }), intervalSeconds * 1000);
+ return () => window.clearInterval(rotation);
+ }, [items.length, intervalSeconds, slideCount]);
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setShift((value) => (value + 1) % 2), 60000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ useEffect(() => {
+ const update = () => setFullscreen(Boolean(document.fullscreenElement));
+ document.addEventListener('fullscreenchange', update);
+ update();
+ return () => document.removeEventListener('fullscreenchange', update);
+ }, []);
+
+ const toggleFullscreen = async () => {
+ try {
+ if (document.fullscreenElement) await document.exitFullscreen();
+ else if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen();
+ } catch { /* Fullscreen is optional; transport and data state remain truthful. */ }
+ };
+ const handleRuntimeState = useCallback((runtime: RuntimeState) => setDataState(runtime), []);
+ const handleSlideCount = useCallback((count: number) => { setSlideCount(Math.max(1, count)); setActiveSlide((value) => Math.min(value, Math.max(1, count) - 1)); }, []);
+
+ if (state === 'loading') return {copy.wallboard.eyebrow}
{copy.wallboard.loading} ;
+ if (state === 'unauthorized') return {copy.wallboard.eyebrow}
{copy.states.unauthorizedTitle} {copy.states.unauthorizedDetail}
;
+ if (state === 'error') return {copy.wallboard.eyebrow}
{copy.wallboard.errorTitle} {copy.wallboard.errorDetail}
;
+ if (state === 'empty') return {copy.wallboard.eyebrow}
{copy.wallboard.noDashboards} ;
+
+ const current = items[activeIndex];
+ return
+ {copy.wallboard.eyebrow}
{copy.wallboard.title} {copy.wallboard.intro}
{copy.wallboard.transport}: {transport === 'connected' ? copy.wallboard.connected : transport === 'reconnecting' ? copy.wallboard.reconnecting : copy.wallboard.unavailable} {copy.wallboard.data}: {dataState === 'usable' ? copy.wallboard.dataUsable : dataState === 'loading' ? copy.wallboard.dataLoading : dataState === 'empty' ? copy.wallboard.dataEmpty : copy.wallboard.unavailable} {fullscreen ? copy.wallboard.exitFullscreen : copy.wallboard.enterFullscreen}
+ {copy.wallboard.overall} {system.label} {copy.wallboard.storage} {storage ? presentStatus(storage.state) : copy.wallboard.unknown} {copy.wallboard.services} {priority.loading ? copy.wallboard.dataLoading : priority.unavailable ? copy.wallboard.unknown : `${priority.serviceProblems} ${copy.wallboard.problems}`} {copy.wallboard.incidents} {priority.loading ? copy.wallboard.dataLoading : priority.unavailable ? copy.wallboard.unknown : `${priority.openIncidents} ${copy.wallboard.open}`}
+ {copy.wallboard.lastUpdated}: {lastUpdated ? formatDateTime(lastUpdated) : copy.wallboard.reconnecting} {copy.wallboard.rotate} {copy.wallboard.every} {intervalSeconds} {copy.wallboard.seconds} {copy.wallboard.slide} {activeSlide + 1} / {slideCount} {copy.wallboard.dashboard} {activeIndex + 1} / {items.length}
+
+ ;
+}
+const AlertsPage = AlertRulesPage;
+function SettingsPage() {
+ const snapshot = useSystemStatus();
+ const status = aggregateStatus(snapshot);
+ const connected = snapshot.status?.sourceLag.filter((source) => source.state === 'healthy').length ?? 0;
+ const total = snapshot.status?.sourceLag.length ?? 0;
+ const sourceDetail = snapshot.state === 'ready' && total > 0 ? `${connected} van ${total} ${copy.settings.sourcesCurrent}` : status.detail;
+ const groups = [
+ { title: copy.settings.healthTitle, detail: copy.settings.healthDetail, links: [
+ { href: '/status', title: copy.settings.systemStatus, detail: copy.settings.systemStatusDetail, access: copy.settings.adminActions },
+ { href: '/onboarding', title: copy.settings.onboarding, detail: copy.settings.onboardingDetail, access: copy.settings.adminChanges },
+ ] },
+ { title: copy.settings.alertingTitle, detail: copy.settings.alertingDetail, links: [
+ { href: '/alerts?section=rules', title: copy.settings.alertRules, detail: copy.settings.alertRulesDetail, access: copy.settings.editorChanges },
+ { href: '/alerts?section=controls', title: copy.settings.alertControls, detail: copy.settings.alertControlsDetail, access: copy.settings.operatorChanges },
+ ] },
+ { title: copy.settings.presentationTitle, detail: copy.settings.presentationDetail, links: [
+ { href: '/dashboards', title: copy.settings.dashboards, detail: copy.settings.dashboardsDetail, access: copy.settings.editorChanges },
+ { href: '/inventory', title: copy.settings.inventory, detail: copy.settings.inventoryDetail, access: copy.settings.viewAccess },
+ ] },
+ ];
+ return <>
+ {copy.settings.current}
{copy.settings.environment} {copy.settings.source} {sourceDetail} {connected}/{total || '—'}
{copy.settings.language} {copy.settings.languageDetail} {copy.settings.languageValue}
+ {copy.settings.management} {groups.map((group) => {copy.settings.management}
{group.title} {group.detail}
)}
+ >;
+}
+function StatePage({ kind }: { kind: 'loading' | 'error' | 'unauthorized' }) {
+ if (kind === 'loading') return ;
+ if (kind === 'unauthorized') {
+ // The API answered 401, so the visitor is not signed in: offer the real
+ // sign-in entry point and come back to the page they asked for.
+ return ! {copy.states.unauthorizedTitle} {copy.states.unauthorizedDetail}
{copy.auth.signInHint}
navigate('/')}>{copy.states.returnHome}
;
+ }
+ return × {copy.states.errorTitle} {copy.states.errorDetail}
navigate(routeFromLocation(window.location.pathname))}>{copy.states.retry} ;
+}
+
+/** Wraps lazily loaded routes in the same loading state the rest of the app uses. */
+function RouteSuspense({ children }: { children: ReactNode }) {
+ return }>{children};
+}
+
+function Page({ route }: { route: RoutePath }) {
+ if (route.startsWith('/inventory/')) return ;
+ if (route.startsWith('/dashboards/')) return ;
+ if (route.startsWith('/services/')) return ;
+ if (route.startsWith('/incidents/')) return ;
+ if (route.startsWith('/containers/')) return ;
+ if (route.startsWith('/disks/')) return ;
+ if (route.startsWith('/pools/')) return ;
+ if (route.startsWith('/shares/')) return ;
+ if (route.startsWith('/applications/')) return ;
+ switch (route) {
+ case '/': return ;
+ case '/processes': return ;
+ case '/containers': return ;
+ case '/services': return ;
+ case '/topology': return ;
+ case '/network': return ;
+ case '/applications': return ;
+ case '/host': return ;
+ case '/array': return ;
+ case '/disks': return ;
+ case '/pools': return ;
+ case '/shares': return ;
+ case '/storage': return ;
+ case '/capacity': return ;
+ case '/inventory': return ;
+ case '/dashboards': return ;
+ case '/wallboard': return ;
+ case '/alerts': return ;
+ case '/events': return ;
+ case '/incidents': return ;
+ case '/settings': return ;
+ case '/status': return ;
+ case '/onboarding': return ;
+ case '/loading': return ;
+ case '/error': return ;
+ case '/unauthorized': return ;
+ case '/404': return ;
+ default: return ;
+ }
+}
+
+function App() {
+ const [route, setRoute] = useState(() => routeFromLocation(window.location.pathname));
+ const shellStatus = aggregateStatus(useSystemStatus());
+ useEffect(() => { const handleNavigation = () => setRoute(routeFromLocation(window.location.pathname)); window.addEventListener('popstate', handleNavigation); return () => window.removeEventListener('popstate', handleNavigation); }, []);
+ useEffect(() => {
+ const mobileMenu = document.querySelector('.mobile-more');
+ if (mobileMenu?.open) mobileMenu.open = false;
+ }, [route]);
+ const currentNavigation = navigation.find((item) => item.path === route)
+ ?? navigation.find((item) => item.path !== '/' && route.startsWith(item.path + '/'))
+ ?? navigation[0];
+ if (route === '/wallboard') return ;
+ return ;
+}
+export default App;
diff --git a/apps/web/src/ApplicationPage.tsx b/apps/web/src/ApplicationPage.tsx
new file mode 100644
index 0000000..336adf2
--- /dev/null
+++ b/apps/web/src/ApplicationPage.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { presentReason } from './presentation';
+import { SourceStatusDetails } from './SourceStatusDetails';
+
+type ComponentItem = { id: string; name: string; kind: string; critical: boolean; containerState: string; serviceState: string; status: string; reason?: string };
+type ApplicationItem = { id: string; name: string; status: string; overridden: boolean; components: ComponentItem[]; reasons?: Array<{ code: string; message: string; componentId?: string; critical: boolean }> };
+type ApplicationSnapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; applications: ApplicationItem[]; total: number };
+type ApplicationDetail = { source: ApplicationSnapshot['source']; application: ApplicationItem };
+
+function Badge({ state }: { state: string }) {
+ const normalized = state.toLowerCase();
+ const tone = normalized === 'healthy' ? 'ready' : normalized === 'degraded' || normalized === 'down' ? 'attention' : 'unknown';
+ return {tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'} {normalized === 'healthy' ? copy.applications.healthy : normalized === 'degraded' || normalized === 'down' ? copy.applications.degraded : copy.applications.unknown} ;
+}
+function Source({ source }: { source: ApplicationSnapshot['source'] }) {
+ return ;
+}
+function Components({ items }: { items: ComponentItem[] }) {
+ return {copy.applications.components} ({items.length}) {items.map((item) =>
{item.name} {item.kind} · {item.critical ? copy.applications.critical : copy.applications.optional}
{item.reason || item.id}
)}
;
+}
+
+export function ApplicationPage({ id }: { id?: string }) {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [data, setData] = useState(null);
+ useEffect(() => {
+ const controller = new AbortController();
+ const url = id ? '/api/v1/applications/' + encodeURIComponent(id) : '/api/v1/applications';
+ fetch(url, { signal: controller.signal }).then((response) => {
+ if (!response.ok) throw new Error('applications');
+ return response.json() as Promise;
+ }).then((value) => { setData(value); setState('ready'); }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState('error');
+ });
+ return () => controller.abort();
+ }, [id]);
+ if (state === 'loading') return {copy.applications.loading} ;
+ if (state === 'error' || !data) return ;
+ if (id) {
+ const detail = data as ApplicationDetail;
+ const app = detail.application;
+ return <>{copy.applications.source}
{detail.source?.id || copy.applications.unknown} {app.reasons?.map((reason) => {presentReason(reason.code)}
)} >;
+ }
+ const snapshot = data as ApplicationSnapshot;
+ return <>{copy.applications.source}
{snapshot.source?.id || copy.applications.unknown} {snapshot.total} {copy.applications.rows}
{copy.applications.list}
{copy.applications.overview} {snapshot.applications.length === 0 ? {copy.applications.empty}
: } >;
+}
diff --git a/apps/web/src/ArrayPage.tsx b/apps/web/src/ArrayPage.tsx
new file mode 100644
index 0000000..571aa72
--- /dev/null
+++ b/apps/web/src/ArrayPage.tsx
@@ -0,0 +1,32 @@
+import { formatDateTime } from './locale';
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { presentArrayRole, presentStatus } from './presentation';
+import { SourceStatusDetails } from './SourceStatusDetails';
+
+type Member = { id: string; name: string; role: string; state: string; capacityBytes: number; readBytes: number; writeBytes: number };
+type Check = { id: string; state: string; progressPercent: number; speedBytesPerSecond: number; errors: number; startedAt?: string; completedAt?: string };
+type ArraySnapshot = { contractVersion: string; source: { id: string; state: string; freshness: string; observedAt?: string; receivedAt?: string; reason?: string }; state: string; parity: { present: boolean; state: string; errors: number }; members?: Member[] | null; currentCheck?: Check; history?: Check[] | null };
+
+function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
+function date(value?: string): string { return formatDateTime(value); }
+function Badge({ label, ready }: { label: string; ready: boolean }) { return {ready ? '✓' : '?'} {label} ; }
+
+export function ArrayPage() {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [snapshot, setSnapshot] = useState(null);
+ useEffect(() => { const controller = new AbortController(); fetch('/api/v1/array', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('array'); return response.json() as Promise; }).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
+ if (state === 'loading') return ;
+ if (state === 'error' || !snapshot) return × {copy.array.errorTitle} {copy.array.errorDetail}
;
+ const available = snapshot.source?.state !== 'unknown' && snapshot.source?.freshness === 'fresh';
+ const stateLabel = snapshot.state === 'operational' ? copy.array.operational : snapshot.state === 'degraded' ? copy.array.degraded : snapshot.state === 'missing' ? copy.array.missing : copy.array.unknown;
+ const members = Array.isArray(snapshot.members) ? snapshot.members : [];
+ const history = Array.isArray(snapshot.history) ? snapshot.history : [];
+ return <>
+ {copy.array.eyebrow}
{copy.array.title} {copy.array.intro}
+ {copy.array.source}
{snapshot.source?.id || copy.array.unknown} {copy.array.readOnly}
+ {copy.array.parity}
{snapshot.parity.present ? copy.array.present : copy.array.notPresent} {presentStatus(snapshot.parity.state)} · {copy.array.errors}: {snapshot.parity.errors}
{copy.array.members}
{members.length} {members.filter((member) => member.state !== 'online').length} {copy.array.notOperational}
{snapshot.currentCheck && {copy.array.currentCheck}
{snapshot.currentCheck.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% {presentStatus(snapshot.currentCheck.state)} · {bytes(snapshot.currentCheck.speedBytesPerSecond)}/s · {copy.array.errors}: {snapshot.currentCheck.errors}
}
+ {copy.array.members}
{copy.array.membersTitle} {members.length === 0 ? {copy.array.noMembers}
: {copy.array.name} {copy.array.role} {copy.array.state} {copy.array.capacity} {copy.array.io} {members.map((member) => {member.name} {presentArrayRole(member.role)} {bytes(member.capacityBytes)} {bytes(member.readBytes)} gelezen / {bytes(member.writeBytes)} geschreven )}
}
+ {copy.array.history} {history.length === 0 ? {copy.array.noHistory}
: ID {copy.array.state} {copy.array.progress} {copy.array.speed} {copy.array.completed} {history.map((check) => {check.id} {presentStatus(check.state)} {check.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% {bytes(check.speedBytesPerSecond)}/s {date(check.completedAt)} )}
}
+ >;
+}
diff --git a/apps/web/src/CapacityPage.tsx b/apps/web/src/CapacityPage.tsx
new file mode 100644
index 0000000..5227f7f
--- /dev/null
+++ b/apps/web/src/CapacityPage.tsx
@@ -0,0 +1,52 @@
+import { formatDateTime } from './locale';
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+
+type Forecast = {
+ entityId: string;
+ name: string;
+ kind: string;
+ enabled: boolean;
+ method: string;
+ windowSeconds: number;
+ dataPoints: number;
+ confidence: string;
+ currentUsedBytes: number;
+ capacityBytes: number;
+ rateBytesPerDay: number;
+ daysToCapacity?: number;
+ projectedAt?: string;
+ reason?: string;
+};
+type Snapshot = { contractVersion: string; generatedAt: string; policy: { enabled: boolean; windowSeconds: number; minPoints: number; method: string }; items: Forecast[]; qualifiedCount: number; reason?: string };
+
+function formatBytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let amount = value; let index = 0; while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1; } return `${amount.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${units[index]}`; }
+function formatWindow(seconds: number): string { const days = Math.round(seconds / 86400); return `${days} ${days === 1 ? copy.capacity.day : copy.capacity.days}`; }
+function formatDate(value?: string): string { return formatDateTime(value); }
+function label(value: string): string {
+ const labels: Record = {
+ linear_median_rate: copy.capacity.linearMedian, insufficient_data: copy.capacity.insufficient,
+ disabled: copy.capacity.disabled, high: copy.capacity.high, medium: copy.capacity.medium,
+ low: copy.capacity.low, none: copy.capacity.none, insufficient_points: copy.capacity.insufficientPoints,
+ insufficient_time_span: copy.capacity.insufficientSpan, history_stale: copy.capacity.historyStale,
+ history_unavailable: copy.capacity.historyUnavailable, source_unavailable: copy.capacity.sourceUnavailable,
+ no_capacity_entities: copy.capacity.noEntities, capacity_unknown_or_reached: copy.capacity.capacityUnknown,
+ no_positive_growth: copy.capacity.noGrowth, bulk_import_detected: copy.capacity.bulkImport,
+ irregular_intervals: copy.capacity.irregular, disabled_by_policy: copy.capacity.disabledByPolicy,
+ };
+ return labels[value] ?? copy.capacity.unknown;
+}
+
+export function CapacityPage() {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [snapshot, setSnapshot] = useState(null);
+ useEffect(() => { const controller = new AbortController(); fetch('/api/v1/forecasts', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('forecasts'); return response.json() as Promise; }).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
+ if (state === 'loading') return ;
+ if (state === 'error' || !snapshot) return × {copy.capacity.errorTitle} {copy.capacity.errorDetail}
;
+ return <>{copy.capacity.eyebrow}
{copy.capacity.title} {copy.capacity.intro}
{copy.capacity.policy}
{snapshot.policy.enabled ? copy.capacity.enabled : copy.capacity.disabled} {copy.capacity.method}: {label(snapshot.policy.method)} · {copy.capacity.window}: {formatWindow(snapshot.policy.windowSeconds)} · {copy.capacity.minimum}: {snapshot.policy.minPoints}
i {copy.capacity.readOnly}{copy.capacity.observed}: {formatDate(snapshot.generatedAt)} · {snapshot.qualifiedCount ?? 0} {copy.capacity.items} · {snapshot.items.length} {copy.capacity.assessments}
{snapshot.items.length ? snapshot.items.map((item) => ) : {copy.capacity.emptyTitle} {snapshot.reason ? label(snapshot.reason) : copy.capacity.empty}
{copy.capacity.openShares} } >;
+}
+
+function ForecastCard({ item }: { item: Forecast }) {
+ const qualified = item.confidence === 'high' || item.confidence === 'medium';
+ return {item.kind === 'share' ? copy.capacity.share : item.kind}
{item.name || item.entityId || copy.capacity.unknown} {qualified ? '✓' : '?'} {label(item.confidence)}
{copy.capacity.method} {label(item.method)}
{copy.capacity.window} {formatWindow(item.windowSeconds)}
{copy.capacity.points} {item.dataPoints}
{copy.capacity.current} {formatBytes(item.currentUsedBytes)} / {item.capacityBytes > 0 ? formatBytes(item.capacityBytes) : '—'}
{copy.capacity.rate} {qualified && item.rateBytesPerDay > 0 ? `${formatBytes(item.rateBytesPerDay)} / ${copy.capacity.day}` : '—'} {qualified && item.daysToCapacity !== undefined &&
{copy.capacity.projected} {Math.round(item.daysToCapacity)} {copy.capacity.days} · {formatDate(item.projectedAt)} }{item.reason ? label(item.reason) : copy.capacity.qualified}
;
+}
diff --git a/apps/web/src/ContainerPage.tsx b/apps/web/src/ContainerPage.tsx
new file mode 100644
index 0000000..f3272ae
--- /dev/null
+++ b/apps/web/src/ContainerPage.tsx
@@ -0,0 +1,144 @@
+import { formatDateTime } from './locale';
+import { useDeferredValue, useEffect, useRef, useState } from 'react';
+import { copy } from './copy';
+import { queryValue, replaceListQuery } from './listQuery';
+import { presentReason, presentStatus } from './presentation';
+
+type ContainerItem = {
+ id: string;
+ name: string;
+ image?: string;
+ imageDigest?: string;
+ state: string;
+ health: string;
+ intentionalStop: boolean;
+ metricsAvailable?: boolean;
+ lifecycleAvailable?: boolean;
+ uptimeSeconds: number;
+ restartCount: number;
+ exitCode: number;
+ cpuPercent: number;
+ memoryBytes: number;
+ memoryLimitBytes: number;
+ networkRxBytes: number;
+ networkTxBytes: number;
+ blockReadBytes: number;
+ blockWriteBytes: number;
+ project?: string;
+ ports?: Array<{ containerPort: number; hostPort?: number; protocol: string }>;
+};
+type ContainerSnapshot = { source: { id: string; state: string; reason?: string }; containers: ContainerItem[]; total: number; nextCursor?: string };
+
+function bytes(value: number): string {
+ if (!Number.isFinite(value) || value < 0) return '—';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let scaled = value;
+ let index = 0;
+ while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; }
+ return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index];
+}
+function duration(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds < 0) return '—';
+ const hours = Math.floor(seconds / 3600);
+ return hours >= 24 ? Math.floor(hours / 24) + ' d ' + (hours % 24) + ' u' : hours + ' u';
+}
+type BadgeTone = 'ready' | 'attention' | 'unknown';
+function Badge({ label, tone }: { label: string; tone: BadgeTone }) {
+ return {tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'} {label} ;
+}
+function runtimeTone(state: string, sourceHealthy = true): BadgeTone {
+ if (!sourceHealthy) return 'unknown';
+ switch (state.toLowerCase()) {
+ case 'running': return 'ready';
+ case 'restarting': case 'paused': case 'exited': case 'dead': case 'stopped': return 'attention';
+ default: return 'unknown';
+ }
+}
+function healthTone(health: string, sourceHealthy = true): BadgeTone {
+ if (!sourceHealthy) return 'unknown';
+ switch (health.toLowerCase()) {
+ case 'healthy': return 'ready';
+ case 'unhealthy': return 'attention';
+ default: return 'unknown';
+ }
+}
+
+export function ContainerPage() {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [snapshot, setSnapshot] = useState(null);
+ const [query, setQuery] = useState(() => queryValue('q'));
+ const deferredQuery = useDeferredValue(query);
+ const [stateFilter, setStateFilter] = useState(() => queryValue('state'));
+ const [healthFilter, setHealthFilter] = useState(() => queryValue('health'));
+ const [sort, setSort] = useState(() => queryValue('sort', ['name', 'cpu', 'memory', 'state'], 'name'));
+ const [cursor, setCursor] = useState(() => queryValue('after'));
+ const [history, setHistory] = useState([]);
+ const pageStatus = useRef(null);
+ const [reload, setReload] = useState(0);
+ useEffect(() => {
+ const controller = new AbortController();
+ setState((current) => current === 'ready' ? 'ready' : 'loading');
+ const params = new URLSearchParams({ limit: '25', sort });
+ if (deferredQuery.trim()) params.set('q', deferredQuery.trim());
+ if (stateFilter) params.set('state', stateFilter);
+ if (healthFilter) params.set('health', healthFilter);
+ if (cursor) params.set('after', cursor);
+ replaceListQuery({ q: deferredQuery.trim(), state: stateFilter, health: healthFilter, sort: sort === 'name' ? '' : sort, after: cursor });
+ fetch('/api/v1/containers?' + params, { signal: controller.signal }).then((response) => {
+ if (!response.ok) throw new Error('containers');
+ return response.json() as Promise;
+ }).then((data) => { setSnapshot(data); setState('ready'); if (cursor) requestAnimationFrame(() => pageStatus.current?.focus()); }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState('error');
+ });
+ return () => controller.abort();
+ }, [deferredQuery, stateFilter, healthFilter, sort, cursor, reload]);
+ const resetPage = () => { setCursor(''); setHistory([]); };
+ const previous = () => { const prior = [...history]; setCursor(prior.pop() ?? ''); setHistory(prior); };
+ const next = () => { if (!snapshot?.nextCursor) return; setHistory((values) => [...values, cursor]); setCursor(snapshot.nextCursor ?? ''); };
+ if (state === 'loading') return {copy.containers.loading} ;
+ if (state === 'error' || !snapshot) return × {copy.containers.errorTitle} {copy.containers.errorDetail}
setReload((value) => value + 1)}>{copy.containers.retry} ;
+ const available = snapshot.source?.state === 'healthy';
+ return <>
+
+ {copy.containers.source}
{snapshot.source?.id || copy.containers.unknown} {snapshot.source?.reason ? presentReason(snapshot.source.reason) : copy.containers.readOnly}
{snapshot.total} {copy.containers.rows} · {copy.containers.limitNote}
+
+ {copy.containers.list}
{copy.containers.top}
+
+ {snapshot.containers.length === 0 ? {copy.containers.empty}
: <>{copy.containers.name} {copy.containers.state} {copy.containers.health} {copy.containers.resources} {copy.containers.image} {snapshot.containers.map((item) => {item.name} {item.project || copy.containers.noProject} · {item.lifecycleAvailable ? duration(item.uptimeSeconds) : copy.containers.unknown} {item.metricsAvailable ? item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : copy.containers.unknown}{item.metricsAvailable ? `${bytes(item.memoryBytes)} / ${bytes(item.memoryLimitBytes)}` : copy.containers.metricsUnavailable} {item.image || copy.containers.unknown} )}
{snapshot.containers.map((item) => {item.name}
{copy.containers.resources} {item.metricsAvailable ? `${item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% · ${bytes(item.memoryBytes)}` : copy.containers.unknown}
{copy.containers.image} {item.image || copy.containers.unknown} )} >}
+ {copy.containers.previous} {copy.containers.page} {history.length + 1} {copy.containers.next}
+
+ >;
+}
+type ContainerDetailResponse = { source: { id: string; state: string; freshness: string; observedAt?: string; receivedAt?: string; reason?: string }; container: ContainerItem };
+
+export function ContainerDetailPage({ id }: { id: string }) {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [detail, setDetail] = useState(null);
+ useEffect(() => {
+ const controller = new AbortController();
+ fetch('/api/v1/containers/' + encodeURIComponent(id), { signal: controller.signal }).then((response) => {
+ if (!response.ok) throw new Error('container-detail');
+ return response.json() as Promise;
+ }).then((data) => { setDetail(data); setState('ready'); }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState('error');
+ });
+ return () => controller.abort();
+ }, [id]);
+ if (state === 'loading') return {copy.containers.loading} ;
+ if (state === 'error' || !detail) return ;
+ const item = detail.container;
+ const fresh = detail.source?.freshness === 'fresh';
+ return <>
+
+ {copy.containers.source}
{detail.source?.id || copy.containers.unknown} {detail.source?.reason ? presentReason(detail.source.reason) : (fresh ? copy.containers.fresh : copy.containers.stale)}
{copy.containers.observed}: {detail.source?.observedAt ? formatDateTime(detail.source.observedAt) : '—'} · {fresh ? copy.containers.fresh : copy.containers.stale}
{item.intentionalStop && {copy.containers.intentionalStop} }
+ {copy.containers.resources}
{item.metricsAvailable ? item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : copy.containers.unknown} {item.metricsAvailable ? `${bytes(item.memoryBytes)} / ${bytes(item.memoryLimitBytes)}` : copy.containers.metricsUnavailable} · {copy.containers.restarts}: {item.lifecycleAvailable ? item.restartCount : copy.containers.unknown}
{copy.containers.image}
{item.image || copy.containers.unknown} {item.project || copy.containers.noProject}
+ {copy.containers.technical} ID {item.id} {copy.containers.exitCode} {item.lifecycleAvailable ? item.exitCode : copy.containers.unknown} {copy.containers.network} {item.metricsAvailable ? `${bytes(item.networkRxBytes)} RX / ${bytes(item.networkTxBytes)} TX` : copy.containers.unknown} {copy.containers.blockIO} {item.metricsAvailable ? `${bytes(item.blockReadBytes)} read / ${bytes(item.blockWriteBytes)} write` : copy.containers.unknown} {copy.containers.digest} {item.imageDigest || copy.containers.noDigest} {copy.containers.ports} {item.ports?.length ? {item.ports.map((port) => {port.hostPort || '—'} → {port.containerPort}/{port.protocol} )} : {copy.containers.noData}
}
+ >;
+}
diff --git a/apps/web/src/DashboardEditor.tsx b/apps/web/src/DashboardEditor.tsx
new file mode 100644
index 0000000..f85ff97
--- /dev/null
+++ b/apps/web/src/DashboardEditor.tsx
@@ -0,0 +1,244 @@
+import { useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react';
+import { copy } from './copy';
+import { WidgetConfigDrawer, type PreviewResult, type ValidationErrors } from './WidgetConfigDrawer';
+import { DashboardVariablesEditor, validateVariables, type DashboardVariable } from './DashboardVariablesEditor';
+import { DashboardTransfer } from './DashboardTransfer';
+
+export type RecordValue = Record;
+export type EditorWidget = { id: string; type: string; title: string; description?: string; data?: RecordValue; visualization?: RecordValue; behavior?: RecordValue; layouts?: RecordValue };
+type EditorProps = { dashboardId: string; revision: number; document: RecordValue; onExit: () => void; onSaved: (revision: number, document: RecordValue) => void };
+
+const shell = copy.editor.shell;
+const cardCopy = copy.editor.card;
+const messages = copy.editor.messages;
+const validation = copy.editor.validation;
+const widgetTypeCopy = copy.editor.widgetTypes;
+
+export const types = ['stat', 'timeseries', 'gauge', 'ranked-list', 'status-grid', 'table', 'heatmap', 'event-timeline', 'storage-map', 'topology', 'service-matrix', 'alert-summary', 'text', 'query-inspector'];
+export const labels: Record = { stat: widgetTypeCopy.stat, timeseries: widgetTypeCopy.timeseries, gauge: widgetTypeCopy.gauge, 'ranked-list': widgetTypeCopy.rankedList, 'status-grid': widgetTypeCopy.statusGrid, table: widgetTypeCopy.table, heatmap: widgetTypeCopy.heatmap, 'event-timeline': widgetTypeCopy.eventTimeline, 'storage-map': widgetTypeCopy.storageMap, topology: widgetTypeCopy.topology, 'service-matrix': widgetTypeCopy.serviceMatrix, 'alert-summary': widgetTypeCopy.alertSummary, text: widgetTypeCopy.text };
+const sourceTypes = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'];
+const ranges = ['live', '15m', '1h', '6h', '24h', '7d'];
+const aggregations = ['avg', 'min', 'max', 'sum', 'last'];
+type Viewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
+const viewports: Viewport[] = ['desktop', 'tablet', 'mobile', 'wallboard'];
+const viewportLabels: Record = { desktop: copy.editor.viewports.desktop, tablet: copy.editor.viewports.tablet, mobile: copy.editor.viewports.mobile, wallboard: copy.editor.viewports.wallboard };
+const viewportColumns: Record = { desktop: 18, tablet: 8, mobile: 1, wallboard: 24 };
+
+function read(value: RecordValue | undefined, name: string): T | undefined {
+ if (!value) return undefined;
+ const upper = name.charAt(0).toUpperCase() + name.slice(1);
+ return (value[name] ?? value[upper] ?? value[name.toUpperCase()]) as T | undefined;
+}
+function copyWidget(value: EditorWidget): EditorWidget { return JSON.parse(JSON.stringify(value)) as EditorWidget; }
+function widgetId(): string { return typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : 'widget-' + Date.now().toString(36); }
+function layout(widget: EditorWidget, viewport: Viewport = 'desktop'): RecordValue {
+ const layouts = widget.layouts ?? {};
+ const fallback = read(layouts, 'desktop') ?? {};
+ return { ...fallback, ...(read(layouts, viewport) ?? {}) };
+}
+function withLayout(widget: EditorWidget, viewport: Viewport, nextLayout: RecordValue): EditorWidget {
+ return { ...widget, layouts: { ...(widget.layouts ?? {}), [viewport]: nextLayout } };
+}
+function layoutWidth(widget: EditorWidget, viewport: Viewport): number {
+ return Math.min(viewportColumns[viewport], Math.max(1, Number(layout(widget, viewport).w ?? 6) || 1));
+}
+function normalizeWidget(widget: EditorWidget, index: number): EditorWidget {
+ const base = { x: 0, y: index * 4, w: widget.type === 'timeseries' ? 9 : 6, h: 4, visible: true };
+ const layouts = widget.layouts ?? {};
+ const desktop = { ...base, ...(read(layouts, 'desktop') ?? {}) };
+ const tablet = { ...desktop, ...(read(layouts, 'tablet') ?? {}) };
+ const mobile = { ...desktop, ...(read(layouts, 'mobile') ?? {}), w: 1 };
+ const wallboard = { ...desktop, ...(read(layouts, 'wallboard') ?? {}) };
+ return { ...widget, data: { sourceType: 'inventory', limit: 100, ...(widget.data ?? {}) }, visualization: { decimals: 0, thresholds: [], ...(widget.visualization ?? {}) }, behavior: { locked: false, hidden: false, liveIntervalSeconds: 30, ...(widget.behavior ?? {}) }, layouts: { desktop, tablet, mobile, wallboard } };
+}
+function newWidget(type: string, index: number): EditorWidget {
+ const baseLayout = { x: 0, y: index * 4, w: type === 'timeseries' ? 9 : 6, h: 4, visible: true };
+ return { id: widgetId(), type, title: labels[type] ?? widgetTypeCopy.newWidget, data: { sourceType: type === 'text' ? 'text' : 'inventory', limit: 100 }, visualization: { decimals: 0, thresholds: [] }, behavior: { locked: false, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, liveIntervalSeconds: 30 }, layouts: { desktop: baseLayout, tablet: baseLayout, mobile: { ...baseLayout, w: 1 }, wallboard: baseLayout } };
+}
+
+function numberValue(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
+}
+export function validateWidget(widget: EditorWidget): ValidationErrors {
+ const errors: ValidationErrors = {};
+ if (!widget.id.trim()) errors.id = validation.idRequired;
+ if (!types.includes(widget.type)) errors.type = validation.typeUnsupported;
+ if (!widget.title.trim()) errors.title = validation.titleRequired;
+ if (widget.title.length > 120) errors.title = validation.titleTooLong;
+ const data = widget.data ?? {};
+ const source = typeof data.sourceType === 'string' ? data.sourceType : '';
+ if (!sourceTypes.includes(source)) errors['data.sourceType'] = validation.sourceUnsupported;
+ if (source === 'semantic-metric') {
+ const metric = typeof data.metric === 'string' ? data.metric.trim() : '';
+ if (!metric) errors['data.metric'] = validation.metricRequired;
+ if (/promql|query=/i.test(metric)) errors['data.metric'] = validation.metricNoPromql;
+ }
+ if (typeof data.range === 'string' && data.range && !ranges.includes(data.range) && !data.range.startsWith('$')) errors['data.range'] = validation.rangeUnsupported;
+ if (typeof data.aggregation === 'string' && data.aggregation && !aggregations.includes(data.aggregation)) errors['data.aggregation'] = validation.aggregationUnsupported;
+ const limit = numberValue(data.limit);
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 1000)) errors['data.limit'] = validation.limitRange;
+ const visualization = widget.visualization ?? {};
+ const decimals = numberValue(visualization.decimals);
+ if (decimals !== undefined && (!Number.isInteger(decimals) || decimals < 0 || decimals > 6)) errors['visualization.decimals'] = validation.decimalsRange;
+ const minimum = numberValue(visualization.min);
+ const maximum = numberValue(visualization.max);
+ if (minimum !== undefined && maximum !== undefined && minimum > maximum) errors['visualization.max'] = validation.maxBelowMin;
+ const interval = numberValue((widget.behavior ?? {}).liveIntervalSeconds);
+ if (interval !== undefined && (!Number.isInteger(interval) || interval < 1 || interval > 300)) errors['behavior.liveIntervalSeconds'] = validation.intervalRange;
+ return errors;
+}
+
+export function DashboardEditor({ dashboardId, revision, document, onExit, onSaved }: EditorProps) {
+ const initial = (read(document, 'widgets') ?? []) as EditorWidget[];
+ const initialVariables = (read(document, 'variables') ?? []) as DashboardVariable[];
+ const [draft, setDraft] = useState(initial.map((widget, index) => normalizeWidget(copyWidget(widget), index)));
+ const [variables, setVariables] = useState(initialVariables.map((variable) => ({ ...variable, options: variable.options ? [...variable.options] : undefined })));
+ const [history, setHistory] = useState([]);
+ const [future, setFuture] = useState([]);
+ const [savedSnapshot, setSavedSnapshot] = useState(() => JSON.stringify({ widgets: initial.map((widget, index) => normalizeWidget(copyWidget(widget), index)), variables: initialVariables }));
+ const [confirmExit, setConfirmExit] = useState(false);
+ const [conflict, setConflict] = useState(false);
+ const [selected, setSelected] = useState(initial[0]?.id ?? null);
+ const [newType, setNewType] = useState(types[0]);
+ const [activeViewport, setActiveViewport] = useState(() => window.innerWidth <= 700 ? 'mobile' : 'desktop');
+ const [dragged, setDragged] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useState('');
+ const [preview, setPreview] = useState(null);
+ const [previewFor, setPreviewFor] = useState(null);
+ const [previewState, setPreviewState] = useState('loading');
+ const [previewing, setPreviewing] = useState(false);
+ const [previewError, setPreviewError] = useState('');
+ const resize = useRef<{ id: string; x: number; w: number } | null>(null);
+ const drag = useRef<{ id: string; y: number } | null>(null);
+
+ const update = (id: string, change: (widget: EditorWidget) => EditorWidget) => applyDraft((items) => items.map((item) => item.id === id ? change(item) : item));
+ const selectedWidget = draft.find((item) => item.id === selected);
+ const dirty = JSON.stringify({ widgets: draft, variables }) !== savedSnapshot;
+ const applyDraft = (change: (items: EditorWidget[]) => EditorWidget[]): void => setDraft((items) => {
+ const next = change(items);
+ if (next === items) return items;
+ setHistory((entries) => [...entries, items.map(copyWidget)].slice(-50));
+ setFuture([]);
+ return next;
+ });
+ const selectedErrors = selectedWidget ? validateWidget(selectedWidget) : {};
+ const updateSelected = (next: EditorWidget) => { setPreview(null); setPreviewFor(null); setPreviewError(''); if (selected) update(selected, () => next); };
+ const move = (id: string, direction: -1 | 1) => applyDraft((items) => {
+ const index = items.findIndex((item) => item.id === id);
+ const next = index + direction;
+ if (index < 0 || next < 0 || next >= items.length || read(items[index].behavior, 'locked')) return items;
+ const copy = [...items]; const [item] = copy.splice(index, 1); copy.splice(next, 0, item); return copy;
+ });
+ const onWidgetPointerDown = (event: ReactPointerEvent, id: string, locked: boolean) => {
+ if (locked || (event.target as HTMLElement).closest('button, input, select, textarea')) return;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ drag.current = { id, y: event.clientY };
+ setDragged(id);
+ };
+ const onWidgetPointerMove = (event: ReactPointerEvent, id: string) => {
+ const active = drag.current;
+ if (!active || active.id !== id) return;
+ const delta = event.clientY - active.y;
+ if (Math.abs(delta) < 28) return;
+ move(id, delta > 0 ? 1 : -1);
+ drag.current = { id, y: event.clientY };
+ };
+ const onWidgetPointerUp = (event: ReactPointerEvent) => {
+ drag.current = null;
+ setDragged(null);
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
+ };
+ const duplicate = (id: string) => applyDraft((items) => {
+ const source = items.find((item) => item.id === id); if (!source) return items;
+ const copy = copyWidget(source); copy.id = widgetId(); copy.title = copy.title + widgetTypeCopy.copySuffix; copy.behavior = { ...(copy.behavior ?? {}), locked: false, hidden: false }; return [...items, copy];
+ });
+ const remove = (id: string) => { applyDraft((items) => items.filter((item) => item.id !== id)); setSelected((value) => value === id ? null : value); };
+ const add = () => { const item = newWidget(newType, draft.length); applyDraft((items) => [...items, item]); setSelected(item.id); };
+ // Single width mutation used by the pointer handle, the arrow keys and the
+ // width field in the configuration drawer, so all three stay in step.
+ const setWidgetWidth = (id: string, next: number) => {
+ if (!Number.isFinite(next)) return;
+ const width = Math.min(viewportColumns[activeViewport], Math.max(1, Math.round(next)));
+ update(id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), w: width }));
+ };
+ const onResizeMove = (event: ReactPointerEvent) => {
+ const active = resize.current; if (!active) return;
+ setWidgetWidth(active.id, active.w + Math.round((event.clientX - active.x) / 48));
+ };
+ // Keyboard alternative for the pointer-only resize handle (UX_SPEC 5 and 12).
+ const onResizeKeyDown = (event: ReactKeyboardEvent, id: string, current: number) => {
+ const max = viewportColumns[activeViewport];
+ const step = event.key === 'ArrowRight' || event.key === 'ArrowUp' ? 1 : event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -1 : 0;
+ if (step === 0 && event.key !== 'Home' && event.key !== 'End') return;
+ event.preventDefault();
+ event.stopPropagation();
+ setWidgetWidth(id, event.key === 'Home' ? 1 : event.key === 'End' ? max : current + step);
+ };
+ const undo = () => {
+ const previous = history[history.length - 1];
+ if (!previous) return;
+ setHistory((entries) => entries.slice(0, -1));
+ setFuture((entries) => [draft.map(copyWidget), ...entries].slice(0, 50));
+ setDraft(previous.map(copyWidget));
+ };
+ const redo = () => {
+ const next = future[0];
+ if (!next) return;
+ setFuture((entries) => entries.slice(1));
+ setHistory((entries) => [...entries, draft.map(copyWidget)].slice(-50));
+ setDraft(next.map(copyWidget));
+ };
+ const requestExit = () => { if (dirty) setConfirmExit(true); else onExit(); };
+ const importDocument = (nextDocument: RecordValue) => {
+ const importedWidgets = (read(nextDocument, 'widgets') ?? []) as EditorWidget[];
+ const importedVariables = (read(nextDocument, 'variables') ?? []) as DashboardVariable[];
+ setDraft(importedWidgets.map((widget, index) => normalizeWidget(copyWidget(widget), index)));
+ setVariables(importedVariables);
+ setHistory([]); setFuture([]); setMessage(messages.importLoaded);
+ };
+ const reloadServer = async () => {
+ try {
+ const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId));
+ if (!response.ok) throw new Error('reload');
+ const data = await response.json() as { version?: RecordValue };
+ const serverDocument = read(data.version, 'document') ?? {};
+ const serverWidgets = (read(serverDocument, 'widgets') ?? []) as EditorWidget[];
+ const next = serverWidgets.map((widget, index) => normalizeWidget(copyWidget(widget), index));
+ const nextVariables = (read(serverDocument, 'variables') ?? []) as DashboardVariable[];
+ setDraft(next); setVariables(nextVariables); setSavedSnapshot(JSON.stringify({ widgets: next, variables: nextVariables })); setHistory([]); setFuture([]); setConflict(false); setMessage(messages.serverLoaded);
+ } catch { setMessage(messages.serverLoadFailed); }
+ };
+ const previewSelected = async () => {
+ if (!selectedWidget || Object.keys(selectedErrors).length > 0) { setPreviewError(messages.previewBlocked); return; }
+ setPreviewing(true); setPreviewError('');
+ try {
+ const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId) + '/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ widget: selectedWidget, state: previewState }) });
+ const data = await response.json() as { preview?: PreviewResult; detail?: string };
+ if (!response.ok || !data.preview) { setPreviewError(data.detail ?? messages.previewFailed); return; }
+ setPreview(data.preview); setPreviewFor(selectedWidget.id);
+ } catch { setPreviewError(messages.previewFailedConnection); } finally { setPreviewing(false); }
+ };
+ const save = async () => {
+ const invalid = draft.map((widget) => ({ widget, errors: validateWidget(widget) })).find((item) => Object.keys(item.errors).length > 0);
+ if (invalid) { setSelected(invalid.widget.id); setMessage(messages.saveBlockedWidgets); return; }
+ if (Object.keys(validateVariables(variables)).length > 0) { setMessage(messages.saveBlockedVariables); return; }
+ setSaving(true); setMessage('');
+ const body = { ...document, widgets: draft, variables };
+ try {
+ const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId) + '/document', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'If-Match': String(revision) }, body: JSON.stringify(body) });
+ if (!response.ok) { if (response.status === 409) { setConflict(true); setMessage(messages.saveConflict); } else setMessage(messages.saveFailed); return; }
+ const data = await response.json() as RecordValue;
+ const summary = read(data, 'dashboard') ?? {};
+ setSavedSnapshot(JSON.stringify({ widgets: body.widgets, variables: body.variables })); setConflict(false); onSaved(Number(read(summary, 'revision') ?? revision + 1), body);
+ } catch { setMessage(messages.saveFailedConnection); } finally { setSaving(false); }
+ };
+
+ return
+
+ {conflict && {shell.conflictTitle} {shell.conflictDetail} {shell.reloadServer}
}{confirmExit && {shell.confirmExitTitle} {shell.confirmExitDetail} setConfirmExit(false)}>{shell.keepEditing} {shell.leaveWithoutSaving}
}{shell.advanced} {shell.layout} setActiveViewport(event.target.value as Viewport)}>{viewports.map((viewport) => {viewportLabels[viewport]} )} {shell.addWidget} setNewType(event.target.value)}>{types.map((type) => {labels[type]} )} {shell.addWidget} {message &&
{message}
}
+ {shell.canvas}
+ {draft.map((widget) => { const activeLayout = layout(widget, activeViewport); const locked = read
(widget.behavior, 'locked') === true; const viewportHidden = read(activeLayout, 'visible') === false; const hidden = read(widget.behavior, 'hidden') === true || viewportHidden; const errors = validateWidget(widget); const width = layoutWidth(widget, activeViewport); return onWidgetPointerDown(event, widget.id, locked)} onPointerMove={(event) => onWidgetPointerMove(event, widget.id)} onPointerUp={onWidgetPointerUp} onPointerCancel={onWidgetPointerUp} onClick={() => { setSelected(widget.id); setPreview(null); setPreviewFor(null); }}>{labels[widget.type] ?? widgetTypeCopy.fallback}
{widget.title || widgetTypeCopy.untitled} {locked ? cardCopy.locked : cardCopy.movable} { event.stopPropagation(); move(widget.id, -1); }}>{cardCopy.moveUpVisible} { event.stopPropagation(); move(widget.id, 1); }}>{cardCopy.moveDownVisible} { event.stopPropagation(); update(widget.id, (item) => ({ ...item, behavior: { ...(item.behavior ?? {}), locked: !locked } })); }}>{locked ? cardCopy.unlock : cardCopy.lock} { event.stopPropagation(); update(widget.id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), visible: viewportHidden })); }}>{viewportHidden ? cardCopy.show : cardCopy.hide} { event.stopPropagation(); duplicate(widget.id); }}>{cardCopy.duplicate} { event.stopPropagation(); remove(widget.id); }}>{cardCopy.remove} onResizeKeyDown(event, widget.id, width)} onPointerDown={(event) => { event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); resize.current = { id: widget.id, x: event.clientX, w: width }; }} onPointerMove={onResizeMove} onPointerUp={() => { resize.current = null; }}>{cardCopy.resizeVisible}
{cardCopy.resizeHint}
{Object.keys(errors).length > 0 && {cardCopy.incompleteConfig}
}{locked ? cardCopy.lockedHint : cardCopy.dragHint}
; })}
+ {selectedWidget ?
setWidgetWidth(selectedWidget.id, value)} onPreviewStateChange={setPreviewState} onPreview={previewSelected} /> : }
+ ;
+}
diff --git a/apps/web/src/DashboardRuntimeWidget.tsx b/apps/web/src/DashboardRuntimeWidget.tsx
new file mode 100644
index 0000000..571951c
--- /dev/null
+++ b/apps/web/src/DashboardRuntimeWidget.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useMemo, useState, type CSSProperties } from 'react';
+import { resolveDashboardScope } from './dashboardScope';
+import { LiveChartAdapter, historicalSamplesFromData, type LiveFreshness } from './liveBuffer';
+import { LiveClient } from './liveClient';
+import { MetricClient, rangeForPreset, type MetricQueryRequest, type MetricRangePreset } from './metricClient';
+import { MetricWidget, StatusGridWidget, type MetricWidgetProps, type StatusGridItem } from './MetricWidgets';
+import { buildStorageNodes, type StorageData } from './StoragePage';
+import { StorageMapWidget } from './StorageVisuals';
+import { aggregateStatus, useSystemStatus } from './systemStatus';
+import { useLiveMetric } from './useLiveMetric';
+import { useMetricQuery } from './useMetricQuery';
+import { formatDateTime } from './locale';
+import { copy } from './copy';
+import { presentEventSummary, presentEventType, presentReason, presentStatus } from './presentation';
+import { wallboardColumns, wallboardPlacement } from './wallboardLayout';
+
+type RecordValue = Record;
+export type RuntimeWidget = { id: string; type: string; title: string; description?: string; data?: RecordValue; visualization?: RecordValue; behavior?: RecordValue; layouts?: RecordValue };
+export type RuntimeState = 'loading' | 'usable' | 'empty' | 'error';
+
+const metricClient = new MetricClient();
+const liveClient = new LiveClient();
+
+function field(record: RecordValue | undefined, name: string): T | undefined {
+ if (!record) return undefined;
+ const upper = name.charAt(0).toUpperCase() + name.slice(1);
+ return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
+}
+
+function Badge({ state }: { state: RuntimeState }) {
+ const usable = state === 'usable';
+ const label = usable ? copy.dashboards.runtime.current : state === 'loading' ? copy.dashboards.runtime.loading : state === 'empty' ? copy.dashboards.runtime.empty : copy.dashboards.runtime.error;
+ return {usable ? '✓' : '?'} {label} ;
+}
+
+type Resource = { state: RuntimeState; items?: StatusGridItem[]; storage?: StorageData; events?: EventItem[]; error?: string };
+type EventItem = { id: string; type: string; severity: string; summary: string; occurredAt: string; sourceId?: string };
+
+function useInventoryResource(widget: RuntimeWidget): Resource {
+ const [resource, setResource] = useState({ state: 'loading' });
+ const sourceType = String(field(widget.data, 'sourceType') ?? '');
+ const scope = field(widget.data, 'scope') ?? {};
+ const entityType = String(field(scope, 'entityType') ?? '');
+ const isApplications = sourceType === 'inventory' && entityType === 'application';
+ const isStorage = sourceType === 'inventory' && Array.isArray(field(scope, 'entityTypes'));
+ const isEvents = sourceType === 'events';
+ const interval = Math.min(300, Math.max(5, Number(field(widget.behavior, 'liveIntervalSeconds') ?? 15))) * 1000;
+
+ useEffect(() => {
+ if (!isApplications && !isStorage && !isEvents) {
+ setResource({ state: 'empty' });
+ return undefined;
+ }
+ let active = true;
+ let controller: AbortController | null = null;
+ const read = async (url: string, signal: AbortSignal): Promise => {
+ const response = await fetch(url, { signal, cache: 'no-store' });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json() as Promise;
+ };
+ const load = async () => {
+ controller?.abort();
+ const current = new AbortController();
+ controller = current;
+ try {
+ if (isApplications) {
+ const value = await read<{ applications?: Array<{ id: string; name: string; status: string; reasons?: Array<{ message?: string }> }> }>('/api/v1/applications', current.signal);
+ const items = (value.applications ?? []).slice(0, 50).map((item) => ({ id: item.id, label: item.name, status: presentStatus(item.status), reason: presentReason(item.reasons?.[0]?.message) }));
+ if (active) setResource({ state: items.length ? 'usable' : 'empty', items });
+ } else if (isStorage) {
+ const [array, disks, pools] = await Promise.all([
+ read('/api/v1/array', current.signal),
+ read('/api/v1/disks?limit=100', current.signal),
+ read('/api/v1/pools?limit=100', current.signal),
+ ]);
+ const storage = { array, disks, pools };
+ if (active) setResource({ state: buildStorageNodes(storage).length ? 'usable' : 'empty', storage });
+ } else {
+ const limit = Math.min(100, Math.max(1, Number(field(widget.data, 'limit') ?? 100)));
+ const value = await read<{ items?: EventItem[] }>('/api/v1/events?limit=' + limit, current.signal);
+ const events = (value.items ?? []).slice(0, limit);
+ if (active) setResource({ state: events.length ? 'usable' : 'empty', events });
+ }
+ } catch (error: unknown) {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ if (active) setResource({ state: 'error', error: error instanceof Error ? error.message : 'bron niet beschikbaar' });
+ }
+ };
+ void load();
+ const timer = window.setInterval(load, interval);
+ return () => { active = false; controller?.abort(); window.clearInterval(timer); };
+ }, [isApplications, isStorage, isEvents, interval, widget.data]);
+ return resource;
+}
+
+export function DashboardRuntimeWidget({ widget, viewport, document, timeRange, onState, onFilter }: { widget: RuntimeWidget; viewport: 'desktop' | 'tablet' | 'mobile' | 'wallboard'; document: RecordValue; timeRange: string; onState: (id: string, state: RuntimeState) => void; onFilter: () => void }) {
+ const sourceType = String(field(widget.data, 'sourceType') ?? '');
+ const semantic = sourceType === 'semantic-metric';
+ const system = useSystemStatus();
+ const inventory = useInventoryResource(widget);
+ const range = useMemo(() => rangeForPreset(timeRange as MetricRangePreset), [timeRange]);
+ const request = useMemo(() => {
+ if (!semantic) return null;
+ const metric = String(field(widget.data, 'metric') ?? '');
+ if (!metric) return null;
+ const scope = resolveDashboardScope(field(widget.data, 'scope') ?? {}, field(document, 'variables') ?? []);
+ return { metric, scope, range, aggregation: String(field(widget.data, 'aggregation') ?? 'avg') };
+ }, [semantic, widget.data, document, range]);
+ const metricState = useMetricQuery(metricClient, request);
+ const historicalSamples = useMemo(() => metricState.status === 'success' ? historicalSamplesFromData(metricState.response?.data) : [], [metricState.status, metricState.response?.data]);
+ const freshness: LiveFreshness = metricState.response?.freshness ?? 'unavailable';
+ const historicalSeries = useMemo(() => { const adapter = new LiveChartAdapter(4000); adapter.append(historicalSamples.map((sample) => ({ ...sample, freshness }))); return adapter.snapshot(); }, [historicalSamples, freshness]);
+ const live = useLiveMetric(liveClient, semantic && timeRange === 'live' ? request : null, historicalSamples);
+ const systemWidget = sourceType === 'inventory' && String(field(field(widget.data, 'scope'), 'entityType') ?? '') === 'server';
+ const systemStatus = aggregateStatus(system);
+ const metricRuntime: RuntimeState = metricState.status === 'error' || (timeRange === 'live' && live.state === 'error') ? 'error' : (timeRange === 'live' ? live.series.length : historicalSeries.length) > 0 ? 'usable' : metricState.status === 'loading' || (timeRange === 'live' && live.state === 'connecting') ? 'loading' : 'empty';
+ const runtime = semantic ? metricRuntime : systemWidget ? (system.state === 'loading' ? 'loading' : system.state === 'ready' ? 'usable' : 'error') : inventory.state;
+ useEffect(() => { onState(widget.id, runtime); }, [onState, runtime, widget.id]);
+
+ const activeLayout = field(widget.layouts, viewport) ?? field(widget.layouts, 'desktop') ?? {};
+ const columns = viewport === 'wallboard' ? wallboardColumns : viewport === 'tablet' ? 8 : viewport === 'mobile' ? 1 : 18;
+ const width = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, Number(field(activeLayout, 'w') ?? 6)));
+ const placement = wallboardPlacement(activeLayout);
+ const layoutStyle = viewport === 'wallboard' ? { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties : { '--widget-span': String(width) } as CSSProperties;
+ let content;
+ if (semantic) {
+ const metric: MetricWidgetProps = { kind: widget.type as MetricWidgetProps['kind'], series: timeRange === 'live' ? live.series : historicalSeries, freshness, expectedStepSeconds: request?.range.stepSeconds ?? 15, availability: timeRange === 'live' ? live.state : metricState.status, error: timeRange === 'live' ? live.error : metricState.error?.actionable ?? null, visualization: widget.visualization, metricName: request?.metric, sourceObservedAt: metricState.response?.sourceObservedAt, receivedAt: metricState.response?.receivedAt, warnings: metricState.response?.warnings, inspector: metricState.response?.inspector };
+ content = ;
+ } else if (systemWidget && system.state === 'ready') {
+ content = {systemStatus.label} {systemStatus.detail} {system.status?.components.length ?? 0} {copy.dashboards.runtime.checkedComponents}
;
+ } else if (inventory.items?.length) {
+ content = ;
+ } else if (inventory.storage) {
+ content = ;
+ } else if (inventory.events?.length) {
+ content = {inventory.events.slice(0, viewport === 'mobile' ? 6 : 12).map((event) => {presentEventSummary(event.type, event.summary)} {presentEventType(event.type)} · {formatDateTime(event.occurredAt)} )} ;
+ } else {
+ content = {runtime === 'error' ? copy.dashboards.runtime.sourceUnavailable : runtime === 'loading' ? copy.dashboards.runtime.telemetryLoading : copy.dashboards.runtime.noCurrentData} {runtime === 'error' ? copy.dashboards.runtime.unavailableDetail : copy.dashboards.runtime.retryDetail}
;
+ }
+ return {sourceType === 'semantic-metric' ? copy.dashboards.runtime.semanticMetric : sourceType === 'events' ? copy.dashboards.runtime.events : copy.dashboards.runtime.inventory}
{widget.description && {widget.description}
}{content} ;
+}
diff --git a/apps/web/src/DashboardTransfer.tsx b/apps/web/src/DashboardTransfer.tsx
new file mode 100644
index 0000000..7c87c9d
--- /dev/null
+++ b/apps/web/src/DashboardTransfer.tsx
@@ -0,0 +1,50 @@
+import { copy } from './copy';
+import type { RecordValue } from './DashboardEditor';
+
+export type TransferResult = { document: RecordValue; errors: string[] };
+
+const transferCopy = copy.editor.transfer;
+
+function hasUnsafeText(value: unknown): boolean {
+ if (typeof value === 'string') return /<\s*script|<\s*iframe|javascript:/i.test(value);
+ if (Array.isArray(value)) return value.some(hasUnsafeText);
+ if (value && typeof value === 'object') return Object.values(value as RecordValue).some(hasUnsafeText);
+ return false;
+}
+function depth(value: unknown, current = 0): number {
+ if (!value || typeof value !== 'object') return current;
+ const children = Array.isArray(value) ? value : Object.values(value as RecordValue);
+ return children.reduce((max, child) => Math.max(max, depth(child, current + 1)), current);
+}
+export function validatePortableDashboard(value: unknown): TransferResult {
+ const errors: string[] = [];
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { document: {}, errors: [transferCopy.errors.notAnObject] };
+ const document = value as RecordValue;
+ const schemaVersion = document.schemaVersion;
+ if (schemaVersion !== 1 && schemaVersion !== 2) errors.push(transferCopy.errors.schemaVersion);
+ const widgets = Array.isArray(document.widgets) ? document.widgets : [];
+ const variables = Array.isArray(document.variables) ? document.variables : [];
+ if (!Array.isArray(document.widgets)) errors.push(transferCopy.errors.widgetsArray);
+ if (!Array.isArray(document.variables)) errors.push(transferCopy.errors.variablesArray);
+ if (widgets.length > 200) errors.push(transferCopy.errors.tooManyWidgets);
+ if (variables.length > 30) errors.push(transferCopy.errors.tooManyVariables);
+ if (depth(value) > 12) errors.push(transferCopy.errors.tooDeep);
+ if (hasUnsafeText(value)) errors.push(transferCopy.errors.unsafeContent);
+ return { document, errors };
+}
+export function parsePortableDashboard(text: string): TransferResult {
+ if (text.length > 2 * 1024 * 1024) return { document: {}, errors: [transferCopy.errors.tooLarge] };
+ try { return validatePortableDashboard(JSON.parse(text)); } catch { return { document: {}, errors: [transferCopy.errors.invalidJson] }; }
+}
+
+type Props = { document: RecordValue; onImport: (document: RecordValue) => void };
+const templateLabels: Record = { empty: transferCopy.templateEmpty, operations: transferCopy.templateOperations };
+const templates: Record = {
+ empty: { schemaVersion: 2, id: '00000000-0000-0000-0000-000000000000', slug: 'nieuw-dashboard', name: transferCopy.templateEmptyName, scope: 'personal', variables: [], widgets: [], settings: { defaultTimeRange: '1h' } },
+ operations: { schemaVersion: 2, id: '00000000-0000-0000-0000-000000000000', slug: 'operations-template', name: transferCopy.templateOperations, scope: 'personal', variables: [{ name: 'timeRange', type: 'time-range', label: copy.editor.variables.periodLabel, default: '1h', options: ['live', '15m', '1h', '6h', '24h', '7d'] }], widgets: [], settings: { defaultTimeRange: '1h' } }
+};
+export function DashboardTransfer({ document, onImport }: Props) {
+ const exportDocument = () => { const blob = new Blob([JSON.stringify(document, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = window.document.createElement('a'); link.href = url; link.download = 'pulse-dashboard.json'; link.click(); URL.revokeObjectURL(url); };
+ const importDocument = async (file: File) => { const result = parsePortableDashboard(await file.text()); if (result.errors.length) { window.alert(result.errors.join(' ')); return; } onImport(result.document); };
+ return ;
+}
diff --git a/apps/web/src/DashboardVariablesEditor.tsx b/apps/web/src/DashboardVariablesEditor.tsx
new file mode 100644
index 0000000..557d94a
--- /dev/null
+++ b/apps/web/src/DashboardVariablesEditor.tsx
@@ -0,0 +1,41 @@
+import { copy } from './copy';
+
+export type DashboardVariable = { name: string; type: string; label: string; default: string; options?: string[] };
+
+export type VariableErrors = Record;
+const variableTypes = ['server', 'entity', 'container', 'application', 'disk', 'pool', 'service', 'time-range', 'enum'];
+const text = copy.editor.variables;
+
+export function validateVariables(variables: DashboardVariable[]): VariableErrors {
+ const errors: VariableErrors = {};
+ if (variables.length > 30) errors.form = text.errors.tooMany;
+ const names = new Set();
+ variables.forEach((variable, index) => {
+ const prefix = 'variables.' + index;
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(variable.name)) errors[prefix + '.name'] = text.errors.nameFormat;
+ if (names.has(variable.name)) errors[prefix + '.name'] = text.errors.nameUnique;
+ names.add(variable.name);
+ if (!variableTypes.includes(variable.type)) errors[prefix + '.type'] = text.errors.typeUnsupported;
+ if (!variable.label.trim()) errors[prefix + '.label'] = text.errors.labelRequired;
+ if (variable.options && variable.options.length > 1000) errors[prefix + '.options'] = text.errors.tooManyOptions;
+ if ((variable.type === 'entity' || variable.type === 'server') && variable.options?.length && !variable.options.includes(variable.default)) errors[prefix + '.default'] = text.errors.defaultNotAllowed;
+ });
+ return errors;
+}
+
+type Props = { variables: DashboardVariable[]; onChange: (variables: DashboardVariable[]) => void };
+
+export function DashboardVariablesEditor({ variables, onChange }: Props) {
+ const errors = validateVariables(variables);
+ const error = (key: string) => errors[key] ? {errors[key]}
: null;
+ const update = (index: number, change: (variable: DashboardVariable) => DashboardVariable) => onChange(variables.map((variable, current) => current === index ? change(variable) : variable));
+ const add = () => onChange([...variables, { name: 'variable' + (variables.length + 1), type: 'time-range', label: text.newLabel, default: '1h', options: ['live', '15m', '1h', '6h', '24h', '7d'] }]);
+ const remove = (index: number) => onChange(variables.filter((_, current) => current !== index));
+ return
+ {text.kicker}
{text.title} {text.add}
+ {text.intro}
+ {errors.form && {errors.form}
}
+ {variables.length === 0 && {text.empty}
}
+ {variables.map((variable, index) => { const prefix = 'variables.' + index; const options = variable.options ?? []; return
{text.name} update(index, (item) => ({ ...item, name: event.target.value }))} /> {text.label} update(index, (item) => ({ ...item, label: event.target.value }))} />
{error(prefix + '.name')}{error(prefix + '.label')}{text.type} update(index, (item) => ({ ...item, type: event.target.value }))}>{variableTypes.map((type) => {type} )} {text.default}{(variable.type === 'entity' || variable.type === 'server') && options.length > 0 ? update(index, (item) => ({ ...item, default: event.target.value }))}>{options.map((option) => {option} )} : update(index, (item) => ({ ...item, default: event.target.value }))} />}
{error(prefix + '.type')}{error(prefix + '.default')}{text.options} update(index, (item) => ({ ...item, options: event.target.value.split(',').map((option) => option.trim()).filter(Boolean) }))} /> {error(prefix + '.options')} remove(index)}>{text.remove} ; })}
+ ;
+}
diff --git a/apps/web/src/DiskPage.tsx b/apps/web/src/DiskPage.tsx
new file mode 100644
index 0000000..e4a8f4f
--- /dev/null
+++ b/apps/web/src/DiskPage.tsx
@@ -0,0 +1,15 @@
+import { formatDateTime } from './locale';
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { presentArrayRole, presentReason, presentStatus } from './presentation';
+
+type SmartAttribute = { id:string; name:string; rawValue:number; normalizedValue?:number; unit?:string; status:string; critical:boolean; reason?:string }; type Smart = { state:string; overall:string; observedAt?:string; attributes:SmartAttribute[]; selfTest?:{supported:boolean;result:string;completedAt?:string;ageSeconds?:number}; reasons?:string[] }; type Sample = { observedAt:string; readBytesPerSecond?:number; writeBytesPerSecond?:number; readIops?:number; writeIops?:number; readLatencyMs?:number; writeLatencyMs?:number }; type Disk = { id:string; name:string; role:string; state:string; model?:string; serialDisplay?:string; filesystem?:string; sizeBytes:number; usedBytes:number; freeBytes:number; utilizationPercent:number; capacitySeverity?:string; thermalSeverity?:string; inodes?:{total:number;used:number;free:number;utilizationPercent:number}; smart?:Smart; performance?:{state:string;current:Sample;history:Sample[]}; temperature?:{state:string;celsius?:number;status:string;observedAt?:string}; spin?:{state:string} };
+type Snapshot = { source:{id:string;state:string;freshness:string;observedAt?:string;reason?:string}; disks:Disk[]; total:number; missingHistory?:Array<{diskId:string;name:string;role:string;observedAt:string;reason:string}> };
+type Detail = {source:Snapshot['source'];disk:Disk};
+function bytes(value:number):string{if(!Number.isFinite(value)||value<0)return'—';const units=['B','KB','MB','GB','TB'];let scaled=value;let index=0;while(scaled>=1024&&index{ready?'✓':'?'} {label}}
+function severityLabel(value?:string):string{return value==='normal'?'Normaal':value==='attention'?'Aandacht':value==='critical'?'Kritiek':'Onbekend'}
+function DiskCard({disk}:{disk:Disk}){return {presentArrayRole(disk.role)}
{disk.filesystem||copy.disks.noFilesystem} · {bytes(disk.sizeBytes)} · {disk.utilizationPercent.toLocaleString('nl-BE',{maximumFractionDigits:1})}% {copy.disks.used}
Capaciteit: {severityLabel(disk.capacitySeverity)} · Temperatuur: {severityLabel(disk.thermalSeverity)}
{disk.model||copy.disks.noModel} · {disk.serialDisplay||copy.disks.noSerial}
}
+export function DiskPage(){const[state,setState]=useState<'loading'|'ready'|'error'>('loading');const[snapshot,setSnapshot]=useState(null);useEffect(()=>{const controller=new AbortController();fetch('/api/v1/disks?limit=100',{signal:controller.signal}).then(response=>{if(!response.ok)throw new Error('disks');return response.json() as Promise}).then(data=>{setSnapshot(data);setState('ready')}).catch(error=>{if(error instanceof DOMException&&error.name==='AbortError')return;setState('error')});return()=>controller.abort()},[]);if(state==='loading')return ;if(state==='error'||!snapshot)return × {copy.disks.errorTitle} {copy.disks.errorDetail}
;const fresh=snapshot.source?.freshness==='fresh'&&snapshot.source?.state!=='unknown';return <>{copy.disks.eyebrow}
{copy.disks.title} {copy.disks.intro}
{copy.disks.source}
{snapshot.source?.id||copy.disks.unknown} {snapshot.source?.reason?presentReason(snapshot.source.reason):(fresh?copy.disks.fresh:copy.disks.stale)}
{copy.disks.observed}: {date(snapshot.source?.observedAt)} · {snapshot.total} {copy.disks.rows}
{snapshot.disks.length?snapshot.disks.map(disk=>):{copy.disks.empty}
} {copy.disks.history}
{copy.disks.missingTitle} {snapshot.missingHistory?.length?{snapshot.missingHistory.map(item=>{item.name} {item.role} · {presentReason(item.reason)} · {date(item.observedAt)} )} :{copy.disks.noMissingHistory}
} >}
+function TelemetrySection({performance,temperature,spin}:{performance?:Disk['performance'];temperature?:Disk['temperature'];spin?:Disk['spin']}){return {copy.disks.telemetry}
{temperature?.celsius == null ? '—' : temperature.celsius.toLocaleString('nl-BE',{maximumFractionDigits:1})+' °C'} {copy.disks.spin}: {spin?.state==='unsupported'?copy.disks.unsupported:presentStatus(spin?.state)} · {copy.disks.performance}: {performance?.state==='unsupported'?copy.disks.unsupported:presentStatus(performance?.state)}
{performance?.state==='available'&&{copy.disks.read}: {Math.round(performance.current.readBytesPerSecond||0).toLocaleString('nl-BE')} B/s · {copy.disks.write}: {Math.round(performance.current.writeBytesPerSecond||0).toLocaleString('nl-BE')} B/s · {performance.history.length} {copy.disks.historyPoints}
} }function SmartSection({smart}:{smart?:Smart}){if(!smart)return
SMART {copy.disks.smartUnavailable}
;return SMART
{presentStatus(smart.overall)} {smart.reasons?.length?{smart.reasons.map(reason=>{reason} )} :{copy.disks.smartNoReasons}
}{smart.selfTest&&{copy.disks.selfTest}: {smart.selfTest.result} · {smart.selfTest.ageSeconds == null ? '—' : Math.floor(smart.selfTest.ageSeconds/3600)+' u'}
}{smart.attributes.length>0&&{copy.disks.attribute} {copy.disks.value} {copy.disks.state} {smart.attributes.map(attribute=>{attribute.name}{attribute.id} {attribute.rawValue}{attribute.unit?' '+attribute.unit:''} )}
} }export function DiskDetailPage({id}:{id:string}){const[state,setState]=useState<'loading'|'ready'|'error'>('loading');const[detail,setDetail]=useState(null);useEffect(()=>{const controller=new AbortController();fetch('/api/v1/disks/'+encodeURIComponent(id),{signal:controller.signal}).then(response=>{if(!response.ok)throw new Error('disk-detail');return response.json() as Promise}).then(data=>{setDetail(data);setState('ready')}).catch(error=>{if(error instanceof DOMException&&error.name==='AbortError')return;setState('error')});return()=>controller.abort()},[id]);if(state==='loading')return ;if(state==='error'||!detail)return ;const disk=detail.disk;return <>{copy.disks.source}
{detail.source?.id||copy.disks.unknown} {detail.source?.reason||copy.disks.readOnly}
{copy.disks.observed}: {date(detail.source?.observedAt)} · {detail.source?.freshness=== 'fresh'?copy.disks.fresh:copy.disks.stale}
{copy.disks.capacity}
{disk.utilizationPercent.toLocaleString('nl-BE',{maximumFractionDigits:1})}% {bytes(disk.usedBytes)} {copy.disks.usedOf} {bytes(disk.sizeBytes)} · {bytes(disk.freeBytes)} {copy.disks.free}
{copy.disks.identity}
{presentArrayRole(disk.role)} {disk.model||copy.disks.noModel} · {disk.serialDisplay||copy.disks.noSerial}
{disk.inodes&&{copy.disks.inodes}
{disk.inodes.utilizationPercent.toLocaleString('nl-BE',{maximumFractionDigits:1})}% {disk.inodes.used} / {disk.inodes.total}
}{copy.disks.technical} ID {disk.id} {copy.disks.filesystem} {disk.filesystem||copy.disks.noFilesystem} {copy.disks.capacity} {bytes(disk.sizeBytes)} {copy.disks.free} {bytes(disk.freeBytes)} {copy.disks.serial} {disk.serialDisplay||copy.disks.noSerial} >}
diff --git a/apps/web/src/EventsPage.tsx b/apps/web/src/EventsPage.tsx
new file mode 100644
index 0000000..01d1e47
--- /dev/null
+++ b/apps/web/src/EventsPage.tsx
@@ -0,0 +1,110 @@
+import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
+
+import { copy } from './copy';
+import { formatDateTime, hasReceivedTimestamp } from './locale';
+import { queryValue, replaceListQuery } from './listQuery';
+import { presentEventSummary, presentEventType, presentStatus } from './presentation';
+
+type EventItem = { id: string; type: string; severity: string; entityId?: string; sourceId?: string; occurredAt: string; receivedAt: string; summary: string };
+type EventResponse = { items?: EventItem[] };
+type LoadState = 'loading' | 'ready' | 'error';
+
+const pageSize = 20;
+const severities = ['critical', 'warning', 'attention', 'info', 'unknown'] as const;
+
+function severityTone(value: string): 'ready' | 'attention' | 'critical' | 'unknown' {
+ if (value === 'critical') return 'critical';
+ if (value === 'warning' || value === 'attention' || value === 'error') return 'attention';
+ if (value === 'info') return 'ready';
+ return 'unknown';
+}
+
+function initialPage(): number {
+ const value = Number.parseInt(queryValue('page'), 10);
+ return Number.isFinite(value) && value > 1 ? value - 1 : 0;
+}
+
+function searchableText(item: EventItem): string {
+ return [item.summary, presentEventSummary(item.type, item.summary), item.type, presentEventType(item.type), item.entityId, item.sourceId]
+ .filter(Boolean)
+ .join(' ')
+ .toLocaleLowerCase('nl-BE');
+}
+
+export function EventsPage() {
+ const [state, setState] = useState('loading');
+ const [items, setItems] = useState([]);
+ const [search, setSearch] = useState(() => queryValue('q'));
+ const deferredSearch = useDeferredValue(search);
+ const [severity, setSeverity] = useState(() => queryValue('severity', severities));
+ const [type, setType] = useState(() => queryValue('type'));
+ const [entity, setEntity] = useState(() => queryValue('entity'));
+ const [page, setPage] = useState(initialPage);
+ const [revision, setRevision] = useState(0);
+ const pageStatus = useRef(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setState('loading');
+ fetch('/api/v1/events?limit=100', { signal: controller.signal })
+ .then((response) => { if (!response.ok) throw new Error('events'); return response.json() as Promise; })
+ .then((value) => { setItems((value.items ?? []).slice(0, 100)); setState('ready'); })
+ .catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
+ return () => controller.abort();
+ }, [revision]);
+
+ const types = useMemo(() => [...new Set(items.map((item) => item.type))].sort((a, b) => presentEventType(a).localeCompare(presentEventType(b), 'nl-BE')), [items]);
+ const entities = useMemo(() => [...new Set(items.map((item) => item.entityId).filter((value): value is string => Boolean(value)))].sort((a, b) => a.localeCompare(b)), [items]);
+ const normalizedSearch = deferredSearch.trim().toLocaleLowerCase('nl-BE');
+ const shown = useMemo(() => items.filter((item) => (!severity || item.severity === severity)
+ && (!type || item.type === type)
+ && (!entity || item.entityId === entity)
+ && (!normalizedSearch || searchableText(item).includes(normalizedSearch))), [entity, items, normalizedSearch, severity, type]);
+ const criticalCount = items.filter((item) => item.severity === 'critical').length;
+ const pageCount = Math.max(1, Math.ceil(shown.length / pageSize));
+ const currentPage = Math.min(page, pageCount - 1);
+ const pageItems = shown.slice(currentPage * pageSize, (currentPage + 1) * pageSize);
+ const firstResult = shown.length === 0 ? 0 : currentPage * pageSize + 1;
+ const lastResult = Math.min((currentPage + 1) * pageSize, shown.length);
+
+ useEffect(() => {
+ if (page !== currentPage) setPage(currentPage);
+ }, [currentPage, page]);
+ useEffect(() => {
+ replaceListQuery({ q: search.trim(), severity, type, entity, page: currentPage > 0 ? String(currentPage + 1) : '' });
+ }, [currentPage, entity, search, severity, type]);
+
+ const resetPage = () => setPage(0);
+ const movePage = (next: number) => {
+ setPage(next);
+ requestAnimationFrame(() => pageStatus.current?.focus());
+ };
+ const clearFilters = () => { setSearch(''); setSeverity(''); setType(''); setEntity(''); setPage(0); };
+
+ return <>
+ {copy.events.eyebrow}
{copy.events.title} {copy.events.intro}
+
+ {copy.events.loaded}
{items.length} {copy.events.loadedDetail}
+ { setSeverity('critical'); setPage(0); }}>{copy.events.criticalSummary} {criticalCount} {criticalCount ? copy.events.showCritical : copy.events.noCritical}
+ {copy.events.results}
{shown.length} {firstResult}–{lastResult} {copy.events.of} {shown.length}
+
+
+ {copy.events.timeline}
{copy.events.latest} setRevision((value) => value + 1)}>{copy.events.refresh}
+
+ {state === 'loading' ? {copy.events.loading}
: state === 'error' ? {copy.events.errorTitle} {copy.events.errorDetail}
: shown.length === 0 ? {copy.events.empty}
{copy.events.clearEmpty} : {pageItems.map((item) =>
+ {item.severity === 'critical' ? '!' : '•'} {presentStatus(item.severity)}
+
{presentEventType(item.type)} {hasReceivedTimestamp(item.occurredAt) ? {formatDateTime(item.occurredAt)} : {formatDateTime(item.occurredAt)} }{presentEventSummary(item.type, item.summary)}
{item.entityId ? `${copy.events.entity}: ${item.entityId}` : copy.events.noEntity}
+
{copy.events.technical} ID {item.id} {copy.events.type} {item.type} {copy.events.source} {item.sourceId || copy.events.noSource} {copy.events.entity} {item.entityId || copy.events.noEntity} {copy.events.received} {formatDateTime(item.receivedAt)}
+
+ )} }
+ {state === 'ready' && shown.length > 0 && movePage(currentPage - 1)}>{copy.events.previous} {copy.events.page} {currentPage + 1} {copy.events.of} {pageCount} · {firstResult}–{lastResult} = pageCount} onClick={() => movePage(currentPage + 1)}>{copy.events.next} }
+ {items.length} {copy.events.rows}
+
+ >;
+}
diff --git a/apps/web/src/HostPage.tsx b/apps/web/src/HostPage.tsx
new file mode 100644
index 0000000..a1e03dc
--- /dev/null
+++ b/apps/web/src/HostPage.tsx
@@ -0,0 +1,90 @@
+import { formatDateTime } from './locale';
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { presentReason, presentStatus } from './presentation';
+import { SourceStatusDetails } from './SourceStatusDetails';
+
+type HostSnapshot = {
+ source: { id: string; type: string; observedAt?: string; freshness: string; state: string; reason?: string };
+ identity: { name: string; version?: string; kernel?: string; architecture?: string };
+ uptimeSeconds: number;
+ bootTime?: string;
+ cpu: { totalPercent?: number; perCore?: number[]; iowaitPercent?: number };
+ load: { one: number; five: number; fifteen: number };
+ memory: { totalBytes: number; availableBytes: number; usedBytes: number; utilizationPercent: number; swapTotalBytes: number; swapUsedBytes: number; swapUtilizationPercent: number };
+ filesystems: Array<{ mount: string; filesystem?: string; capacityBytes: number; usedBytes: number; utilizationPercent: number; inodes?: { total: number; used: number } }>;
+ network: Array<{ name: string; state?: string; rxBytes: number; txBytes: number; rxErrors: number; txErrors: number; rxDrops: number; txDrops: number }>;
+ time: { synchronized: boolean; offsetSeconds: number; stratum?: number; state: string };
+ hardware?: { capabilities: Array<{ id: string; version: string; state: string; reason?: string }>; temperatures: Array<{ id: string; name: string; celsius: number }>; fans: Array<{ id: string; name: string; rpm: number }>; gpus: Array<{ id: string; name: string; vendor?: string; utilizationPercent?: number; memoryUsedBytes: number; memoryTotalBytes: number; memoryUtilizationPercent?: number; temperatureCelsius?: number }>; status: { state: string; reasons?: Array<{ code: string; message: string }> } };
+ status: { state: string; reasons?: Array<{ code: string; message: string }> };
+ observedAt?: string;
+ receivedAt: string;
+ warnings?: string[];
+};
+
+function getStatusLabel(state: string): string {
+ return state === 'healthy' ? 'Gezond' : state === 'degraded' ? 'Aandacht' : 'Onbekend';
+}
+function Badge({ state }: { state: string }) {
+ const ready = state === 'healthy';
+ return {ready ? '✓' : '?'} {getStatusLabel(state)} ;
+}
+function bytes(value: number): string {
+ if (!Number.isFinite(value) || value < 0) return '—';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let scaled = value;
+ let index = 0;
+ while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; }
+ return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index];
+}
+function duration(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds < 0) return '—';
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ return days > 0 ? days + ' d ' + hours + ' u' : hours + ' u ' + minutes + ' min';
+}
+function when(value?: string): string { return formatDateTime(value); }
+
+export function HostPage() {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [snapshot, setSnapshot] = useState(null);
+ useEffect(() => {
+ const controller = new AbortController();
+ fetch('/api/v1/host', { signal: controller.signal }).then((response) => {
+ if (!response.ok) throw new Error('host');
+ return response.json() as Promise;
+ }).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState('error');
+ });
+ return () => controller.abort();
+ }, []);
+ if (state === 'loading') return ;
+ if (state === 'error' || !snapshot) return × {copy.host.errorTitle} {copy.host.errorDetail}
window.location.reload()}>{copy.host.retry} ;
+ const status = snapshot.status?.state ?? 'unknown';
+ const observed = snapshot.observedAt ?? snapshot.source?.observedAt;
+ return <>
+ {copy.host.eyebrow}
{copy.host.title} {copy.host.intro}
+
+ {copy.host.identity}
{snapshot.identity?.name || copy.host.unknown} {[snapshot.identity?.version, snapshot.identity?.kernel, snapshot.identity?.architecture].filter(Boolean).join(' · ') || copy.host.noIdentityDetails}
+
+ {snapshot.status?.reasons?.map((reason) => {presentReason(reason.code)}
)}
+
+
+ {copy.host.uptime}
{duration(snapshot.uptimeSeconds)} {snapshot.bootTime ? copy.host.boot + ' ' + when(snapshot.bootTime) : copy.host.noBoot}
+ {copy.host.cpu}
{snapshot.cpu?.totalPercent == null ? '—' : snapshot.cpu.totalPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'} {snapshot.cpu?.perCore?.length ?? 0} {copy.host.cores} · iowait {snapshot.cpu?.iowaitPercent == null ? '—' : snapshot.cpu.iowaitPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}
+ {copy.host.load}
{snapshot.load?.one?.toLocaleString('nl-BE', { maximumFractionDigits: 2 }) ?? '—'} 1 / 5 / 15 min: {snapshot.load?.one ?? '—'} · {snapshot.load?.five ?? '—'} · {snapshot.load?.fifteen ?? '—'}
+ {copy.host.memory}
{snapshot.memory?.utilizationPercent?.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) ?? '—'}% {bytes(snapshot.memory?.usedBytes ?? 0)} / {bytes(snapshot.memory?.totalBytes ?? 0)} · {copy.host.available}: {bytes(snapshot.memory?.availableBytes ?? 0)}
+ {copy.host.time}
{snapshot.time?.synchronized ? copy.host.synchronized : copy.host.notSynchronized} Offset {snapshot.time?.offsetSeconds?.toLocaleString('nl-BE', { maximumFractionDigits: 3 }) ?? '—'} s · stratum {snapshot.time?.stratum ?? '—'}
+
+
+ {copy.host.filesystems}
{snapshot.filesystems?.length ?? 0} {snapshot.filesystems?.length ? {copy.host.mount} {copy.host.used} {copy.host.inodes} {snapshot.filesystems.map((item) => {item.mount} {item.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%{bytes(item.usedBytes)} / {bytes(item.capacityBytes)} {item.inodes ? ((item.inodes.used / item.inodes.total) * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'} )}
: {copy.host.noData}
}
+ {copy.host.network}
{snapshot.network?.length ?? 0} {snapshot.network?.length ? {copy.host.interface} RX / TX {copy.host.errors} {snapshot.network.map((item) => {item.name}{item.state || copy.host.unknown} {bytes(item.rxBytes)} / {bytes(item.txBytes)} {item.rxErrors + item.txErrors + item.rxDrops + item.txDrops} )}
: {copy.host.noData}
}
+
+
+ {copy.host.hardware}
{snapshot.hardware?.temperatures?.length ?? 0} · {snapshot.hardware?.fans?.length ?? 0} {copy.host.sensors} {snapshot.hardware?.status?.reasons?.map((reason) => {presentReason(reason.code)}
)}{snapshot.hardware?.capabilities?.length ? {snapshot.hardware.capabilities.map((capability) => {capability.id} {presentStatus(capability.state)} · {presentReason(capability.reason)} )} : null}{snapshot.hardware?.temperatures?.length ? {copy.host.sensor} {copy.host.temperature} {snapshot.hardware.temperatures.map((item) => {item.name}{item.id} {item.celsius.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} °C )}
: {copy.host.noSensors}
}
+ {copy.host.gpu}
{snapshot.hardware?.gpus?.length ?? 0} {snapshot.hardware?.gpus?.length ? {copy.host.device} {copy.host.gpuUsage} {copy.host.gpuMemory} {snapshot.hardware.gpus.map((item) => {item.name}{item.vendor || copy.host.unknown} {item.utilizationPercent == null ? '—' : item.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'} {item.memoryTotalBytes ? item.memoryUtilizationPercent?.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'} )}
: {copy.host.noGpu}
}
+ {snapshot.warnings?.length ? {copy.host.warnings}
{snapshot.warnings.map((warning) => {presentReason(warning)}
)} : null}
+ >;
+}
diff --git a/apps/web/src/IncidentPage.tsx b/apps/web/src/IncidentPage.tsx
new file mode 100644
index 0000000..84e66e7
--- /dev/null
+++ b/apps/web/src/IncidentPage.tsx
@@ -0,0 +1,46 @@
+import { useEffect, useMemo, useState } from 'react';
+import { copy } from './copy';
+import { formatDateTime } from './locale';
+
+type Association = { alertId: string; rationale: string; confidence: number; correlationMethod: string; manual: boolean; addedBy?: string; createdAt: string };
+type Note = { id: string; incidentId: string; author: string; body: string; createdAt: string };
+type Incident = { id: string; correlationKey: string; title: string; summary: string; severity: string; status: string; startedAt: string; resolvedAt?: string; ownerUserId?: string; correlationMethod: string; confidence: number; revision: number; updatedAt: string; alerts?: Association[]; notes?: Note[] };
+
+function navigate(path: string) { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')); }
+function formatTime(value: string) { return formatDateTime(value); }
+function severityLabel(value: string) { return value === 'critical' ? 'Kritiek' : value === 'degraded' ? 'Aandacht' : 'Opmerking'; }
+function statusLabel(value: string) { return value === 'resolved' ? 'Opgelost' : value === 'acknowledged' ? 'Erkend' : 'Open'; }
+
+export function IncidentPage({ id }: { id?: string }) {
+ const [state, setState] = useState<'loading' | 'ready' | 'empty' | 'error' | 'unauthorized'>('loading');
+ const [items, setItems] = useState([]);
+ const [incident, setIncident] = useState(null);
+ const [note, setNote] = useState('');
+ const [owner, setOwner] = useState('');
+ const [message, setMessage] = useState('');
+ const [reload, setReload] = useState(0);
+ useEffect(() => {
+ const controller = new AbortController();
+ setState('loading');
+ const url = id ? '/api/v1/incidents/' + encodeURIComponent(id) : '/api/v1/incidents?limit=100&status=open';
+ fetch(url, { signal: controller.signal }).then((response) => { if (response.status === 401) throw new Error('unauthorized'); if (!response.ok) throw new Error('error'); return response.json() as Promise<{ incident?: Incident; items?: Incident[] }>; }).then((data) => {
+ if (id) { const next = data.incident ?? null; setIncident(next); setOwner(next?.ownerUserId ?? ''); setState(next ? 'ready' : 'empty'); } else { const next = data.items ?? []; setItems(next); setState(next.length ? 'ready' : 'empty'); }
+ }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState(error instanceof Error && error.message === 'unauthorized' ? 'unauthorized' : 'error'); });
+ return () => controller.abort();
+ }, [id, reload]);
+ const timeline = useMemo(() => {
+ if (!incident) return [] as Array<{ id: string; label: string; detail: string; at: string }>;
+ const entries = (incident.alerts ?? []).map((alert) => ({ id: 'alert-' + alert.alertId, label: alert.manual ? 'Handmatig gekoppeld alert' : 'Gecorreleerd alert', detail: alert.rationale + ' · confidence ' + Math.round(alert.confidence * 100) + '%', at: alert.createdAt }));
+ entries.push(...(incident.notes ?? []).map((item) => ({ id: item.id, label: 'Notitie van ' + item.author, detail: item.body, at: item.createdAt })));
+ return entries.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
+ }, [incident]);
+ const submitNote = async () => { if (!id || !note.trim()) return; setMessage('Notitie wordt opgeslagen…'); const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id) + '/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: note }) }); if (!response.ok) { setMessage('De notitie kon niet worden opgeslagen.'); return; } setNote(''); setMessage('Notitie opgeslagen.'); setReload((value) => value + 1); };
+ const saveOwner = async () => { if (!id || !incident) return; setMessage('Eigenaar wordt opgeslagen…'); const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ownerUserId: owner.trim(), revision: incident.revision }) }); if (!response.ok) { setMessage('De eigenaar kon niet worden opgeslagen.'); return; } setMessage('Eigenaar opgeslagen.'); setReload((value) => value + 1); };
+ if (state === 'loading') return ;
+ if (state === 'unauthorized') return Geen toegang tot incidenten Je hebt geen rechten om incidentgegevens te bekijken.
;
+ if (state === 'error') return Incidenten niet beschikbaar De incidentgegevens konden niet veilig worden geladen.
setReload((value) => value + 1)}>Opnieuw laden ;
+ if (!id && state === 'empty') return <>✓ Geen open incidenten Er zijn momenteel geen open incidenten geregistreerd.
>;
+ if (!id) return <>{copy.incidents.openIncidents}
{copy.incidents.listTitle} ? {items.length} incidenten{items.map((item) => navigate('/incidents/' + encodeURIComponent(item.id))}>{item.title} {severityLabel(item.severity)} · {item.summary} {statusLabel(item.status)} {copy.incidents.confidence}: {Math.round(item.confidence * 100)}% )} >;
+ if (!incident) return null;
+ return <> navigate('/incidents')}>← Terug naar incidenten Ernst {severityLabel(incident.severity)} Status {statusLabel(incident.status)} {copy.incidents.started} {formatTime(incident.startedAt)} {copy.incidents.confidence} {Math.round(incident.confidence * 100)}% {copy.incidents.statusAndConfidence}
{statusLabel(incident.status)} ? {severityLabel(incident.severity)}
{copy.incidents.started} {formatTime(incident.startedAt)}
{copy.incidents.correlationMethod} {incident.correlationMethod}
{copy.incidents.confidence} {Math.round(incident.confidence * 100)}%
{copy.incidents.revision} {incident.revision} Correlatie is een onderbouwde aanwijzing, geen bewezen causaliteit. Betrouwbaarheid beschrijft de correlatieregel, niet de zekerheid van de oorzaak.
{copy.incidents.ownership}
{copy.incidents.followUp} {copy.incidents.ownerLabel} setOwner(event.target.value)} placeholder={copy.incidents.ownerPlaceholder} aria-label={copy.incidents.ownerLabel} /> {copy.incidents.saveOwner} {copy.incidents.ownerNote}
{copy.incidents.timeline}
{copy.incidents.signalsAndNotes} {timeline.length} {copy.incidents.timelineItems} {timeline.length === 0 ? {copy.incidents.noTimeline}
: {timeline.map((entry) => {formatTime(entry.at)} {entry.label} {entry.detail}
)} }{copy.incidents.notes}
{copy.incidents.operatorContext} {copy.incidents.newNote} {copy.incidents.noteHelp}
{copy.incidents.addNote} {message && {message}
} >;
+}
diff --git a/apps/web/src/InventoryPage.tsx b/apps/web/src/InventoryPage.tsx
new file mode 100644
index 0000000..3540120
--- /dev/null
+++ b/apps/web/src/InventoryPage.tsx
@@ -0,0 +1,140 @@
+import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
+
+import { copy } from './copy';
+import { formatDateTime } from './locale';
+import { queryValue, replaceListQuery } from './listQuery';
+import { presentEntityType, presentInventoryField, presentRelationType, presentStatus } from './presentation';
+
+type Entity = {
+ id: string; entityType: string; canonicalName: string; displayName: string; status: string;
+ firstSeenAt?: string; lastSeenAt?: string; factCount: number; overrideCount: number;
+ relationCount: number; sourceCount: number; staleFactCount: number;
+};
+type Fact = { fieldName: string; sourceId: string; sourceName: string; value: unknown; observedAt: string; confidence: number; validUntil?: string; stale: boolean };
+type Override = { fieldName: string; value: unknown; updatedAt: string };
+type Effective = { fieldName: string; value: unknown; origin: 'override' | 'discovered'; sourceName?: string; observedAt?: string; confidence?: number; stale: boolean; overriddenAt?: string };
+type Alias = { sourceName: string; externalType: string; externalId: string };
+type Relation = { id: string; direction: 'incoming' | 'outgoing'; relationType: string; peerId: string; peerType: string; peerName: string; peerStatus: string; peerTombstonedAt?: string; sourceName: string; confidence: number; confirmed: boolean; tombstonedAt?: string };
+type Detail = { entity: Entity; aliases: Alias[]; facts: Fact[]; overrides: Override[]; effectiveValues: Effective[]; relations: Relation[] };
+type Page = { items: Entity[]; nextCursor: string; hasMore: boolean };
+type LoadState = 'loading' | 'ready' | 'error';
+
+function valueText(value: unknown): string {
+ if (value == null) return copy.inventory.missingValue;
+ if (typeof value === 'string') return value;
+ if (typeof value === 'boolean') return value ? copy.inventory.yes : copy.inventory.no;
+ if (typeof value === 'number') return value.toLocaleString('nl-BE');
+ return JSON.stringify(value);
+}
+
+function effectiveValueText(fieldName: string, value: unknown): string {
+ return /(?:status|state|health)$/i.test(fieldName) && typeof value === 'string' ? presentStatus(value) : valueText(value);
+}
+
+function statusTone(status: string, stale = false): string {
+ if (stale || !status || status.toLowerCase() === 'unknown') return 'unknown';
+ if (['healthy', 'operational', 'running', 'up', 'online', 'ready', 'gereed'].includes(status.toLowerCase())) return 'ready';
+ return 'attention';
+}
+
+function Badge({ label, stale = false }: { label: string; stale?: boolean }) {
+ const tone = statusTone(label, stale);
+ const rawStatus = /^(healthy|operational|running|up|online|ready|degraded|attention|critical|down|offline|missing|faulted|unknown)$/i.test(label);
+ return {tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'} {stale ? copy.inventory.stale : rawStatus ? presentStatus(label) : label || copy.inventory.unknown} ;
+}
+
+function ErrorState({ retry }: { retry: () => void }) {
+ return {copy.inventory.error}
{copy.inventory.retry} ;
+}
+
+export function InventoryPage({ id }: { id?: string }) {
+ return id ? : ;
+}
+
+function InventoryList() {
+ const [state, setState] = useState('loading');
+ const [items, setItems] = useState([]);
+ const [cursor, setCursor] = useState(() => queryValue('after'));
+ const [nextCursor, setNextCursor] = useState('');
+ const [history, setHistory] = useState([]);
+ const pageStatus = useRef(null);
+ const [hasMore, setHasMore] = useState(false);
+ const [query, setQuery] = useState(() => queryValue('q'));
+ const deferredQuery = useDeferredValue(query);
+ const [type, setType] = useState(() => queryValue('type'));
+ const [status, setStatus] = useState(() => queryValue('status'));
+ const [order, setOrder] = useState(() => queryValue('order', ['asc', 'desc'], 'asc'));
+ const [revision, setRevision] = useState(0);
+ const request = useMemo(() => {
+ const params = new URLSearchParams({ limit: '25', order });
+ if (deferredQuery.trim()) params.set('q', deferredQuery.trim());
+ if (type) params.set('type', type);
+ if (status) params.set('status', status);
+ if (cursor) params.set('after', cursor);
+ return params;
+ }, [deferredQuery, type, status, order, cursor]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setState((current) => current === 'ready' ? 'ready' : 'loading');
+ replaceListQuery({ q: deferredQuery.trim(), type, status, order: order === 'asc' ? '' : order, after: cursor });
+ fetch('/api/v1/entities?' + request, { signal: controller.signal })
+ .then((response) => { if (!response.ok) throw new Error('inventory'); return response.json() as Promise; })
+ .then((page) => { setItems(page.items ?? []); setNextCursor(page.nextCursor ?? ''); setHasMore(Boolean(page.hasMore)); setState('ready'); if (cursor) requestAnimationFrame(() => pageStatus.current?.focus()); })
+ .catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
+ return () => controller.abort();
+ }, [request, deferredQuery, type, status, order, cursor, revision]);
+
+ const resetPage = () => { setCursor(''); setHistory([]); };
+ const previous = () => { const prior = [...history]; setCursor(prior.pop() ?? ''); setHistory(prior); };
+ const next = () => { if (!nextCursor) return; setHistory((values) => [...values, cursor]); setCursor(nextCursor); };
+
+ const sourceTotal = items.filter((item) => item.sourceCount > 0).length;
+ return <>
+ {copy.inventory.eyebrow}
{copy.inventory.title} {copy.inventory.intro}
+
+ {copy.inventory.entities}
{state === 'ready' ? items.length + (hasMore ? '+' : '') : '—'} {copy.inventory.visibleEntities}
+ {copy.inventory.provenance}
{state === 'ready' ? sourceTotal : '—'} {copy.inventory.sourceCoverage}
+ {copy.inventory.manualCorrections}
{state === 'ready' ? items.reduce((sum, item) => sum + item.overrideCount, 0) : '—'} {copy.inventory.overrideProtection}
+
+
+ {copy.inventory.entities}
{copy.inventory.searchTitle} {state === 'ready' &&
}
+
+ {state === 'error' ? setRevision((value) => value + 1)} /> : state === 'loading' ? {copy.inventory.loading}
: items.length === 0 ? {copy.inventory.empty}
: }
+ {state === 'ready' && {copy.inventory.previous} {copy.inventory.page} {history.length + 1} {copy.inventory.next} }
+
+ >;
+}
+
+function InventoryDetail({ id }: { id: string }) {
+ const [state, setState] = useState('loading');
+ const [detail, setDetail] = useState(null);
+ const [revision, setRevision] = useState(0);
+ useEffect(() => {
+ const controller = new AbortController(); setState('loading');
+ fetch('/api/v1/entities/' + encodeURIComponent(id), { signal: controller.signal })
+ .then((response) => { if (!response.ok) throw new Error('inventory'); return response.json() as Promise; })
+ .then((value) => { setDetail(value); setState('ready'); })
+ .catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
+ return () => controller.abort();
+ }, [id, revision]);
+ if (state === 'loading') return {copy.inventory.loadingDetail} ;
+ if (state === 'error' || !detail) return {copy.inventory.detailUnavailable} setRevision((value) => value + 1)} /> ;
+ const entity = detail.entity;
+ return <>
+ ← {copy.inventory.back}
+
+ {copy.inventory.sources}
{entity.sourceCount} {copy.inventory.firstSeen}: {entity.firstSeenAt ? formatDateTime(entity.firstSeenAt) : copy.inventory.unknown}
{copy.inventory.facts}
{entity.factCount} {entity.staleFactCount} {copy.inventory.staleFacts}
{copy.inventory.relations}
{entity.relationCount} {detail.aliases.length} {copy.inventory.aliases.toLowerCase()}
+ {copy.inventory.effective}
{copy.inventory.effectiveTitle} {detail.effectiveValues.length === 0 ? {copy.inventory.noEffective}
: {detail.effectiveValues.map((item) =>
{presentInventoryField(item.fieldName)} {effectiveValueText(item.fieldName, item.value)} {item.origin === 'override' ? copy.inventory.manualOverride : item.sourceName || copy.inventory.discovered} {item.stale && {copy.inventory.stale} }{item.origin === 'override' ? copy.inventory.overrideWins : `${copy.inventory.observed} ${item.observedAt ? formatDateTime(item.observedAt) : copy.inventory.unknown} · ${Math.round((item.confidence ?? 0) * 100)}%`} )} }
+
+
{copy.inventory.topology}
{copy.inventory.relations} {detail.relations.length === 0 ? {copy.inventory.noRelations}
: }
+
{copy.inventory.identity}
{copy.inventory.aliases} {detail.aliases.length === 0 ? {copy.inventory.noAliases}
: {detail.aliases.map((alias) => {alias.sourceName} : {presentEntityType(alias.externalType)} )} }
+
+ {copy.inventory.allEvidence} {copy.inventory.facts} {detail.facts.length === 0 ? {copy.inventory.noFacts}
: {detail.facts.map((fact) => {fact.fieldName} : {valueText(fact.value)} · {fact.sourceName} · {formatDateTime(fact.observedAt)} {fact.stale ? '· ' + copy.inventory.stale : ''} )} }{copy.inventory.overrides} {detail.overrides.length === 0 ? {copy.inventory.noOverrides}
: {detail.overrides.map((override) => {override.fieldName} : {valueText(override.value)} · {formatDateTime(override.updatedAt)} )} }
+ >;
+}
diff --git a/apps/web/src/MetricWidgets.tsx b/apps/web/src/MetricWidgets.tsx
new file mode 100644
index 0000000..cd2f0eb
--- /dev/null
+++ b/apps/web/src/MetricWidgets.tsx
@@ -0,0 +1,264 @@
+import { useId, type CSSProperties, type ReactNode } from 'react';
+import type { BufferedPoint, ChartSeries, LiveFreshness } from './liveBuffer';
+import type { MetricInspector } from './metricClient';
+import { copy } from './copy';
+import { presentMetric, presentStatus } from './presentation';
+
+export type MetricWidgetKind = 'stat' | 'timeseries' | 'gauge' | 'query-inspector';
+export type MetricWidgetAvailability = 'idle' | 'loading' | 'success' | 'error' | 'connecting' | 'live';
+export type MetricWidgetProps = {
+ kind: MetricWidgetKind;
+ series: ChartSeries[];
+ freshness: LiveFreshness;
+ expectedStepSeconds: number;
+ availability: MetricWidgetAvailability;
+ error?: string | null;
+ visualization?: Record;
+ metricName?: string;
+ sourceObservedAt?: string;
+ receivedAt?: string;
+ warnings?: string[];
+ inspector?: MetricInspector;
+};
+
+type LimitedSeries = { key: string; points: BufferedPoint[] };
+type NumericPoint = Omit & { value: number };
+type Summary = { min: number; max: number; average: number; count: number };
+
+export function seriesDisplayName(series: LimitedSeries, metricName?: string): string {
+ const pointLabels = series.points.at(-1)?.labels ?? {};
+ const labels = Object.entries(pointLabels)
+ .filter(([key, value]) => key !== '__name__' && String(value).trim() !== '')
+ .sort(([left], [right]) => left.localeCompare(right, 'nl-BE'));
+ if (labels.length > 0) return labels.map(([, value]) => String(value)).join(' · ');
+
+ if (series.key.startsWith('{')) {
+ try {
+ const parsed = JSON.parse(series.key) as Record;
+ const values = Object.entries(parsed)
+ .filter(([key, value]) => key !== '__name__' && String(value).trim() !== '')
+ .sort(([left], [right]) => left.localeCompare(right, 'nl-BE'))
+ .map(([, value]) => String(value));
+ if (values.length > 0) return values.join(' · ');
+ } catch { /* A non-JSON series key is handled by the readable fallback below. */ }
+ }
+ if (series.key === metricName) return copy.metrics.totalSeries;
+ return series.key.replaceAll('.', ' › ').replaceAll('_', ' ');
+}
+
+const freshnessLabels: Record = {
+ fresh: copy.metrics.fresh,
+ delayed: copy.metrics.delayed,
+ stale: copy.metrics.stale,
+ unavailable: copy.metrics.unavailable,
+};
+const unitLabels: Record = {
+ percent: '%',
+ percentage: '%',
+ seconds: 's',
+ milliseconds: 'ms',
+ bytes: 'bytes',
+ bytesPerSecond: 'bytes/s',
+ count: '',
+};
+
+function numberSetting(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
+}
+function booleanSetting(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; }
+function decimalsSetting(value: unknown): number { const decimals = numberSetting(value); return decimals === undefined ? 2 : Math.min(6, Math.max(0, Math.trunc(decimals))); }
+function formatNumber(value: number, decimals: number): string { return new Intl.NumberFormat('nl-BE', { maximumFractionDigits: decimals, minimumFractionDigits: decimals }).format(value); }
+function unitText(value: unknown): string { const unit = typeof value === 'string' ? value : ''; return unitLabels[unit] ?? unit; }
+function formatValue(value: number, visualization: Record): string {
+ const decimals = decimalsSetting(visualization.decimals);
+ const unit = unitText(visualization.unit);
+ const formatted = formatNumber(value, decimals);
+ return unit ? formatted + ' ' + unit : formatted;
+}
+function validPoints(series: readonly LimitedSeries[]): NumericPoint[] { return series.flatMap((item) => item.points).filter((point): point is NumericPoint => point.value !== null && Number.isFinite(point.value)); }
+function latestPoint(series: readonly LimitedSeries[]): BufferedPoint | null {
+ return validPoints(series).sort((a, b) => b.timestamp - a.timestamp || String(a.labels?.__name__ ?? '').localeCompare(String(b.labels?.__name__ ?? '')))[0] ?? null;
+}
+function summaryFor(points: readonly BufferedPoint[]): Summary | null {
+ const values = points.map((point) => point.value).filter((value): value is number => value !== null && Number.isFinite(value));
+ if (values.length === 0) return null;
+ return { min: Math.min(...values), max: Math.max(...values), average: values.reduce((sum, value) => sum + value, 0) / values.length, count: values.length };
+}
+function seriesSummary(series: LimitedSeries): Summary | null { return summaryFor(series.points); }
+
+/**
+ * UX_SPEC section 8 asks for "readable charts with reduced series" on mobile.
+ * Narrow viewports get a smaller series/point budget than the documented
+ * desktop bounds; desktop keeps 20 series x 4000 points unchanged.
+ */
+export function reducedMetricLimits(width = typeof window === 'undefined' ? 1280 : window.innerWidth): { maxSeries: number; maxPoints: number } | null {
+ if (width <= 700) return { maxSeries: 4, maxPoints: 600 };
+ if (width <= 900) return { maxSeries: 8, maxPoints: 1500 };
+ return null;
+}
+
+export function limitMetricSeries(series: readonly ChartSeries[], maxSeries = 20, maxPoints = 4000): LimitedSeries[] {
+ const safeSeries = Math.max(1, Math.min(100, Math.trunc(maxSeries)));
+ const safePoints = Math.max(1, Math.min(10000, Math.trunc(maxPoints)));
+ const selected = [...series].sort((a, b) => a.key.localeCompare(b.key)).slice(0, safeSeries);
+ if (selected.length === 0) return [];
+ const perSeries = Math.max(1, Math.floor(safePoints / selected.length));
+ let remainder = safePoints - perSeries * selected.length;
+ return selected.map((item) => {
+ const allowance = perSeries + (remainder > 0 ? 1 : 0);
+ remainder = Math.max(0, remainder - 1);
+ return { key: item.key, points: [...item.points].sort((a, b) => a.timestamp - b.timestamp).slice(-allowance) };
+ });
+}
+
+function freshnessFor(props: MetricWidgetProps, point: BufferedPoint | null): LiveFreshness {
+ if (props.freshness === 'stale' || props.freshness === 'unavailable') return props.freshness;
+ return point?.freshness ?? props.freshness;
+}
+function freshnessLabel(freshness: LiveFreshness): string { return freshnessLabels[freshness]; }
+function availabilityText(props: MetricWidgetProps): string {
+ if (props.availability === 'loading') return copy.metrics.loading;
+ if (props.availability === 'connecting') return copy.metrics.connecting;
+ if (props.availability === 'error') return props.error || 'De metric is niet beschikbaar.';
+ if (props.availability === 'idle') return copy.metrics.noMetric;
+ return copy.metrics.noReliableData;
+}
+function hasDataGap(series: readonly LimitedSeries[], expectedStepSeconds: number): boolean {
+ const threshold = Math.max(1, expectedStepSeconds) * 2000;
+ return series.some((item) => item.points.some((point, index) => point.value === null || (index > 0 && point.timestamp - item.points[index - 1].timestamp > threshold)));
+}
+function MetricNotice({ children, tone = 'unknown' }: { children: ReactNode; tone?: 'unknown' | 'warning' }) { return {children}
; }
+function FreshnessPill({ freshness }: { freshness: LiveFreshness }) { return {freshnessLabel(freshness)} ; }
+function Sparkline({ points, label }: { points: BufferedPoint[]; label: string }) {
+ const values = points.filter((point): point is BufferedPoint & { value: number } => point.value !== null && Number.isFinite(point.value));
+ if (values.length < 2) return null;
+ const min = Math.min(...values.map((point) => point.value));
+ const max = Math.max(...values.map((point) => point.value));
+ const spread = max - min || 1;
+ const path = values.map((point, index) => `${index === 0 ? 'M' : 'L'} ${(index / (values.length - 1)) * 100} ${100 - ((point.value - min) / spread) * 100}`).join(' ');
+ return ;
+}
+
+function sourceAgeText(iso?: string): string {
+ if (!iso) return '';
+ const timestamp = Date.parse(iso);
+ if (!Number.isFinite(timestamp)) return '';
+ const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
+ if (seconds < 60) return copy.metrics.sourceAge + ': ' + seconds + ' ' + copy.metrics.secondsAgo;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return copy.metrics.sourceAge + ': ' + minutes + ' ' + copy.metrics.minutesAgo;
+ return copy.metrics.sourceAge + ': ' + Math.floor(minutes / 60) + ' ' + copy.metrics.hoursAgo;
+}
+function SourceMetadata({ freshness, sourceObservedAt }: { freshness: LiveFreshness; sourceObservedAt?: string }) {
+ const age = sourceAgeText(sourceObservedAt);
+ return {age && {age} }
;
+}
+function safeInspectorQuery(value: string): string {
+ let safe = value;
+ ['authorization', 'cookie', 'password', 'passwd', 'secret', 'token', 'api_key', 'client_secret'].forEach((key) => {
+ safe = safe.replace(new RegExp(key + '[^,; ]*', 'gi'), key + '=');
+ });
+ return safe;
+}
+function QueryInspectorWidget({ props }: { props: MetricWidgetProps }) {
+ const inspector = props.inspector;
+ if (!inspector) return {copy.metrics.inspectorForbidden} ;
+ return {copy.metrics.inspectorTitle}
{copy.metrics.semanticMetric} {inspector.semanticMetric}
{copy.metrics.estimatedSamples} {inspector.cost.estimatedSamples.toLocaleString('nl-BE')}
{copy.metrics.seriesLimit} {inspector.cost.series} / {inspector.limits.maxSeries}
{copy.metrics.pointLimit} {inspector.cost.points} / {inspector.limits.maxPoints} {copy.metrics.generatedQuery}
{safeInspectorQuery(inspector.generatedQuery)} ;
+}
+function StatWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
+ const point = latestPoint(series);
+ const freshness = freshnessFor(props, point);
+ if (!point || point.value === null || props.availability === 'error' || props.availability === 'idle') return {availabilityText(props)} ;
+ const visualization = props.visualization ?? {};
+ const primary = series[0];
+ return {formatValue(point.value, visualization)}
{new Date(point.timestamp).toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit' })}
{booleanSetting(visualization.showSparkline, false) && primary &&
}
;
+}
+
+function GaugeWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
+ const point = latestPoint(series);
+ const visualization = props.visualization ?? {};
+ const freshness = freshnessFor(props, point);
+ if (!point || point.value === null || props.availability === 'error' || props.availability === 'idle') return {availabilityText(props)} ;
+ const values = validPoints(series).map((item) => item.value);
+ const configuredMin = numberSetting(visualization.min);
+ const configuredMax = numberSetting(visualization.max);
+ const min = configuredMin ?? Math.min(0, ...values);
+ const max = configuredMax ?? Math.max(min + 1, ...values);
+ const ratio = Math.min(1, Math.max(0, (point.value - min) / (max - min || 1)));
+ const style = { '--gauge-ratio': `${ratio * 100}%` } as CSSProperties;
+ return
{formatValue(point.value, visualization)}
{formatValue(min, visualization)} {formatValue(max, visualization)}
{configuredMin !== undefined && configuredMax !== undefined ? copy.metrics.fixedRange : copy.metrics.derivedRange}
;
+}
+
+function chartPath(points: readonly BufferedPoint[], minTime: number, timeSpread: number, minValue: number, valueSpread: number, gapThreshold: number): string {
+ let path = '';
+ points.forEach((point, index) => {
+ if (point.value === null || !Number.isFinite(point.value)) return;
+ const x = 28 + ((point.timestamp - minTime) / timeSpread) * 600;
+ const y = 192 - ((point.value - minValue) / valueSpread) * 180;
+ const previous = points[index - 1];
+ const command = !previous || previous.value === null || point.timestamp - previous.timestamp > gapThreshold ? 'M' : 'L';
+ path += `${command} ${Math.min(628, Math.max(28, x)).toFixed(3)} ${Math.min(192, Math.max(12, y)).toFixed(3)} `;
+ });
+ return path.trim();
+}
+function exportCsv(series: readonly LimitedSeries[], visualization: Record, metricName?: string): void {
+ if (typeof document === 'undefined' || typeof URL === 'undefined') return;
+ const rows = [['series', 'timestamp', 'value', 'freshness']];
+ series.forEach((item) => item.points.forEach((point) => rows.push([item.key, new Date(point.timestamp).toISOString(), point.value === null ? '' : String(point.value), point.freshness])));
+ const csv = rows.map((row) => row.map((cell) => '"' + cell.replaceAll('"', '""') + '"').join(',')).join('\n');
+ const link = document.createElement('a');
+ const objectURL = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
+ link.href = objectURL;
+ link.download = (metricName || 'metric').replace(/[^a-z0-9_-]+/gi, '-') + '.csv';
+ link.click();
+ window.setTimeout(() => URL.revokeObjectURL(objectURL), 0);
+ void visualization;
+}
+
+function TimeSeriesWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
+ const chartId = useId();
+ const chartTitleId = chartId + '-title';
+ const chartDescriptionId = chartId + '-description';
+ const visualization = props.visualization ?? {};
+ const point = latestPoint(series);
+ const freshness = freshnessFor(props, point);
+ if (props.availability === 'error' || props.availability === 'idle' || series.length === 0 || validPoints(series).length === 0) return {availabilityText(props)} ;
+ const points = validPoints(series);
+ const configuredMin = numberSetting(visualization.min);
+ const configuredMax = numberSetting(visualization.max);
+ const minValue = configuredMin ?? Math.min(...points.map((item) => item.value));
+ const maxValue = configuredMax ?? Math.max(...points.map((item) => item.value));
+ const valueSpread = maxValue - minValue || 1;
+ const minTime = Math.min(...series.flatMap((item) => item.points.map((pointItem) => pointItem.timestamp)));
+ const maxTime = Math.max(...series.flatMap((item) => item.points.map((pointItem) => pointItem.timestamp)));
+ const timeSpread = maxTime - minTime || 1;
+ const gap = hasDataGap(series, props.expectedStepSeconds);
+ const metricLabel = props.metricName ? presentMetric(props.metricName) : copy.metrics.metric;
+ const summaryText = `${series.length} reeks, ${points.length} meetpunten, bereik ${formatValue(minValue, visualization)} tot ${formatValue(maxValue, visualization)}.`;
+ return {gap && {copy.metrics.dataGap} }
exportCsv(series, visualization, props.metricName)}>{copy.metrics.exportCsv} {copy.metrics.chartTitle} {metricLabel} {summaryText}{gap ? ' ' + copy.metrics.gapDescription : ''} {series.map((item, index) => { const label = seriesDisplayName(item, props.metricName); return {label} ; })}{summaryText} {booleanSetting(visualization.legend, true) &&
{series.map((item, index) => {seriesDisplayName(item, props.metricName)} )} }
{copy.metrics.minimum}: {formatValue(Math.min(...points.map((item) => item.value)), visualization)} {copy.metrics.maximum}: {formatValue(Math.max(...points.map((item) => item.value)), visualization)} {copy.metrics.average}: {formatValue(points.reduce((sum, item) => sum + item.value, 0) / points.length, visualization)}
{copy.metrics.summary} {copy.metrics.series} {copy.metrics.latest} {copy.metrics.count} {series.map((item) => { const latest = latestPoint([item]); const itemSummary = seriesSummary(item); return {seriesDisplayName(item, props.metricName)} { !latest || latest.value === null ? copy.dashboards.unknown : formatValue(latest.value, visualization)} {itemSummary?.count ?? 0} ; })}
;
+}
+
+export function metricStatus(props: MetricWidgetProps): { label: string; tone: 'unknown' | 'ready' } {
+ if (props.availability === 'loading' || props.availability === 'connecting') return { label: copy.metrics.connecting, tone: 'unknown' };
+ if (props.availability === 'error' || props.availability === 'idle' || props.series.length === 0) return { label: copy.dashboards.unknown, tone: 'unknown' };
+ const freshness = freshnessFor(props, latestPoint(limitMetricSeries(props.series)));
+ return { label: freshnessLabel(freshness), tone: freshness === 'fresh' ? 'ready' : 'unknown' };
+}
+
+export function MetricWidget(props: MetricWidgetProps) {
+ const reduced = reducedMetricLimits();
+ const series = reduced ? limitMetricSeries(props.series, reduced.maxSeries, reduced.maxPoints) : limitMetricSeries(props.series, 20, 4000);
+ const gap = hasDataGap(series, props.expectedStepSeconds);
+ const content = props.kind === 'stat' ? : props.kind === 'gauge' ? : props.kind === 'query-inspector' ? : ;
+ return {props.warnings && props.warnings.slice(0, 10).map((warning) =>
{copy.metrics.sourceWarning}: {warning.slice(0, 240)}
)}{content}{gap && props.kind !== 'timeseries' &&
{copy.metrics.gapNotice} }{(props.freshness === 'stale' || props.freshness === 'unavailable') &&
{copy.metrics.sourceStatus}: {freshnessLabel(props.freshness)}. {copy.metrics.notHealthy} }
;
+}
+export type RankedListItem = { id: string; label: string; value: string; detail?: string };
+export type StatusGridItem = { id: string; label: string; status: string; reason?: string };
+
+export function RankedListWidget({ items, onSelect }: { items: RankedListItem[]; onSelect: (id: string) => void }) {
+ return {items.slice(0, 100).map((item, index) => onSelect(item.id)}>{index + 1} {item.label} {item.detail || item.id} {item.value} )} ;
+}
+
+export function StatusGridWidget({ items, onSelect }: { items: StatusGridItem[]; onSelect: (id: string) => void }) {
+ return {items.slice(0, 100).map((item) => onSelect(item.id)}>{item.label} {item.reason || presentStatus(item.status)} )} ;
+}
diff --git a/apps/web/src/NetworkPage.tsx b/apps/web/src/NetworkPage.tsx
new file mode 100644
index 0000000..bbc238a
--- /dev/null
+++ b/apps/web/src/NetworkPage.tsx
@@ -0,0 +1,35 @@
+import { formatDateTime } from './locale';
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { presentReason, presentStatus } from './presentation';
+
+export type NetworkData = {
+ contractVersion: string;
+ observedAt: string;
+ source: { id: string; freshness: string; observedAt: string; state: string; reason?: string };
+ health: Array<{ scope: string; state: string; capabilityState?: string; configurationState?: string; reason?: string; sourceId?: string; freshness: string; observedAt: string; latencyMs?: number }>;
+ interfaces: Array<{ name: string; state: string; rxBytes: number; txBytes: number; rxErrors: number; txErrors: number; rxDrops: number; txDrops: number }>;
+ certificates: Array<{ id: string; serviceId: string; observedAt: string; expiresAt?: string; issuer?: string; subject?: string; hostnameValid?: boolean; verificationState: string }>;
+ events: Array<{ id: string; scope: string; state: string; reason?: string; occurredAt: string }>;
+};
+
+function stateLabel(state: string): string { if (state === 'up') return copy.network.up; if (state === 'degraded') return copy.network.degraded; if (state === 'down') return copy.network.down; return copy.network.unknown; }
+function scopeLabel(scope: string): string { if (scope === 'internal') return copy.network.internal; if (scope === 'gateway') return copy.network.gateway; if (scope === 'dns') return copy.network.dns; return copy.network.internet; }
+function reasonLabel(reason?: string): string { if (reason === 'not_configured') return copy.network.notConfiguredDetail; if (reason === 'stale_probe' || reason === 'source_stale') return copy.network.staleDetail; if (reason === 'source_unavailable') return copy.network.unavailableDetail; if (reason === 'unsupported') return copy.network.unsupportedDetail; return reason ? presentReason(reason) : copy.network.noReason; }
+function when(value?: string): string { return value ? formatDateTime(value) : copy.network.notAvailable; }
+function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return copy.network.notAvailable; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
+function NetworkState({ state, capabilityState, configurationState }: { state: string; capabilityState?: string; configurationState?: string }) { const label = configurationState === 'not_configured' ? copy.network.notConfigured : capabilityState === 'unsupported' ? copy.network.unsupported : capabilityState === 'unavailable' ? copy.network.unavailable : stateLabel(state); return {state === 'up' ? '✓' : state === 'unknown' ? '?' : '!'} {label} ; }
+
+export function NetworkHealthWidget({ snapshot, compact = false }: { snapshot: NetworkData; compact?: boolean }) {
+ const health = [...snapshot.health].sort((left, right) => left.scope.localeCompare(right.scope));
+ return {!compact && {copy.network.widgetKicker}
{copy.network.widgetTitle} {snapshot.source.id} · {presentStatus(snapshot.source.freshness)} }{health.map((item) =>
{scopeLabel(item.scope)} {reasonLabel(item.reason)}
{item.latencyMs != null && {item.latencyMs} ms }{presentStatus(item.freshness)} · {when(item.observedAt)} )}
{!compact && {copy.network.separateSignals}
} ;
+}
+
+export function NetworkPage() {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [snapshot, setSnapshot] = useState(null);
+ useEffect(() => { const controller = new AbortController(); fetch('/api/v1/network', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('network'); return response.json() as Promise; }).then((data) => { setSnapshot({ ...data, health: data.health ?? [], interfaces: data.interfaces ?? [], certificates: data.certificates ?? [], events: data.events ?? [] }); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
+ if (state === 'loading') return ;
+ if (state === 'error' || !snapshot) return × {copy.network.errorTitle} {copy.network.errorDetail}
window.location.reload()}>{copy.network.retry} ;
+ return <>{copy.network.eyebrow}
{copy.network.title} {copy.network.intro}
{copy.network.interfaces}
{snapshot.interfaces.length} {snapshot.interfaces.length === 0 ? {copy.network.noInterfaces}
: {copy.network.interfaceCaption} {copy.network.interface} RX / TX {copy.network.errors} {copy.network.drops} {snapshot.interfaces.map((item) => {item.name} {bytes(item.rxBytes)} / {bytes(item.txBytes)} {item.rxErrors} / {item.txErrors} {item.rxDrops} / {item.txDrops} )}
}{copy.network.certificates}
{snapshot.certificates.length} {snapshot.certificates.length === 0 ? {copy.network.noCertificates}
: {snapshot.certificates.map((certificate) => {certificate.serviceId} {presentStatus(certificate.verificationState)} · {when(certificate.expiresAt)} {certificate.issuer || copy.network.notAvailable} · {certificate.hostnameValid === false ? copy.network.hostnameInvalid : copy.network.hostnameValid} )} }{copy.network.events}
{snapshot.events.length} {snapshot.events.length === 0 ? {copy.network.noEvents}
: {snapshot.events.map((event) => {scopeLabel(event.scope)} · {stateLabel(event.state)} {reasonLabel(event.reason)} · {when(event.occurredAt)} )} } >;
+}
diff --git a/apps/web/src/NotFoundPage.tsx b/apps/web/src/NotFoundPage.tsx
new file mode 100644
index 0000000..9c5bea4
--- /dev/null
+++ b/apps/web/src/NotFoundPage.tsx
@@ -0,0 +1,11 @@
+import { copy } from './copy';
+
+export function NotFoundPage() {
+ return
+ 404
+ {copy.notFound.eyebrow}
+ {copy.notFound.title}
+ {copy.notFound.detail}
+
+ ;
+}
diff --git a/apps/web/src/OnboardingPage.tsx b/apps/web/src/OnboardingPage.tsx
new file mode 100644
index 0000000..24f8c2f
--- /dev/null
+++ b/apps/web/src/OnboardingPage.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { componentStatus, useSystemStatus } from './systemStatus';
+
+type Capability = { id: string; state: string; detail: string };
+type OnboardingStatus = { state: { completed: boolean; step: string; dashboardChoice?: string; rulesChoice?: string; dashboardId?: string; rulesReady: boolean }; capabilities: Capability[]; resume: boolean };
+
+function capabilityLabel(state: string): string {
+ if (state === 'ready' || state === 'configured' || state === 'development') return copy.onboarding.ready;
+ if (state === 'incomplete' || state === 'not-ready') return copy.onboarding.action;
+ return copy.onboarding.unknown;
+}
+
+export function OnboardingPage() {
+ const runtime = useSystemStatus();
+ const [status, setStatus] = useState(null);
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [dashboard, setDashboard] = useState('default');
+ const [rules, setRules] = useState('default');
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useState('');
+ const [reconfigure, setReconfigure] = useState(false);
+
+ const load = (signal?: AbortSignal) => {
+ setState('loading');
+ fetch('/api/v1/onboarding', { signal }).then((response) => { if (!response.ok) throw new Error('onboarding'); return response.json() as Promise; }).then((data) => {
+ setStatus(data); setDashboard(data.state.dashboardChoice || 'default'); setRules(data.state.rulesChoice || 'default'); setState('ready');
+ }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); });
+ };
+ useEffect(() => { const controller = new AbortController(); load(controller.signal); return () => controller.abort(); }, []);
+
+ const complete = async () => {
+ if (status?.state.completed && !window.confirm(copy.onboarding.confirmReconfigure)) return;
+ setSaving(true); setMessage('');
+ try {
+ const response = await fetch('/api/v1/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dashboard, rules }) });
+ if (!response.ok) throw new Error(response.status === 403 ? 'admin' : 'save');
+ setStatus(await response.json() as OnboardingStatus);
+ setReconfigure(false);
+ setMessage(copy.onboarding.saved);
+ } catch (error) { setMessage(error instanceof Error && error.message === 'admin' ? copy.onboarding.adminRequired : copy.onboarding.saveError); } finally { setSaving(false); }
+ };
+
+ if (state === 'loading') return {copy.onboarding.loading} ;
+ if (state === 'error' || !status) return × {copy.onboarding.errorTitle} {copy.onboarding.errorDetail}
load()}>{copy.onboarding.retry} ;
+
+ const capabilities = status.capabilities.map((capability) => {
+ if (capability.id !== 'prometheus' && capability.id !== 'unraid') return capability;
+ const observed = componentStatus(runtime.status, capability.id);
+ if (!observed || observed.state !== 'healthy') return capability;
+ return { ...capability, state: 'ready', detail: copy.onboarding.runtimeReady };
+ });
+ return <>{copy.onboarding.eyebrow}
{copy.onboarding.title} {status.state.completed ? copy.onboarding.completedIntro : status.resume ? copy.onboarding.resumeIntro : copy.onboarding.intro}
+ {copy.onboarding.capabilities}
{copy.onboarding.readiness} ? {status.state.completed ? copy.onboarding.completed : copy.onboarding.inProgress}{capabilities.map((capability) => {copy.onboarding.capabilityNames[capability.id as keyof typeof copy.onboarding.capabilityNames] ?? capability.id} {capability.detail} {capabilityLabel(capability.state)} )}
+ {status.state.completed && !reconfigure ? {copy.onboarding.completedSummary}
{copy.onboarding.configurationActive} {copy.onboarding.configurationActiveDetail}
{copy.onboarding.dashboardChoice} {dashboard === 'default' ? copy.onboarding.dashboardInstalled : copy.onboarding.skipped}
{copy.onboarding.rulesChoice} {rules === 'default' ? copy.onboarding.rulesInstalled : copy.onboarding.skipped} {copy.onboarding.reconfigureNote}
{ setMessage(''); setReconfigure(true); }}>{copy.onboarding.reconfigure} {message && {message}
} : {status.state.completed ? copy.onboarding.reconfigure : copy.onboarding.choices}
{copy.onboarding.defaultsTitle} {copy.onboarding.defaultsIntro}
{copy.onboarding.dashboardChoice} setDashboard('default')} /> {copy.onboarding.installDefault} setDashboard('skip')} /> {copy.onboarding.skip}{copy.onboarding.rulesChoice} setRules('default')} /> {copy.onboarding.keepDefaults} setRules('skip')} /> {copy.onboarding.skip}{copy.onboarding.safeNote}
void complete()}>{saving ? copy.onboarding.saving : status.state.completed ? copy.onboarding.saveReconfiguration : copy.onboarding.complete} {status.state.completed && setReconfigure(false)}>{copy.onboarding.cancel} }
{message && {message}
} }
+ >;
+}
diff --git a/apps/web/src/OperationalSignalPath.tsx b/apps/web/src/OperationalSignalPath.tsx
new file mode 100644
index 0000000..13ae416
--- /dev/null
+++ b/apps/web/src/OperationalSignalPath.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useMemo, useState } from 'react';
+
+import { copy } from './copy';
+import { signalToneRank, type SignalTone } from './overviewSignals';
+
+export type OperationalSignalStage = {
+ id: string;
+ label: string;
+ icon: string;
+ tone: SignalTone;
+ statusLabel: string;
+ primaryLabel: string;
+ primaryValue: string;
+ secondaryLabel: string;
+ secondaryValue: string;
+ detail: string;
+ route: string;
+};
+
+const toneIcon: Record = {
+ healthy: '✓',
+ attention: '!',
+ critical: '×',
+ stale: '◷',
+ unknown: '?',
+};
+
+function initialStage(stages: OperationalSignalStage[]): string {
+ return [...stages].sort((left, right) => signalToneRank[left.tone] - signalToneRank[right.tone])[0]?.id ?? '';
+}
+
+export function OperationalSignalPath({ stages, onNavigate }: { stages: OperationalSignalStage[]; onNavigate: (route: string) => void }) {
+ const preferredStage = useMemo(() => initialStage(stages), [stages]);
+ const [selectedID, setSelectedID] = useState(preferredStage);
+ const [userSelected, setUserSelected] = useState(false);
+ useEffect(() => {
+ if (!stages.some((stage) => stage.id === selectedID)) {
+ setSelectedID(preferredStage);
+ setUserSelected(false);
+ return;
+ }
+ if (!userSelected && selectedID !== preferredStage) setSelectedID(preferredStage);
+ }, [preferredStage, selectedID, stages, userSelected]);
+ const selected = stages.find((stage) => stage.id === selectedID) ?? stages[0];
+ const overall = [...stages].sort((left, right) => signalToneRank[left.tone] - signalToneRank[right.tone])[0];
+
+ return
+
+
{copy.overview.signalPathKicker}
{copy.overview.signalPathTitle}
+ {overall &&
{toneIcon[overall.tone]} {overall.statusLabel}}
+
+ {copy.overview.signalPathIntro}
+
+ {stages.map((stage, index) =>
+ { setSelectedID(stage.id); setUserSelected(true); }}>
+ {String(index + 1).padStart(2, '0')}
+ {stage.icon}
+ {stage.label} {toneIcon[stage.tone]} {stage.statusLabel}
+ {stage.primaryLabel} {stage.primaryValue}
+
+ )}
+
+ {selected &&
+ {selected.icon} {copy.overview.signalPathSelected} {selected.label}
+
+
{selected.primaryLabel} {selected.primaryValue}
+
{selected.secondaryLabel} {selected.secondaryValue}
+
{copy.overview.signalPathState} {toneIcon[selected.tone]} {selected.statusLabel}
+
+ {selected.detail}
+ onNavigate(selected.route)}>{copy.overview.signalPathOpen} {selected.label.toLowerCase()}
+ }
+ {copy.overview.signalPathDisclaimer}
+ ;
+}
diff --git a/apps/web/src/PoolPage.tsx b/apps/web/src/PoolPage.tsx
new file mode 100644
index 0000000..b2a4a32
--- /dev/null
+++ b/apps/web/src/PoolPage.tsx
@@ -0,0 +1,21 @@
+import { formatDateTime } from './locale';
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { operationalStorageState, presentArrayRole, presentStatus } from './presentation';
+import { SourceStatusDetails } from './SourceStatusDetails';
+
+type Capability = 'available' | 'unsupported' | 'unavailable';
+type Member = { id: string; name: string; role: string; state: string; capacityBytes: number; errors: number };
+type Scrub = { id: string; state: string; progressPercent: number; errors: number; bytesChecked: number; startedAt?: string; completedAt?: string; result?: string };
+type Pool = { id: string; name: string; filesystem: string; state: string; usableBytes: number; usedBytes: number; freeBytes: number; utilizationPercent: number; capacitySeverity?: string; profile?: string; redundancy?: string; capabilities: { members: Capability; capacity: Capability; redundancy: Capability; scrub: Capability; filesystemErrors: Capability; performance: Capability; ssdWear: Capability; moverSignals: Capability }; members?: Member[]; errors?: Array<{ id: string; kind: string; message: string; count: number; observedAt?: string }>; scrub?: Scrub; scrubHistory?: Scrub[] };
+type Snapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; pools: Pool[]; total: number };
+
+function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
+function date(value?: string): string { return formatDateTime(value); }
+function Badge({ label, ready }: { label: string; ready: boolean }) { return {ready ? '✓' : '?'} {label} ; }
+function stateLabel(state: string): string { if (state === 'healthy') return copy.pools.healthy; if (state === 'degraded') return copy.pools.degraded; if (state === 'faulted') return copy.pools.faulted; return copy.pools.unknown; }
+function capabilityLabel(value: Capability): string { return value === 'available' ? copy.pools.available : value === 'unsupported' ? copy.pools.unsupported : copy.pools.unknown; }
+function severityLabel(value?: string): string { return value === 'normal' ? 'Normaal' : value === 'attention' ? 'Aandacht' : value === 'critical' ? 'Kritiek' : 'Onbekend'; }
+function PoolCard({ pool }: { pool: Pool }) { const operational = operationalStorageState(pool.state, pool.capacitySeverity); return {bytes(pool.usedBytes)} {copy.pools.usedOf} {bytes(pool.usableBytes)} · {pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% {copy.pools.used}
Device-health: {stateLabel(pool.state)} · capaciteit: {severityLabel(pool.capacitySeverity)}
{pool.profile || copy.pools.noProfile} · {pool.redundancy || copy.pools.noRedundancy}
; }
+function PoolDetail({ pool, source }: { pool: Pool; source: Snapshot['source'] }) { const members = pool.members ?? []; const errors = pool.errors ?? []; const operational = operationalStorageState(pool.state, pool.capacitySeverity); return <>{copy.pools.source}
{source.id} {pool.filesystem}
{copy.pools.capacity}
{pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% {bytes(pool.freeBytes)} {copy.pools.free}
{copy.pools.profile}
{pool.profile || copy.pools.noProfile} {pool.redundancy || copy.pools.noRedundancy}
{copy.pools.scrub}
{pool.scrub ? presentStatus(pool.scrub.state) : capabilityLabel(pool.capabilities.scrub)} {pool.scrub ? pool.scrub.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '% · ' + copy.pools.errors + ': ' + pool.scrub.errors : copy.pools.noScrub}
{copy.pools.capabilities}
{copy.pools.capabilityTitle} {copy.pools.members}: {capabilityLabel(pool.capabilities.members)} · {copy.pools.redundancy}: {capabilityLabel(pool.capabilities.redundancy)} · {copy.pools.filesystemErrors}: {capabilityLabel(pool.capabilities.filesystemErrors)} · {copy.pools.performance}: {capabilityLabel(pool.capabilities.performance)}
{copy.pools.ssdWear}: {capabilityLabel(pool.capabilities.ssdWear)} · {copy.pools.moverSignals}: {capabilityLabel(pool.capabilities.moverSignals)}
{copy.pools.members}
{copy.pools.memberTitle} {members.length === 0 ? {copy.pools.noMembers}
: {copy.pools.name} {copy.pools.role} {copy.pools.state} {copy.pools.errors} {members.map((member) => {member.name} {presentArrayRole(member.role)} {member.errors} )}
}{copy.pools.errors}
{copy.pools.poolErrorTitle} {errors.length === 0 ? {copy.pools.noErrors}
: {errors.map((error) => {error.kind} : {error.message} ({error.count}) )} }{copy.pools.history} {pool.scrubHistory?.length ? {pool.scrubHistory.map((scrub) => {presentStatus(scrub.state)} {scrub.result || copy.pools.noResult} · {date(scrub.completedAt || scrub.startedAt)} 0 ? copy.pools.attention : copy.pools.completed} ready={scrub.errors === 0} /> )} : {copy.pools.noHistory}
}{copy.pools.readOnly}
>; }
+export function PoolPage({ id }: { id?: string }) { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); useEffect(() => { const controller = new AbortController(); fetch(id ? '/api/v1/pools/' + encodeURIComponent(id) : '/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('pools'); return response.json() as Promise; }).then((data) => { setSnapshot(data); if (data.pool) setSelected(data.pool); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, [id]); if (state === 'loading') return ; if (state === 'error' || !snapshot) return × {copy.pools.errorTitle} {copy.pools.errorDetail}
; if (selected) return ; const fresh = snapshot.source?.freshness === 'fresh' && snapshot.source?.state !== 'unknown'; return <>{copy.pools.eyebrow}
{copy.pools.title} {copy.pools.intro}
{copy.pools.source}
{snapshot.source?.id || copy.pools.unknown} {snapshot.total} {copy.pools.rows}
{snapshot.pools.length ? snapshot.pools.map((pool) => ) : {copy.pools.empty}
} >; }
diff --git a/apps/web/src/ProcessPage.tsx b/apps/web/src/ProcessPage.tsx
new file mode 100644
index 0000000..913df1a
--- /dev/null
+++ b/apps/web/src/ProcessPage.tsx
@@ -0,0 +1,35 @@
+import { useDeferredValue, useEffect, useRef, useState } from 'react';
+import { copy } from './copy';
+import { queryValue, replaceListQuery } from './listQuery';
+import { presentReason, presentStatus } from './presentation';
+
+type ProcessItem = { pid: number; name: string; state: string; runtimeSeconds: number; cpuPercent: number; memoryBytes: number; containerId?: string; containerName?: string };
+type ProcessSnapshot = { source: { id: string; state: string; reason?: string }; processes: ProcessItem[]; total: number; nextCursor?: string };
+
+function bytes(value: number): string { if (!Number.isFinite(value)) return '—'; const units = ['B', 'KB', 'MB', 'GB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
+function Badge({ state }: { state: string }) { const ready = state === 'healthy'; return {ready ? '✓' : '?'} {ready ? 'Gereed' : 'Onbekend'} ; }
+
+export function ProcessPage() {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [snapshot, setSnapshot] = useState(null);
+ const [sort, setSort] = useState<'cpu' | 'memory'>(() => queryValue('sort', ['cpu', 'memory'], 'cpu') as 'cpu' | 'memory');
+ const [query, setQuery] = useState(() => queryValue('q'));
+ const [containerFilter, setContainerFilter] = useState(() => queryValue('container'));
+ const deferredQuery = useDeferredValue(query);
+ const deferredContainer = useDeferredValue(containerFilter);
+ const [cursor, setCursor] = useState(() => queryValue('after'));
+ const [history, setHistory] = useState([]);
+ const pageStatus = useRef(null);
+ const [reload, setReload] = useState(0);
+ useEffect(() => { const controller = new AbortController(); setState((current) => current === 'ready' ? 'ready' : 'loading'); const params = new URLSearchParams({ limit: '25', sort }); if (deferredQuery.trim()) params.set('q', deferredQuery.trim()); if (deferredContainer.trim()) params.set('container', deferredContainer.trim()); if (cursor) params.set('after', cursor); replaceListQuery({ q: deferredQuery.trim(), container: deferredContainer.trim(), sort: sort === 'cpu' ? '' : sort, after: cursor }); fetch('/api/v1/processes?' + params, { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('processes'); return response.json() as Promise; }).then((data) => { setSnapshot(data); setState('ready'); if (cursor) requestAnimationFrame(() => pageStatus.current?.focus()); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, [sort, deferredQuery, deferredContainer, cursor, reload]);
+ const resetPage = () => { setCursor(''); setHistory([]); };
+ const previous = () => { const prior = [...history]; setCursor(prior.pop() ?? ''); setHistory(prior); };
+ const next = () => { if (!snapshot?.nextCursor) return; setHistory((values) => [...values, cursor]); setCursor(snapshot.nextCursor ?? ''); };
+ if (state === 'loading') return ;
+ if (state === 'error' || !snapshot) return × {copy.processes.errorTitle} {copy.processes.errorDetail}
setReload((value) => value + 1)}>{copy.processes.retry} ;
+ return <>
+ {copy.processes.eyebrow}
{copy.processes.title} {copy.processes.intro}
+ {copy.processes.source}
{snapshot.source?.id || copy.processes.unknown} {snapshot.source?.reason ? presentReason(snapshot.source.reason) : copy.processes.privacy}
{snapshot.total} {copy.processes.rows} · {copy.processes.limitNote}
+ {copy.processes.list}
{copy.processes.top} {snapshot.processes.length === 0 ? {copy.processes.empty}
: <>PID {copy.processes.name} {copy.processes.cpu} {copy.processes.memory} {copy.processes.container} {snapshot.processes.map((item) => {item.pid} {item.name}{presentStatus(item.state)} · {Math.floor(item.runtimeSeconds / 60)} min {item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% {bytes(item.memoryBytes)} {item.containerName || copy.processes.unknown} )}
{snapshot.processes.map((item) => {item.name} PID {item.pid} · {presentStatus(item.state)}
{copy.processes.cpu} {item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%
{copy.processes.memory} {bytes(item.memoryBytes)}
{copy.processes.container} {item.containerName || copy.processes.unknown} )} >}{copy.processes.previous} {copy.processes.page} {history.length + 1} {copy.processes.next}
+ >;
+}
diff --git a/apps/web/src/ServicePage.tsx b/apps/web/src/ServicePage.tsx
new file mode 100644
index 0000000..097e55b
--- /dev/null
+++ b/apps/web/src/ServicePage.tsx
@@ -0,0 +1,184 @@
+import { useEffect, useMemo, useState } from 'react';
+import { copy } from './copy';
+import { formatDateTime } from './locale';
+
+type ProbeCertificate = { expiresAt?: string; issuer?: string; subject?: string; hostnameValid?: boolean; verificationState?: string };
+type ProbeResult = { id?: string; probeId: string; observedAt: string; completedAt?: string; state: string; responseTimeMs?: number; statusCode?: number; errorClass?: string; errorMessage?: string; certificate?: ProbeCertificate };
+ type ProbeConfig = { id: string; name: string; type: string; intervalSeconds: number; timeoutSeconds: number; enabled: boolean; followRedirects: boolean; verifyTls: boolean; revision: number };
+type ServiceStatus = { id: string; entityId?: string; sourceId?: string; name: string; description?: string; state: string; reason?: string; lastResultAt?: string; lastSuccessAt?: string; lastFailureAt?: string; responseTimeMs?: number; availabilityPercent?: number; sampleCount: number; successfulSampleCount: number; history?: ProbeResult[]; probes?: ProbeConfig[]; certificate?: ProbeCertificate };
+type ServiceSnapshot = { contractVersion: string; observedAt: string; capabilityState?: string; configurationState?: string; reason?: string; services: ServiceStatus[]; total: number };
+type ServiceDetailResponse = { service: ServiceStatus };
+type Dependency = { id: string; serviceId: string; dependsOnServiceId: string; sourceId?: string; relationType: string; confidence: number; confirmed: boolean };
+type DependencyResponse = { serviceId: string; dependencies: Dependency[] };
+type DependencyLoad = DependencyResponse & { available: boolean };
+
+type ViewState = 'loading' | 'ready' | 'empty' | 'unauthorized' | 'error';
+
+function navigate(path: string) {
+ window.history.pushState({}, '', path);
+ window.dispatchEvent(new PopStateEvent('popstate'));
+}
+
+function statusLabel(state: string): string {
+ switch (state) {
+ case 'up': return copy.services.up;
+ case 'degraded': return copy.services.degraded;
+ case 'down': return copy.services.down;
+ default: return copy.services.unknown;
+ }
+}
+
+function statusIcon(state: string): string {
+ switch (state) {
+ case 'up': return '✓';
+ case 'down': return '!';
+ case 'degraded': return '△';
+ default: return '?';
+ }
+}
+
+function StatusPill({ state }: { state: string }) {
+ const normalized = ['up', 'degraded', 'down', 'unknown'].includes(state) ? state : 'unknown';
+ return {statusIcon(normalized)} {statusLabel(normalized)} ;
+}
+
+function formatDate(value?: string): string {
+ return formatDateTime(value);
+}
+
+function formatLatency(value?: number): string {
+ return value === undefined || !Number.isFinite(value) ? copy.services.notAvailable : `${Math.max(0, Math.round(value))} ms`;
+}
+
+function formatAvailability(value?: number): string {
+ return value === undefined || !Number.isFinite(value) ? copy.services.notAvailable : `${value.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`;
+}
+
+function reasonLabel(reason?: string): string {
+ switch (reason) {
+ case 'stale_probe': return copy.services.reasons.stale;
+ case 'no_probe_result': return copy.services.reasons.noResult;
+ case 'no_probe_configured': return copy.services.reasons.noProbe;
+ case 'probes_disabled': return copy.services.reasons.disabled;
+ case 'no_services_configured': return copy.services.reasons.notConfigured;
+ case 'source_unavailable': return copy.services.reasons.unavailable;
+ case 'status_not_expected': return copy.services.reasons.status;
+ case 'transport_error': return copy.services.reasons.transport;
+ case 'response_too_large': return copy.services.reasons.bodyLimit;
+ case 'unsupported': return copy.services.reasons.unsupported;
+ case 'none': return copy.services.reasons.none;
+ default: return copy.services.reasons.none;
+ }
+}
+
+function errorLabel(result: ProbeResult): string {
+ return result.errorClass ? reasonLabel(result.errorClass) : result.state === 'up' ? copy.services.success : copy.services.reasons.none;
+}
+
+function latestCertificate(history: ProbeResult[] = []): ProbeCertificate | undefined {
+ return history.find((result) => result.certificate)?.certificate;
+}
+
+function certificateStateLabel(state?: string): string {
+ switch (state) {
+ case 'valid': return copy.services.certificate.valid;
+ case 'attention': return copy.services.certificate.attention;
+ case 'invalid': return copy.services.certificate.invalid;
+ default: return copy.services.unknown;
+ }
+}
+
+function daysUntil(value?: string): number | undefined {
+ if (!value) return undefined;
+ const time = new Date(value).getTime();
+ if (!Number.isFinite(time)) return undefined;
+ return Math.ceil((time - Date.now()) / 86400000);
+}
+
+function ServiceLoading() { return ; }
+function ServiceError({ unauthorized, retry }: { unauthorized: boolean; retry: () => void }) { return {unauthorized ? '!' : '×'} {unauthorized ? copy.services.unauthorizedTitle : copy.services.errorTitle} {unauthorized ? copy.services.unauthorizedDetail : copy.services.errorDetail}
{copy.services.retry} ; }
+
+function ServiceSummary({ snapshot }: { snapshot: ServiceSnapshot }) {
+ const state = snapshot.services.length === 0 || snapshot.services.every((item) => item.state === 'unknown') ? 'unknown' : snapshot.services.some((item) => item.state === 'down') ? 'down' : snapshot.services.some((item) => item.state === 'degraded') ? 'degraded' : 'up';
+ return {copy.services.source}
{snapshot.contractVersion || copy.services.unknown} {snapshot.reason ? reasonLabel(snapshot.reason) : copy.services.sourceDetail}
{copy.services.observed}: {formatDate(snapshot.observedAt)} · {snapshot.total} {copy.services.rows}
;
+}
+
+function ServiceMatrix({ services }: { services: ServiceStatus[] }) {
+ return {copy.services.matrix}
{copy.services.matrixTitle} {services.length} {copy.services.rows} {copy.services.readOnly}
;
+}
+
+function ServiceListPage({ snapshot, retry }: { snapshot: ServiceSnapshot; retry: () => void }) {
+ const unavailable = snapshot.capabilityState === 'unavailable';
+ return <>{copy.services.eyebrow}
{copy.services.title} {copy.services.intro}
{snapshot.services.length === 0 ? : }>;
+}
+
+function CertificateCard({ certificate }: { certificate?: ProbeCertificate }) {
+ if (!certificate) return
{copy.services.certificate.title} {copy.services.certificate.none}
;
+ const days = daysUntil(certificate.expiresAt);
+ const expiry = days === undefined ? copy.services.notAvailable : days < 0 ? copy.services.certificate.expired : `${days} ${days === 1 ? copy.services.certificate.day : copy.services.certificate.days}`;
+ return {copy.services.certificate.kicker}
{copy.services.certificate.title} {certificateStateLabel(certificate.verificationState)}
{copy.services.certificate.expires} {formatDate(certificate.expiresAt)}{expiry}
{copy.services.certificate.hostname} {certificate.hostnameValid === true ? copy.services.certificate.valid : copy.services.certificate.invalid}
{copy.services.certificate.issuer} {certificate.issuer || copy.services.notAvailable}
{copy.services.certificate.subject} {certificate.subject || copy.services.notAvailable} ;
+}
+
+function ProbeConfigCard({ probes }: { probes: ProbeConfig[] }) {
+ return {copy.services.config}
{copy.services.configTitle} {probes.length} {copy.services.probes} {copy.services.configCaption}
{probes.length === 0 ? {copy.services.noProbes}
: {copy.services.configCaption} {copy.services.probe} {copy.services.probeType} {copy.services.interval} {copy.services.timeout} {copy.services.tls} {copy.services.redirects} {copy.services.state} {probes.map((probe) => {probe.name}{probe.id} {copy.services.probeTypes[probe.type as keyof typeof copy.services.probeTypes] ?? probe.type} {probe.intervalSeconds} s {probe.timeoutSeconds} s {probe.verifyTls ? copy.services.yes : copy.services.no} {probe.followRedirects ? copy.services.yes : copy.services.no} {probe.enabled ? copy.services.enabled : copy.services.disabled} )}
} ;
+}
+function relationLabel(value: string): string {
+ switch (value) { case 'depends_on': return copy.services.dependsOn; case 'backs': return copy.services.backs; case 'exposes': return copy.services.exposes; default: return copy.services.relation; }
+}
+function DependencyCard({ dependencies, available }: { dependencies: Dependency[]; available: boolean }) {
+ const ordered = [...dependencies].sort((left, right) => left.dependsOnServiceId.localeCompare(right.dependsOnServiceId) || left.id.localeCompare(right.id));
+ return {copy.services.relations}
{copy.services.relationTitle} {available ? ordered.length : '—'} {copy.services.relationRows} {copy.services.relationCaption}
{!available ? {copy.services.relationsUnavailable}
: ordered.length === 0 ? {copy.services.noRelations}
: {copy.services.relationCaption} {copy.services.relation} {copy.services.upstream} {copy.services.source} {copy.services.confidence} {copy.services.confirmation} {ordered.map((dependency) => {relationLabel(dependency.relationType)}{dependency.id} {dependency.dependsOnServiceId} {dependency.sourceId || copy.services.manual} {Math.round(Math.max(0, Math.min(1, dependency.confidence)) * 100)}% {dependency.confirmed ? copy.services.confirmed : copy.services.inferred} )}
} ;
+}
+function ServiceHistory({ history }: { history: ProbeResult[] }) {
+ return {copy.services.history}
{copy.services.historyTitle} {history.length} {copy.services.samples} {history.length === 0 ? {copy.services.noHistory}
: {copy.services.historyCaption} {copy.services.observed} {copy.services.probe} {copy.services.state} {copy.services.latency} {copy.services.reason} {history.map((result, index) => {formatDate(result.observedAt)} {result.probeId} {formatLatency(result.responseTimeMs)} {errorLabel(result)} )}
} ;
+}
+
+function ServiceDetailPage({ service, dependencies, dependenciesAvailable, retry }: { service: ServiceStatus; dependencies: Dependency[]; dependenciesAvailable: boolean; retry: () => void }) {
+ const certificate = useMemo(() => service.certificate ?? latestCertificate(service.history), [service.certificate, service.history]);
+ return <>{copy.services.currentState}
{statusLabel(service.state)} {copy.services.reason}: {reasonLabel(service.reason)}
{copy.services.latency} {formatLatency(service.responseTimeMs)}
{copy.services.availability} {formatAvailability(service.availabilityPercent)}
{copy.services.lastSuccess} {formatDate(service.lastSuccessAt)}
{copy.services.lastFailure} {formatDate(service.lastFailureAt)} {copy.services.retry} {copy.services.technical} {copy.services.serviceId} {service.id} {copy.services.entityId} {service.entityId || copy.services.notAvailable} {copy.services.sourceId} {service.sourceId || copy.services.notAvailable} {copy.services.samples} {service.sampleCount} · {service.successfulSampleCount} {copy.services.successSamples} {copy.services.secretSafe}
>;
+}
+
+export function ServicePage({ id }: { id?: string }) {
+ const [state, setState] = useState('loading');
+ const [snapshot, setSnapshot] = useState(null);
+ const [service, setService] = useState(null);
+ const [dependencies, setDependencies] = useState([]);
+ const [dependenciesAvailable, setDependenciesAvailable] = useState(true);
+ const [reload, setReload] = useState(0);
+ useEffect(() => {
+ const controller = new AbortController();
+ setState('loading');
+ const endpoint = id ? '/api/v1/services/' + encodeURIComponent(id) : '/api/v1/services?limit=100';
+ const serviceRequest = fetch(endpoint, { signal: controller.signal }).then((response) => {
+ if (response.status === 401) throw new Error('unauthorized');
+ if (!response.ok) throw new Error('services');
+ return response.json() as Promise;
+ });
+ const dependencyRequest: Promise = id ? fetch('/api/v1/services/' + encodeURIComponent(id) + '/dependencies?limit=100', { signal: controller.signal }).then(async (response) => response.ok ? { ...(await response.json() as DependencyResponse), available: true } : { serviceId: id, dependencies: [], available: false }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') throw error; return { serviceId: id, dependencies: [], available: false }; }) : Promise.resolve({ serviceId: '', dependencies: [], available: true });
+ Promise.all([serviceRequest, dependencyRequest]).then(([data, dependencyData]) => {
+ if (id) {
+ const detail = data as ServiceDetailResponse;
+ if (!detail.service) throw new Error('services');
+ setService(detail.service);
+ setDependencies(dependencyData.dependencies ?? []);
+ setDependenciesAvailable(dependencyData.available);
+ setState('ready');
+ } else {
+ const list = data as ServiceSnapshot;
+ const normalized = { ...list, services: list.services ?? [] };
+ setSnapshot(normalized);
+ setState(normalized.services.length ? 'ready' : 'empty');
+ }
+ }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState(error instanceof Error && error.message === 'unauthorized' ? 'unauthorized' : 'error');
+ });
+ return () => controller.abort();
+ }, [id, reload]);
+ const retry = () => setReload((value) => value + 1);
+ if (state === 'loading') return ;
+ if (state === 'unauthorized' || state === 'error') return ;
+ if (id && service) return ;
+ if (!id && snapshot) return ;
+ return ;
+}
diff --git a/apps/web/src/SharePage.tsx b/apps/web/src/SharePage.tsx
new file mode 100644
index 0000000..4a4037c
--- /dev/null
+++ b/apps/web/src/SharePage.tsx
@@ -0,0 +1,14 @@
+import { formatDateTime } from './locale';
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { presentStoragePolicy } from './presentation';
+import { SourceStatusDetails } from './SourceStatusDetails';
+
+type Share = { id: string; name: string; storagePolicy: { allocation?: string; cachePolicy?: string; primaryPool?: string; cachePool?: string }; usedBytes: number; sizeObservedAt?: string; sizeState: string; placements?: Array<{ poolId: string; bytes: number }>; growthHistory?: Array<{ observedAt: string; usedBytes: number; deltaBytes: number; rateBytesPerDay: number }> };
+type Snapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; shares: Share[]; total: number; scan: { maxSharesPerRun: number; dueShareIds?: string[]; deferredCount: number; cacheTtlSeconds: number } };
+function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
+function date(value?: string): string { return formatDateTime(value); }
+function Badge({ label, ready }: { label: string; ready: boolean }) { return {ready ? '✓' : '?'} {label} ; }
+function ShareCard({ share }: { share: Share }) { return {presentStoragePolicy(share.storagePolicy.allocation, copy.shares.unknownPolicy)}
{bytes(share.usedBytes)} · {share.storagePolicy.cachePool || copy.shares.noCachePool} · {share.storagePolicy.primaryPool || copy.shares.noPrimaryPool}
{copy.shares.observed}: {date(share.sizeObservedAt)} · {share.growthHistory?.length || 0} {copy.shares.growthPoints}
; }
+function ShareDetail({ share, source }: { share: Share; source: Snapshot['source'] }) { const latest = share.growthHistory?.[share.growthHistory.length - 1]; return <>{copy.shares.source}
{source.id} {bytes(share.usedBytes)}
{copy.shares.policy}
{presentStoragePolicy(share.storagePolicy.allocation, copy.shares.unknownPolicy)} {presentStoragePolicy(share.storagePolicy.cachePolicy, copy.shares.unknownCachePolicy)}
{copy.shares.relation}
{share.storagePolicy.cachePool || copy.shares.noCachePool} {share.storagePolicy.primaryPool || copy.shares.noPrimaryPool}
{copy.shares.growth}
{latest ? (latest.rateBytesPerDay >= 0 ? '+' : '') + bytes(Math.abs(latest.rateBytesPerDay)) + '/d' : '—'} {share.growthHistory?.length || 0} {copy.shares.growthPoints}
{copy.shares.placement}
{copy.shares.placementTitle} {share.placements?.length ? {copy.shares.pool} {copy.shares.size} {share.placements.map((placement) => {placement.poolId} {bytes(placement.bytes)} )}
: {copy.shares.noPlacements}
}{copy.shares.growthHistory} {share.growthHistory?.length ? {copy.shares.observed} {copy.shares.size} {copy.shares.change} {share.growthHistory.map((point) => {date(point.observedAt)} {bytes(point.usedBytes)} {point.deltaBytes >= 0 ? '+' : ''}{bytes(Math.abs(point.deltaBytes))} )}
: {copy.shares.noGrowth}
}{copy.shares.readOnly}
>; }
+export function SharePage({ id }: { id?: string }) { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); useEffect(() => { const controller = new AbortController(); fetch(id ? '/api/v1/shares/' + encodeURIComponent(id) : '/api/v1/shares?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('shares'); return response.json() as Promise; }).then((data) => { setSnapshot(data); if (data.share) setSelected(data.share); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, [id]); if (state === 'loading') return ; if (state === 'error' || !snapshot) return × {copy.shares.errorTitle} {copy.shares.errorDetail}
; if (selected) return ; const fresh = snapshot.source?.freshness === 'fresh' && snapshot.source?.state !== 'unknown'; return <>{copy.shares.eyebrow}
{copy.shares.title} {copy.shares.intro}
{copy.shares.source}
{snapshot.source?.id || copy.shares.unknown} {snapshot.total} {copy.shares.rows}
{copy.shares.scan}: {snapshot.scan.deferredCount} {copy.shares.deferred}; {snapshot.scan.maxSharesPerRun} {copy.shares.perRun}
{snapshot.shares.length ? snapshot.shares.map((share) => ) : {copy.shares.empty}
} >; }
diff --git a/apps/web/src/SignIn.tsx b/apps/web/src/SignIn.tsx
new file mode 100644
index 0000000..ef28ded
--- /dev/null
+++ b/apps/web/src/SignIn.tsx
@@ -0,0 +1,43 @@
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { consumeAuthNoticeFromUrl, currentReturnPath, loginHref, onUnauthenticated, startLogin, type AuthNotice, type AuthNoticeKind } from './auth';
+
+const noticeCopy: Record = {
+ required: { title: copy.auth.requiredTitle, detail: copy.auth.requiredDetail, action: copy.auth.signIn },
+ expired: { title: copy.auth.expiredTitle, detail: copy.auth.expiredDetail, action: copy.auth.signInAgain },
+ failed: { title: copy.auth.failedTitle, detail: copy.auth.failedDetail, action: copy.auth.signInAgain },
+ cancelled: { title: copy.auth.cancelledTitle, detail: copy.auth.cancelledDetail, action: copy.auth.signIn },
+};
+// A login that came back broken outranks a plain 401 observed afterwards, so the
+// banner keeps explaining the actual failure instead of flipping to a generic
+// "please sign in".
+const noticeRank: Record = { required: 0, expired: 1, cancelled: 2, failed: 2 };
+
+/**
+ * Primary sign-in affordance. Rendered as a real anchor so that the browser's
+ * own keyboard, focus and "open in new tab" behaviour applies, while the click
+ * handler keeps the return path in sync with the page the user is actually on.
+ */
+export function SignInButton({ returnPath, label = copy.auth.signIn, variant = 'primary' }: { returnPath?: string; label?: string; variant?: 'primary' | 'secondary' }) {
+ const target = returnPath ?? currentReturnPath();
+ return { event.preventDefault(); startLogin(target); }}>{label} ;
+}
+
+/**
+ * Shell-level banner for authentication events that are not tied to one page: a
+ * 401 on any route, a session that expired mid-session, and a failed or
+ * cancelled login redirecting back into the app.
+ */
+export function AuthNoticeBanner() {
+ const [notice, setNotice] = useState(() => consumeAuthNoticeFromUrl());
+ const [dismissedKind, setDismissedKind] = useState(null);
+ useEffect(() => onUnauthenticated((next) => setNotice((current) => (current && noticeRank[current.kind] >= noticeRank[next.kind]) ? current : next)), []);
+ if (!notice || dismissedKind === notice.kind) return null;
+ const text = noticeCopy[notice.kind];
+ return
+
!
+
{text.title} {text.detail}
+
+
setDismissedKind(notice.kind)}>{copy.auth.dismiss}
+
;
+}
diff --git a/apps/web/src/SourceStatusDetails.tsx b/apps/web/src/SourceStatusDetails.tsx
new file mode 100644
index 0000000..7464226
--- /dev/null
+++ b/apps/web/src/SourceStatusDetails.tsx
@@ -0,0 +1,29 @@
+import { copy } from './copy';
+import { formatDateTime, hasReceivedTimestamp } from './locale';
+import { presentReason, presentStatus } from './presentation';
+
+export type SourceStatus = {
+ id?: string;
+ state?: string;
+ freshness?: string;
+ observedAt?: string;
+ reason?: string;
+};
+
+function dataLabel(source: SourceStatus): string {
+ if (!hasReceivedTimestamp(source.observedAt)) return copy.sourceStatus.neverReceived;
+ if (source.freshness === 'fresh' && source.state !== 'unknown' && source.state !== 'unavailable') return copy.sourceStatus.fresh;
+ if (source.freshness === 'stale') return copy.sourceStatus.stale;
+ return copy.sourceStatus.unavailable;
+}
+
+export function SourceStatusDetails({ source, fallbackReason, className = '' }: { source: SourceStatus; fallbackReason?: string; className?: string }) {
+ const reasonCode = source.reason?.trim();
+ const observed = formatDateTime(source.observedAt);
+ return
+
{copy.sourceStatus.status}: {presentStatus(source.state)}{copy.sourceStatus.data}: {dataLabel(source)}
+
{reasonCode ? presentReason(reasonCode) : fallbackReason || copy.presentation.reason.noDetail}
+
{copy.sourceStatus.observed}: {hasReceivedTimestamp(source.observedAt) ? {observed} : observed}
+ {reasonCode &&
{copy.sourceStatus.technical} {copy.sourceStatus.sourceId} {source.id || '—'} {copy.sourceStatus.reasonCode} {reasonCode}}
+
;
+}
diff --git a/apps/web/src/StoragePage.tsx b/apps/web/src/StoragePage.tsx
new file mode 100644
index 0000000..edaf122
--- /dev/null
+++ b/apps/web/src/StoragePage.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { StorageMapWidget, TemperatureHeatmap, type HeatmapPoint, type StorageMapNode } from './StorageVisuals';
+import { SourceStatusDetails, type SourceStatus } from './SourceStatusDetails';
+
+type Severity = 'normal' | 'attention' | 'critical' | 'unknown';
+type Disk = { id: string; name: string; role: string; state: string; utilizationPercent: number; capacitySeverity?: Severity; thermalSeverity?: Severity; temperature?: { celsius?: number; status: string; observedAt?: string } };
+type DiskSnapshot = { source: SourceStatus; disks: Disk[] };
+type Pool = { id: string; name: string; filesystem: string; state: string; utilizationPercent: number; capacitySeverity?: Severity };
+type PoolSnapshot = { source: SourceStatus; pools: Pool[] };
+type ArrayMember = { id: string; name: string; role: string; state: string };
+type ArraySnapshot = { source: SourceStatus; state: string; members: ArrayMember[] };
+export type StorageData = { array: ArraySnapshot; disks: DiskSnapshot; pools: PoolSnapshot };
+
+function availability(value: string): string { return value === 'online' || value === 'healthy' || value === 'operational' ? 'healthy' : value === 'unknown' ? 'unknown' : 'degraded'; }
+function visualSeverity(...values: Array): string {
+ if (values.includes('critical')) return 'critical';
+ if (values.some((value) => value === 'attention' || value === 'degraded' || value === 'faulted')) return 'degraded';
+ if (values.includes('unknown')) return 'unknown';
+ return 'healthy';
+}
+function signalLabel(value: string): string { return value === 'normal' || value === 'healthy' || value === 'online' ? 'normaal' : value === 'critical' ? 'kritiek' : value === 'attention' || value === 'degraded' ? 'aandacht' : 'onbekend'; }
+
+export function buildStorageNodes(data: StorageData): StorageMapNode[] {
+ const members = new Map((data.array.members ?? []).map((member) => [member.id.toLowerCase(), member]));
+ const diskNodes = (data.disks.disks ?? []).slice(0, 64).map((disk) => {
+ const member = members.get(disk.id.toLowerCase());
+ if (member) members.delete(disk.id.toLowerCase());
+ const capacity = disk.capacitySeverity ?? 'unknown';
+ const thermal = disk.thermalSeverity ?? disk.temperature?.status ?? 'unknown';
+ return { id: 'disk-' + disk.id, label: disk.name, kind: member?.role ?? disk.role, state: visualSeverity(availability(disk.state), capacity, thermal), detail: `Beschikbaarheid ${signalLabel(disk.state)} · capaciteit ${signalLabel(capacity)} · temperatuur ${signalLabel(thermal)}`, href: '/disks/' + encodeURIComponent(disk.id) };
+ });
+ const unmatchedMembers = [...members.values()].slice(0, 64).map((member) => ({ id: 'array-' + member.id, label: member.name, kind: member.role, state: availability(member.state), detail: `Beschikbaarheid ${signalLabel(member.state)} · disktelemetrie onbekend`, href: '/array' }));
+ const poolNodes = (data.pools.pools ?? []).slice(0, 64).map((pool) => {
+ const capacity = pool.capacitySeverity ?? 'unknown';
+ return { id: 'pool-' + pool.id, label: pool.name, kind: `pool · ${pool.filesystem}`, state: visualSeverity(availability(pool.state), capacity), detail: `Device-health ${signalLabel(pool.state)} · capaciteit ${signalLabel(capacity)} (${pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% gebruikt)`, href: '/pools/' + encodeURIComponent(pool.id) };
+ });
+ return [...unmatchedMembers, ...poolNodes, ...diskNodes];
+}
+
+export function StoragePage() {
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [data, setData] = useState(null);
+ useEffect(() => { const controller = new AbortController(); Promise.all([fetch('/api/v1/array', { signal: controller.signal }).then((response) => response.json() as Promise), fetch('/api/v1/disks?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise), fetch('/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise)]).then(([array, disks, pools]) => { setData({ array, disks, pools }); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
+ if (state === 'loading') return ;
+ if (state === 'error' || !data) return × {copy.storage.errorTitle} {copy.storage.errorDetail}
;
+ const nodes = buildStorageNodes(data);
+ const heatmap: HeatmapPoint[] = (data.disks.disks ?? []).slice(0, 64).map((disk) => ({ id: disk.id, label: disk.name, observedAt: disk.temperature?.observedAt || '', value: disk.temperature?.celsius ?? null, status: visualSeverity(disk.thermalSeverity ?? disk.temperature?.status ?? 'unknown'), href: '/disks/' + encodeURIComponent(disk.id) }));
+ return <>{copy.storage.eyebrow}
{copy.storage.title} {copy.storage.intro}
{copy.storage.source}
{copy.storage.sourceTitle} {copy.storage.accessible}
{copy.storage.arraySource} {copy.storage.diskSource} {copy.storage.poolSource} {nodes.length} {copy.storage.mapNodes} · {heatmap.length} {copy.storage.heatmapPoints}
>;
+}
diff --git a/apps/web/src/StorageVisuals.tsx b/apps/web/src/StorageVisuals.tsx
new file mode 100644
index 0000000..85045ad
--- /dev/null
+++ b/apps/web/src/StorageVisuals.tsx
@@ -0,0 +1,17 @@
+import { copy } from './copy';
+import { formatDateTime } from './locale';
+import { presentStatus } from './presentation';
+export type StorageMapNode = { id: string; label: string; kind: string; state: string; detail?: string; href?: string };
+export type HeatmapPoint = { id: string; label: string; observedAt: string; value: number | null; status: string; href?: string };
+
+function StateText({ state }: { state: string }) { return {presentStatus(state)} ; }
+
+export function StorageMapWidget({ nodes, title, description, idPrefix = 'storage-map' }: { nodes: StorageMapNode[]; title: string; description: string; idPrefix?: string }) {
+ const bounded = nodes.slice(0, 128);
+ return {copy.storage.mapKicker}
{title} {description}
{copy.storage.accessibleSummary} ({bounded.length} items) {copy.storage.entity} {copy.storage.type} {copy.storage.status} {copy.storage.detail} {bounded.map((node) => {node.href ? {node.label} : node.label} {node.kind} {presentStatus(node.state)} {node.detail || '—'} )}
;
+}
+
+export function TemperatureHeatmap({ points, title, description, idPrefix = 'temperature-heatmap' }: { points: HeatmapPoint[]; title: string; description: string; idPrefix?: string }) {
+ const bounded = points.slice(0, 256);
+ return {copy.storage.heatmap}
{title} {description}
{bounded.map((point) => {point.value == null ? '?' : Math.round(point.value)} )}
{copy.storage.accessibleSummary} en tabelalternatief ({bounded.length} metingen) {copy.storage.disk} {copy.storage.observed} {copy.storage.temperature} {copy.storage.status} {bounded.map((point) => {point.href ? {point.label} : point.label} {formatDateTime(point.observedAt)} {point.value == null ? 'Onbekend' : point.value + ' °C'} {presentStatus(point.status)} )}
;
+}
diff --git a/apps/web/src/SystemStatusPage.tsx b/apps/web/src/SystemStatusPage.tsx
new file mode 100644
index 0000000..05b9acb
--- /dev/null
+++ b/apps/web/src/SystemStatusPage.tsx
@@ -0,0 +1,47 @@
+import { useState } from 'react';
+import { copy } from './copy';
+import { plural, presentComponent, presentReason } from './presentation';
+import { formatDateTime } from './locale';
+import { backupPresentation, refreshSystemStatus, systemStateLabel as label, useSystemStatus } from './systemStatus';
+
+function Badge({ state }: { state: string }) {
+ const ready = state === 'healthy';
+ return {ready ? '✓' : '?'} {label(state)} ;
+}
+
+function age(seconds?: number): string {
+ if (seconds == null || !Number.isFinite(seconds)) return copy.systemStatus.notAvailable;
+ if (seconds < 60) return `${Math.max(0, Math.round(seconds))} ${copy.systemStatus.seconds}`;
+ if (seconds < 3600) return `${Math.round(seconds / 60)} ${copy.systemStatus.minutes}`;
+ if (seconds < 86400) { const hours = Math.round(seconds / 3600); return `${hours} ${plural(hours, copy.systemStatus.hourAgo, copy.systemStatus.hoursAgo)}`; }
+ const days = Math.round(seconds / 86400);
+ return `${days} ${plural(days, copy.systemStatus.dayAgo, copy.systemStatus.daysAgo)}`;
+}
+
+export function SystemStatusPage() {
+ const { state, status } = useSystemStatus();
+ const [backupAction, setBackupAction] = useState<'idle' | 'creating' | 'created' | 'error'>('idle');
+ if (state === 'loading') return {copy.systemStatus.loading} ;
+ if (state === 'unauthorized') return {copy.systemStatus.unauthorized} {copy.systemStatus.unauthorizedDetail}
;
+ if (state === 'forbidden') return {copy.systemStatus.forbidden} {copy.systemStatus.forbiddenDetail}
;
+ if (state === 'error' || !status) return {copy.systemStatus.errorTitle} {copy.systemStatus.errorDetail}
;
+ const release = status.release;
+ const backup = backupPresentation(status.backup);
+ const createBackup = async () => {
+ setBackupAction('creating');
+ try {
+ const response = await fetch('/api/v1/system/backups', { method: 'POST' });
+ if (!response.ok) throw new Error('backup create failed');
+ setBackupAction('created');
+ refreshSystemStatus();
+ } catch {
+ setBackupAction('error');
+ }
+ };
+ return <>
+
+ {copy.systemStatus.current}
{label(status.overallState)} {copy.systemStatus.version}: {release?.version || status.version} · {copy.systemStatus.commit}: {release?.commit || copy.systemStatus.notAvailable} · {copy.systemStatus.migration}: {release?.migrationVersion || copy.systemStatus.notAvailable}
{copy.systemStatus.built}: {release?.builtAt ? formatDateTime(release.builtAt) : copy.systemStatus.notAvailable} · {copy.systemStatus.generated}: {formatDateTime(status.generatedAt)}
+ {copy.systemStatus.components}
{copy.systemStatus.componentTitle} {status.components.map((component) => {presentComponent(component.id)} {presentReason(component.reason)} )}
+ {copy.systemStatus.backup}
{presentReason(backup.reason)} · {age(backup.ageSeconds)}{backup.verifiedAt ? ` · ${copy.systemStatus.verified}: ${formatDateTime(backup.verifiedAt)}` : ''}
{copy.systemStatus.backupFreshness}
void createBackup()}>{backupAction === 'creating' ? copy.systemStatus.creatingBackup : copy.systemStatus.createBackup} {backupAction === 'created' && {copy.systemStatus.backupCreated}
}{backupAction === 'error' && {copy.systemStatus.backupCreateFailed}
}{copy.systemStatus.sourceLag}
{status.sourceLag.length} {plural(status.sourceLag.length, copy.systemStatus.source, copy.systemStatus.sources)} {status.sourceLag.length === 0 ? {copy.systemStatus.noSources}
: {status.sourceLag.map((source) => {presentComponent(source.sourceId)} : {label(source.state)} · {age(source.ageSeconds)} )} }
+ >;
+}
diff --git a/apps/web/src/TopologyPage.tsx b/apps/web/src/TopologyPage.tsx
new file mode 100644
index 0000000..cf77ef1
--- /dev/null
+++ b/apps/web/src/TopologyPage.tsx
@@ -0,0 +1,100 @@
+import { useEffect, useMemo, useState } from 'react';
+import { copy } from './copy';
+
+type TopologyNode = { id: string; label: string; state: string; reason?: string; known: boolean; kind?: string; sourceId?: string };
+type TopologyEdge = { id: string; from: string; to: string; relationType: string; sourceId?: string; confidence: number; confirmed: boolean; inferred: boolean };
+export type TopologyData = { contractVersion: string; observedAt: string; capabilityState?: string; configurationState?: string; reason?: string; nodes: TopologyNode[]; edges: TopologyEdge[]; totalNodes: number; totalEdges: number; truncated: boolean };
+
+type ViewState = 'loading' | 'error' | 'unauthorized' | 'ready';
+
+function statusLabel(state: string): string {
+ if (state === 'up') return copy.services.up;
+ if (state === 'degraded') return copy.services.degraded;
+ if (state === 'down') return copy.services.down;
+ return copy.services.unknown;
+}
+
+function relationLabel(relation: string): string {
+ if (relation === 'backs') return copy.services.backs;
+ if (relation === 'exposes') return copy.services.exposes;
+ return copy.services.dependsOn;
+}
+
+function edgeClass(edge: TopologyEdge): string {
+ return edge.confirmed ? 'topology-edge topology-edge--confirmed' : 'topology-edge topology-edge--inferred';
+}
+
+function nodeClass(node: TopologyNode): string {
+ return node.known ? 'topology-node topology-node--known' : 'topology-node topology-node--unknown';
+}
+
+function ServiceLink({ node }: { node: TopologyNode }) {
+ if (!node.known) return {node.label} {copy.topology.nodeUnknown} ;
+ if (node.kind === 'reverse_proxy') return {node.label} {copy.topology.reverseProxy} · {node.sourceId || copy.topology.sourceUnknown} ;
+ return {node.label} {statusLabel(node.state)} · {node.id} ;
+}
+
+export function TopologyWidget({ topology, compact = false }: { topology: TopologyData; compact?: boolean }) {
+ const nodes = [...topology.nodes].sort((left, right) => left.id.localeCompare(right.id));
+ const edges = [...topology.edges].sort((left, right) => left.id.localeCompare(right.id));
+ const nodeByID = new Map(nodes.map((node) => [node.id, node]));
+ return
+ {!compact && {copy.topology.widgetKicker}
{nodes.length} {copy.topology.nodes} · {edges.length} {copy.topology.edges} }
+ {!compact && {copy.topology.noCausality}
}
+
+
+ {copy.topology.nodesTitle}
+ {nodes.length === 0 ? {copy.topology.noNodes}
: }
+
+
+ {copy.topology.edgesTitle}
+ {edges.length === 0 ? {copy.topology.noEdges}
: {edges.map((edge) => {
+ const from = nodeByID.get(edge.from);
+ const to = nodeByID.get(edge.to);
+ return {from?.label ?? edge.from} → {to?.label ?? edge.to}
{relationLabel(edge.relationType)} {Math.round(edge.confidence * 100)}% {edge.confirmed ? copy.services.confirmed : copy.services.inferred} {edge.sourceId || copy.services.manual}
;
+ })} }
+
+
+ ;
+}
+
+export function TopologyPage() {
+ const [state, setState] = useState('loading');
+ const [topology, setTopology] = useState(null);
+ const [reload, setReload] = useState(0);
+ const [query, setQuery] = useState('');
+ const [stateFilter, setStateFilter] = useState('all');
+ const [relationFilter, setRelationFilter] = useState('all');
+ useEffect(() => {
+ const controller = new AbortController();
+ setState('loading');
+ fetch('/api/v1/topology?limit=100', { signal: controller.signal }).then((response) => {
+ if (!response.ok) throw new Error(String(response.status));
+ return response.json() as Promise;
+ }).then((data) => { setTopology({ ...data, nodes: data.nodes ?? [], edges: data.edges ?? [] }); setState('ready'); }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ const status = error instanceof Error ? Number(error.message) : 0;
+ setState(status === 401 ? 'unauthorized' : 'error');
+ });
+ return () => controller.abort();
+ }, [reload]);
+ const filtered = useMemo(() => {
+ if (!topology) return null;
+ const normalized = query.trim().toLowerCase();
+ const nodes = topology.nodes.filter((node) => {
+ const textMatch = !normalized || (node.label + ' ' + node.id).toLowerCase().includes(normalized);
+ return textMatch && (stateFilter === 'all' || node.state === stateFilter);
+ });
+ const nodeIDs = new Set(nodes.map((node) => node.id));
+ const edges = topology.edges.filter((edge) => nodeIDs.has(edge.from) && nodeIDs.has(edge.to) && (relationFilter === 'all' || edge.relationType === relationFilter));
+ return { ...topology, nodes, edges };
+ }, [topology, query, stateFilter, relationFilter]);
+ if (state === 'loading') return ;
+ if (state === 'unauthorized') return ! {copy.topology.unauthorizedTitle} {copy.topology.unauthorizedDetail}
;
+ if (state === 'error' || !topology || !filtered) return × {copy.topology.errorTitle} {copy.topology.errorDetail}
setReload((value) => value + 1)}>{copy.topology.retry} ;
+ if (topology.nodes.length === 0) {
+ const unavailable = topology.capabilityState === 'unavailable';
+ return <>{copy.topology.eyebrow}
{copy.topology.title} {copy.topology.intro}
{unavailable ? copy.topology.unavailable : copy.topology.notConfigured} {unavailable ? copy.topology.unavailableDetail : copy.topology.notConfiguredDetail}
{unavailable ? setReload((value) => value + 1)}>{copy.topology.retry} : {copy.topology.configure} } >;
+ }
+ return <>{copy.topology.eyebrow}
{copy.topology.title} {copy.topology.intro}
{topology.truncated && {copy.topology.truncated}
}>;
+}
diff --git a/apps/web/src/WidgetConfigDrawer.tsx b/apps/web/src/WidgetConfigDrawer.tsx
new file mode 100644
index 0000000..41df049
--- /dev/null
+++ b/apps/web/src/WidgetConfigDrawer.tsx
@@ -0,0 +1,103 @@
+import { copy } from './copy';
+import type { EditorWidget } from './DashboardEditor';
+
+export type ValidationErrors = Record;
+export type PreviewResult = {
+ type: string;
+ title: string;
+ state: string;
+ message: string;
+ limits?: { maxSeries: number; maxPoints: number; maxRows: number; requestedRows: number; appliedRows: number };
+ sample?: Record;
+};
+
+type Props = {
+ widget: EditorWidget;
+ errors: ValidationErrors;
+ preview: PreviewResult | null;
+ previewState: string;
+ previewing: boolean;
+ previewError: string;
+ viewportLabel: string;
+ width: number;
+ maxWidth: number;
+ onChange: (widget: EditorWidget) => void;
+ onWidthChange: (width: number) => void;
+ onPreviewStateChange: (state: string) => void;
+ onPreview: () => void;
+};
+
+const configCopy = copy.editor.config;
+
+function record(value: Record | undefined): Record {
+ return value ?? {};
+}
+function text(value: unknown, fallback = ''): string {
+ return typeof value === 'string' ? value : fallback;
+}
+function number(value: unknown, fallback = ''): string {
+ return typeof value === 'number' ? String(value) : fallback;
+}
+function fieldId(name: string): string {
+ return 'widget-config-' + name.replaceAll('.', '-');
+}
+
+export function WidgetConfigDrawer({ widget, errors, preview, previewState, previewing, previewError, viewportLabel, width, maxWidth, onChange, onWidthChange, onPreviewStateChange, onPreview }: Props) {
+ const data = record(widget.data);
+ const visualization = record(widget.visualization);
+ const behavior = record(widget.behavior);
+ const sourceType = text(data.sourceType, 'inventory');
+ const error = (name: string) => errors[name] ? {errors[name]}
: null;
+ const update = (section: 'root' | 'data' | 'visualization' | 'behavior', name: string, value: unknown) => {
+ if (section === 'root') onChange({ ...widget, [name]: value });
+ else onChange({ ...widget, [section]: { ...record(widget[section]), [name]: value } });
+ };
+ const invalid = Object.keys(errors).length > 0;
+
+ return
+ {configCopy.kicker}
+
+ {configCopy.intro}
+
+
+
{configCopy.general}
+ {configCopy.title} update('root', 'title', event.target.value)} />
+ {error('title')}
+ {configCopy.description}
+
+
+
+
{configCopy.data}
+
{configCopy.source} update('data', 'sourceType', event.target.value)}>{configCopy.sourceSemanticMetric} {configCopy.sourceInventory} {configCopy.sourceEvents} {configCopy.sourceAlerts} {configCopy.sourceIncidents} {configCopy.sourceText}
+ {error('data.sourceType')}
+ {sourceType === 'semantic-metric' && <>
{configCopy.metric} update('data', 'metric', event.target.value)} /> {error('data.metric')}
{configCopy.range} update('data', 'range', event.target.value)}>{configCopy.rangeLive} {configCopy.range15m} {configCopy.range1h} {configCopy.range6h} {configCopy.range24h} {configCopy.range7d} {configCopy.aggregation} update('data', 'aggregation', event.target.value)}>{configCopy.aggregationAvg} {configCopy.aggregationMin} {configCopy.aggregationMax} {configCopy.aggregationSum} {configCopy.aggregationLast}
{error('data.range')}{error('data.aggregation')}>}
+
{configCopy.limit} update('data', 'limit', Number(event.target.value))} />
+ {error('data.limit')}
+
+
+
+
+
+
{configCopy.layout}
+
{configCopy.width} ({viewportLabel}) onWidthChange(Number(event.target.value))} />
+
{configCopy.widthHint}
+
+
+
+
+
+
{configCopy.preview}
+
{configCopy.previewState} onPreviewStateChange(event.target.value)}>{configCopy.previewStateLoading} {configCopy.previewStateEmpty} {configCopy.previewStateError} {configCopy.previewStateStale}
+
{previewing ? configCopy.previewLoading : configCopy.previewRefresh}
+ {previewError &&
{previewError}
}
+ {preview &&
{preview.message} {configCopy.limitsPrefix}{preview.limits?.maxSeries ?? 0}{configCopy.limitsSeries}{preview.limits?.maxPoints ?? 0}{configCopy.limitsPoints}{preview.limits?.appliedRows ?? 0}{configCopy.limitsRows}
}
+
+ ;
+}
diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts
new file mode 100644
index 0000000..46d8ec6
--- /dev/null
+++ b/apps/web/src/auth.ts
@@ -0,0 +1,130 @@
+// Frontend half of the OIDC sign-in flow.
+//
+// The backend exposes `GET /auth/login` (302 to the identity provider) and
+// `GET /auth/callback` (302 back into the app). Sign-in is therefore a full
+// document navigation, never a fetch: a redirect to a third-party identity
+// provider cannot be followed by XHR.
+export const LOGIN_PATH = '/auth/login';
+// Query parameter used to hand the backend a relative in-app path to return to.
+export const RETURN_PARAM = 'redirect';
+// Query parameters the backend may set when it redirects back after a failed or
+// cancelled authorization. `error` is the OAuth 2.0 / OIDC standard name.
+export const ERROR_PARAMS = ['reason', 'error'] as const;
+
+export type AuthNoticeKind = 'required' | 'expired' | 'failed' | 'cancelled';
+export type AuthNotice = { kind: AuthNoticeKind; returnPath: string };
+
+const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
+
+/**
+ * Reduces an arbitrary value to a safe, relative, in-app path. Anything that
+ * could leave the origin (absolute URLs, protocol-relative `//host`, backslash
+ * variants, control characters) collapses to the overview route.
+ */
+export function safeReturnPath(value: string | null | undefined): string {
+ if (typeof value !== 'string' || value === '') return '/';
+ const candidate = value.trim();
+ if (!candidate.startsWith('/')) return '/';
+ if (candidate.startsWith('//') || candidate.startsWith('/\\')) return '/';
+ if (CONTROL_CHARACTERS.test(candidate)) return '/';
+ return candidate;
+}
+
+/** The current in-app location, including query and hash, as a safe return path. */
+export function currentReturnPath(): string {
+ if (typeof window === 'undefined') return '/';
+ return safeReturnPath(window.location.pathname + window.location.search + window.location.hash);
+}
+
+/** Absolute-path href for the backend login entry point. */
+export function loginHref(returnPath: string = currentReturnPath()): string {
+ const target = safeReturnPath(returnPath);
+ return target === '/' ? LOGIN_PATH : LOGIN_PATH + '?' + RETURN_PARAM + '=' + encodeURIComponent(target);
+}
+
+/** Leaves the SPA and hands control to the backend login endpoint. */
+export function startLogin(returnPath: string = currentReturnPath()): void {
+ if (typeof window === 'undefined') return;
+ window.location.assign(loginHref(returnPath));
+}
+
+function noticeKind(raw: string): AuthNoticeKind {
+ const value = raw.toLowerCase();
+ if (value === 'access_denied' || value === 'cancelled' || value === 'canceled' || value === 'user_cancelled') return 'cancelled';
+ return 'failed';
+}
+
+/**
+ * Reads a failed/cancelled-login marker left in the URL by `/auth/callback` and
+ * removes it again, so a refresh or a shared link does not resurrect the notice.
+ */
+export function consumeAuthNoticeFromUrl(): AuthNotice | null {
+ if (typeof window === 'undefined') return null;
+ const params = new URLSearchParams(window.location.search);
+ const present = ERROR_PARAMS.find((name) => (params.get(name) ?? '') !== '');
+ if (!present) return null;
+ const kind = noticeKind(params.get(present) ?? '');
+ ERROR_PARAMS.forEach((name) => params.delete(name));
+ const query = params.toString();
+ window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '') + window.location.hash);
+ return { kind, returnPath: currentReturnPath() };
+}
+
+type SessionListener = (notice: AuthNotice) => void;
+const sessionListeners = new Set();
+let sawAuthenticatedResponse = false;
+let watcherInstalled = false;
+let sessionRevoked = false;
+const sessionController = new AbortController();
+
+/** Subscribe to authentication failures observed on any API call. */
+export function onUnauthenticated(listener: SessionListener): () => void {
+ sessionListeners.add(listener);
+ return () => { sessionListeners.delete(listener); };
+}
+
+function emitUnauthenticated(): void {
+ // A 401 before any successful API call is an unauthenticated first visit; a
+ // 401 after one is a session that expired while the user was working.
+ const notice: AuthNotice = { kind: sawAuthenticatedResponse ? 'expired' : 'required', returnPath: currentReturnPath() };
+ [...sessionListeners].forEach((listener) => listener(notice));
+}
+
+function isSameOriginAPIRequest(input: RequestInfo | URL): boolean {
+ if (typeof window === 'undefined') return false;
+ const raw = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
+ const url = new URL(raw, window.location.href);
+ return url.origin === window.location.origin && (url.pathname === '/api' || url.pathname.startsWith('/api/'));
+}
+
+function revokeSession(): void {
+ if (sessionRevoked) return;
+ sessionRevoked = true;
+ sessionController.abort();
+ emitUnauthenticated();
+}
+
+export function apiRequestInit(input: RequestInfo | URL, init?: RequestInit): RequestInit | undefined {
+ if (!isSameOriginAPIRequest(input)) return init;
+ const signal = init?.signal ? AbortSignal.any([init.signal, sessionController.signal]) : sessionController.signal;
+ return { ...init, cache: 'no-store', signal };
+}
+
+/**
+ * Observes API responses in one place so that a 401 on any page can offer a
+ * sign-in affordance without every page having to know about authentication.
+ * Responses are passed through untouched; only the notification is added.
+ */
+export function installSessionWatcher(): void {
+ if (watcherInstalled || typeof window === 'undefined' || typeof window.fetch !== 'function') return;
+ watcherInstalled = true;
+ const original = window.fetch.bind(window);
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ if (!isSameOriginAPIRequest(input)) return original(input, init);
+ if (sessionRevoked) return new Response(null, { status: 401, statusText: 'Unauthorized' });
+ const response = await original(input, apiRequestInit(input, init));
+ if (response.status === 401) revokeSession();
+ else if (response.ok) sawAuthenticatedResponse = true;
+ return response;
+ };
+}
diff --git a/apps/web/src/copy.ts b/apps/web/src/copy.ts
new file mode 100644
index 0000000..ea8cc11
--- /dev/null
+++ b/apps/web/src/copy.ts
@@ -0,0 +1,881 @@
+export const copy = {
+ brand: {
+ name: 'ITWorx Pulse',
+ context: 'Observability voor Unraid',
+ },
+ navigation: {
+ label: 'Hoofdnavigatie',
+ groups: {
+ command: 'Commando',
+ infrastructure: 'Infrastructuur',
+ workloads: 'Workloads',
+ services: 'Services',
+ response: 'Respons',
+ manage: 'Beheer',
+ },
+ overview: 'Overzicht',
+ host: 'Host',
+ array: 'Array',
+ disks: 'Disks',
+ pools: 'Pools',
+ shares: 'Shares',
+ storage: 'Opslagkaart',
+ capacity: 'Capaciteitsprognose',
+ processes: 'Processen',
+ containers: 'Containers',
+ services: 'Services',
+ topology: 'Topologie',
+ network: 'Netwerk',
+ applications: 'Applicaties',
+ inventory: 'Inventaris',
+ dashboards: 'Dashboards', wallboard: 'Wallboard',
+ alerts: 'Meldingen',
+ events: 'Gebeurtenissen',
+ incidents: 'Incidenten',
+ settings: 'Instellingen', status: 'Systeemstatus',
+ onboarding: 'Eerste configuratie', more: 'Meer', mobileStorage: 'Opslag',
+ },
+ events: {
+ eyebrow: 'Operationele tijdlijn', title: 'Gebeurtenissen', intro: 'Bekijk de meest recente, begrensde wijzigingen uit alle aangesloten bronnen.',
+ timeline: 'Gebeurtenistijdlijn', latest: 'Meest recente signalen', severity: 'Ernst', type: 'Soort', allSeverities: 'Alle ernstniveaus', allTypes: 'Alle soorten', allEntities: 'Alle onderdelen',
+ search: 'Zoeken', searchPlaceholder: 'Samenvatting, soort, bron of onderdeel', clearFilters: 'Filters wissen', clearEmpty: 'Alle filters wissen', summary: 'Samenvatting van gebeurtenissen', loaded: 'Geladen', loadedDetail: 'meest recente gebeurtenissen', criticalSummary: 'Kritieke gebeurtenissen', showCritical: 'Toon alleen kritieke resultaten', noCritical: 'Geen kritieke resultaten in deze set', results: 'Resultaten', of: 'van', pagination: 'Paginering van gebeurtenissen', page: 'Pagina', previous: 'Vorige pagina', next: 'Volgende pagina', attention: 'Aandacht',
+ refresh: 'Vernieuwen', loading: 'Gebeurtenissen worden geladen…', errorTitle: 'Gebeurtenissen niet beschikbaar', errorDetail: 'De gebeurtenistijdlijn kon niet veilig worden geladen.', empty: 'Geen gebeurtenissen binnen de huidige filters.',
+ occurred: 'Opgetreden', received: 'Ontvangen', source: 'Bron', entity: 'Onderdeel', technical: 'Technische brongegevens', noSource: 'Bron niet vermeld', noEntity: 'Onderdeel niet vermeld',
+ info: 'Informatie', warning: 'Waarschuwing', critical: 'Kritiek', unknown: 'Onbekend', rows: 'gebeurtenissen (maximaal 100)',
+ },
+ notFound: {
+ context: 'Pagina niet gevonden', eyebrow: 'Navigatiefout', title: 'Deze pagina bestaat niet', detail: 'Het gevraagde adres hoort niet bij een Pulse-onderdeel. Er is niets gewijzigd.', home: 'Naar overzicht', inventory: 'Open inventaris',
+ },
+ topology: {
+ eyebrow: 'Relatietopologie', title: 'Service-topology', intro: 'Bekijk begrensde service-relaties met status, bron en confidence. Een relatie is een aanwijzing, geen bewezen causaliteit.', widgetKicker: 'Topology-widget', widgetTitle: 'Relaties en services', nodes: 'nodes', edges: 'relaties', nodesTitle: 'Services en endpoints', edgesTitle: 'Relaties', nodeUnknown: 'Niet in actuele service-snapshot', reverseProxy: 'Reverse proxy-route', sourceUnknown: 'Bron onbekend', noNodes: 'Geen nodes binnen de huidige filters.', noEdges: 'Geen relaties binnen de huidige filters.', noCausality: 'Pijlen tonen geregistreerde of afgeleide relaties; ze bewijzen niet welke storing de oorzaak is.', filters: 'Topologyfilters', search: 'Zoeken', searchPlaceholder: 'Naam of service-ID', stateFilter: 'Status', relationFilter: 'Relatietype', allStates: 'Alle statussen', allRelations: 'Alle relatietypes', truncated: 'De graph is begrensd; verfijn de filters voor meer detail.', filterCount: 'gefilterd', loading: 'Topology wordt geladen…', errorTitle: 'Topology niet beschikbaar', errorDetail: 'De topologygegevens konden niet veilig worden geladen.', unauthorizedTitle: 'Geen toegang tot topology', unauthorizedDetail: 'Je hebt geen rechten om topologygegevens te bekijken.', retry: 'Opnieuw laden', notConfigured: 'Topology nog niet geconfigureerd', notConfiguredDetail: 'Voeg eerst een bewaakte service en veilige probe toe. Relaties uit service- en inventorybronnen verschijnen daarna automatisch.', unavailable: 'Topologybron niet beschikbaar', unavailableDetail: 'De relationele bron kon niet worden gelezen. Er wordt geen lege topology verondersteld.', configure: 'Open eerste configuratie',
+ },
+ network: {
+ eyebrow: 'Netwerkgezondheid', title: 'Netwerk', intro: 'Bekijk interne interfaces, gateway, DNS, internetbereikbaarheid en certificaten als afzonderlijke signalen.', widgetKicker: 'Netwerkstatus', widgetTitle: 'Afzonderlijke netwerkbronnen', internal: 'Intern netwerk', gateway: 'Gateway', dns: 'DNS', internet: 'Internet', up: 'Beschikbaar', degraded: 'Aandacht', down: 'Niet beschikbaar', unknown: 'Onbekend', notConfigured: 'Niet geconfigureerd', unavailable: 'Bron niet beschikbaar', unsupported: 'Niet ondersteund', notConfiguredDetail: 'Voor dit signaal is nog geen veilige probe geconfigureerd.', unavailableDetail: 'De bron voor dit signaal is niet beschikbaar.', unsupportedDetail: 'Deze capability wordt door de actieve runtime niet ondersteund.', staleDetail: 'De laatste meting is verouderd en wordt niet als actueel beschouwd.', noReason: 'Geen aanvullende reden.', separateSignals: 'Internet-, DNS-, gateway- en hostsignalen worden afzonderlijk getoond; een internetstoring betekent niet automatisch dat de host defect is.', source: 'Bron', freshness: 'Bronversheid', interfaces: 'Netwerkinterfaces', interface: 'Interface', interfaceCaption: 'RX/TX-counters en fout- en dropcounters per interface.', errors: 'Fouten (RX/TX)', drops: 'Drops (RX/TX)', noInterfaces: 'Geen betrouwbare interfacegegevens beschikbaar.', certificates: 'Certificaten', noCertificates: 'Geen certificaatinventaris beschikbaar.', hostnameValid: 'Hostname geldig', hostnameInvalid: 'Hostname ongeldig', events: 'Netwerkgebeurtenissen', noEvents: 'Geen netwerkgebeurtenissen geregistreerd.', notAvailable: 'Niet beschikbaar', loading: 'Netwerkgezondheid wordt geladen…', errorTitle: 'Netwerkgezondheid niet beschikbaar', errorDetail: 'De netwerkgegevens konden niet veilig worden geladen.', retry: 'Opnieuw laden',
+ }, services: {
+ eyebrow: 'Servicemonitoring',
+ title: 'Services',
+ intro: 'Bekijk bereikbaarheid, probehistorie en TLS-signalen van bewaakte services.',
+ source: 'Bronstatus',
+ sourceDetail: 'Status afgeleid uit recente probes; containerstatus is niet bepalend.',
+ observed: 'Geobserveerd', rows: 'services', matrix: 'Service matrix', matrixTitle: 'Bereikbaarheid en historie', matrixCaption: 'Overzicht van service-status, latency, beschikbaarheid en probefouten.',
+ name: 'Service', state: 'Status', latency: 'Latency', availability: 'Beschikbaarheid', lastSuccess: 'Laatste succes', lastFailure: 'Laatste fout', reason: 'Reden',
+ currentState: 'Huidige status', detailEyebrow: 'Servicedetail', detailIntro: 'Alleen-lezen detail van bereikbaarheid en recente controles.', back: 'Terug naar services',
+ relations: 'Relaties', relationTitle: 'Service-afhankelijkheden', relationCaption: 'Bron, betrouwbaarheid en bevestigingsstatus blijven zichtbaar voor iedere relatie.', relationRows: 'relaties', relation: 'Relatie', upstream: 'Afhankelijk van', confidence: 'Betrouwbaarheid', confirmation: 'Herkomst', manual: 'Handmatig', confirmed: 'Bevestigd', inferred: 'Afgeleid', dependsOn: 'Afhankelijk van', backs: 'Ondersteunt', exposes: 'Exposeert', noRelations: 'Geen service-afhankelijkheden geregistreerd.',
+ config: 'Probeconfiguratie', configTitle: 'Veilige probe-instellingen', configCaption: 'Alleen operationele instellingen worden getoond; targets en credentials blijven verborgen.', probes: 'probes', probeType: 'Type', interval: 'Interval', timeout: 'Timeout', tls: 'TLS-validatie', redirects: 'Redirects', yes: 'Ja', no: 'Nee', enabled: 'Ingeschakeld', disabled: 'Uitgeschakeld', noProbes: 'Geen actieve probeconfiguratie beschikbaar.',
+ probeTypes: { http: 'HTTPS-bereikbaarheid', tls: 'TLS-certificaat', tcp: 'TCP-verbinding', dns: 'DNS-resolutie', icmp: 'ICMP-bereikbaarheid' },
+ history: 'Probehistorie', historyTitle: 'Laatste probe-resultaten', historyCaption: 'Recente probe-resultaten met status, latency en veilige foutclassificatie.', samples: 'metingen', probe: 'Probe', noHistory: 'Geen probehistorie beschikbaar.', success: 'Probe geslaagd',
+ unknown: 'Onbekend', up: 'Beschikbaar', degraded: 'Aandacht', down: 'Niet beschikbaar', notAvailable: 'Niet beschikbaar', loading: 'Services worden geladen…', errorTitle: 'Services niet beschikbaar', errorDetail: 'De servicegegevens konden niet worden geladen.', unauthorizedTitle: 'Geen toegang', unauthorizedDetail: 'Je hebt geen rechten om servicegegevens te bekijken.', retry: 'Opnieuw laden', empty: 'Nog geen services geconfigureerd', emptyDetail: 'Voeg een bewaakte service met een veilige probe toe. Pulse voert alleen controles uit binnen het ingestelde netwerkbeleid.', configure: 'Open eerste configuratie', unavailable: 'Servicebron niet beschikbaar', unavailableDetail: 'De servicecatalogus kon niet worden gelezen; dit is geen lege configuratie.', relationsUnavailable: 'De relationele bron is niet beschikbaar; Pulse veronderstelt daarom niet dat er geen afhankelijkheden zijn.',
+ readOnly: 'Servicegegevens zijn alleen-lezen; Pulse voert geen infrastructuuracties uit.', technical: 'Technische details', serviceId: 'Service-ID', entityId: 'Entity-ID', sourceId: 'Bron-ID', successSamples: 'geslaagde metingen', secretSafe: 'Probecredentials en geheime waarden worden nooit in deze weergave getoond.',
+ reasons: { stale: 'De laatste probe is verouderd; status is Onbekend.', noResult: 'De probe is geconfigureerd, maar heeft nog geen resultaat.', noProbe: 'Voor deze service is nog geen probe geconfigureerd.', disabled: 'Alle probes voor deze service zijn uitgeschakeld.', notConfigured: 'Er zijn nog geen services geconfigureerd.', unavailable: 'De servicebron is niet beschikbaar.', status: 'De verwachte status kwam niet terug.', transport: 'De probe kon het doel niet betrouwbaar bereiken.', bodyLimit: 'De response was groter dan de ingestelde limiet.', unsupported: 'Deze probe-capability is niet beschikbaar.', none: 'Geen aanvullende reden.' },
+ certificate: { kicker: 'TLS-bewaking', title: 'TLS-certificaat', none: 'Geen TLS-certificaat waargenomen.', valid: 'Geldig', attention: 'Binnenkort aandacht nodig', invalid: 'Ongeldig', expires: 'Verloopt', hostname: 'Hostname-validatie', issuer: 'Uitgever', subject: 'Onderwerp', expired: 'verlopen', day: 'dag', days: 'dagen' },
+ },
+ containers: {
+ eyebrow: 'Containerinventaris',
+ title: 'Containers',
+ intro: 'Bekijk een begrensde, alleen-lezen momentopname van containers, resources en status. Pulse voert geen containeracties uit.',
+ source: 'Bron',
+ unknown: 'Onbekend',
+ available: 'Beschikbaar',
+ readOnly: 'Containerstatus is observatie; starten, stoppen en verwijderen zijn niet beschikbaar.',
+ loading: 'Containers worden geladen…',
+ errorTitle: 'Containers niet beschikbaar',
+ errorDetail: 'De containermomentopname kon niet worden geladen.',
+ retry: 'Opnieuw laden',
+ rows: 'containers',
+ limitNote: '25 containers per bereikbare pagina',
+ search: 'Zoeken', searchPlaceholder: 'Naam, project of image', filterState: 'Runtime-state', filterHealth: 'Healthstatus', all: 'Alle', sort: 'Sortering', sortName: 'Naam', sortCPU: 'CPU', sortMemory: 'Geheugen', sortState: 'Status', previous: 'Vorige pagina', next: 'Volgende pagina', page: 'Pagina', mobileList: 'Containerkaarten',
+ list: 'Containerlijst',
+ top: 'Overzicht',
+ name: 'Naam',
+ state: 'Runtime-state',
+ health: 'Health',
+ resources: 'Resources',
+ metricsUnavailable: 'metingen niet beschikbaar',
+ image: 'Image',
+ intentionalStop: 'Bewust gestopt',
+ restarts: 'herstarts',
+ noProject: 'geen project',
+ noDigest: 'digest niet beschikbaar',
+ detailEyebrow: 'Containerdetail',
+ detailIntro: 'Status, freshness en technische metadata van deze container.',
+ backToList: 'Terug naar containers',
+ openDashboard: 'Open in dashboards',
+ observed: 'Geobserveerd',
+ fresh: 'Actueel',
+ stale: 'Verouderd of niet beschikbaar',
+ metrics: 'Containermetingen',
+ technical: 'Technische metadata',
+ digest: 'Image-digest',
+ exitCode: 'Exitcode',
+ network: 'Netwerk',
+ blockIO: 'Block I/O',
+ ports: 'Poorten',
+ noData: 'Geen aanvullende gegevens beschikbaar.',
+ empty: 'Geen betrouwbare containergegevens beschikbaar.',
+ }, array: {
+ eyebrow: 'Arraymonitoring',
+ title: 'Array en parity',
+ intro: 'Bekijk arraystatus, parity en controlehistorie. Pulse biedt geen arrayacties aan.',
+ source: 'Bron',
+ observed: 'Geobserveerd',
+ fresh: 'Actueel',
+ stale: 'Verouderd of niet beschikbaar',
+ unknown: 'Onbekend',
+ operational: 'Operationeel',
+ degraded: 'Aandacht',
+ missing: 'Ontbrekend',
+ readOnly: 'Alle informatie is alleen-lezen; starten, stoppen, controleren en corrigeren zijn niet beschikbaar.',
+ loading: 'Arraygegevens worden geladen…',
+ errorTitle: 'Arraygegevens niet beschikbaar',
+ errorDetail: 'De arraymomentopname kon niet worden geladen.',
+ metrics: 'Arraymetingen',
+ parity: 'Parity',
+ present: 'Aanwezig',
+ notPresent: 'Niet aanwezig',
+ errors: 'fouten',
+ members: 'Leden',
+ membersTitle: 'Arrayleden',
+ notOperational: 'niet operationeel',
+ noMembers: 'Geen betrouwbare arrayleden beschikbaar.',
+ name: 'Naam',
+ role: 'Rol',
+ state: 'Status',
+ capacity: 'Capaciteit',
+ io: 'I/O',
+ currentCheck: 'Lopende parity-check',
+ progress: 'Voortgang',
+ speed: 'Snelheid',
+ completed: 'Voltooid',
+ history: 'Parity-checkhistorie',
+ noHistory: 'Geen parity-checkhistorie beschikbaar.',
+ }, disks: {
+ eyebrow: 'Diskmonitoring',
+ title: 'Disks',
+ intro: 'Bekijk capaciteit, filesystem, inodes en privacybewuste diskidentiteit. Pulse voert geen diskacties uit.',
+ detailEyebrow: 'Diskdetail',
+ detailIntro: 'Capaciteit, gebruik en technische identiteit van deze disk.',
+ source: 'Bron',
+ observed: 'Geobserveerd',
+ fresh: 'Actueel',
+ stale: 'Verouderd of niet beschikbaar',
+ available: 'Beschikbaar',
+ unknown: 'Onbekend',
+ readOnly: 'Diskinformatie is alleen-lezen; formatteren, koppelen en verwijderen zijn niet beschikbaar.',
+ loading: 'Disks worden geladen…',
+ errorTitle: 'Disks niet beschikbaar',
+ errorDetail: 'De diskinventaris kon niet worden geladen.',
+ back: 'Terug naar disks',
+ rows: 'disks',
+ cards: 'Diskkaarten',
+ metrics: 'Diskmetingen',
+ identity: 'Identiteit',
+ capacity: 'Capaciteit',
+ used: 'gebruikt',
+ usedOf: 'van',
+ free: 'vrij',
+ inodes: 'Inodes',
+ filesystem: 'Filesystem',
+ serial: 'Serienummer',
+ name: 'Naam',
+ noFilesystem: 'filesystem onbekend',
+ noModel: 'model onbekend',
+ noSerial: 'serienummer verborgen',
+ empty: 'Geen betrouwbare diskgegevens beschikbaar.',
+ technical: 'Technische metadata',
+ smartAvailable: 'Beschikbaar',
+ smartUnknown: 'Onbekend',
+ smartUnavailable: 'SMART-gegevens zijn niet beschikbaar of verouderd.',
+ smartNoReasons: 'Geen aanvullende SMART-redenen.',
+ selfTest: 'Self-test',
+ attribute: 'Attribuut',
+ state: 'Status',
+ value: 'Waarde',
+ telemetry: 'Disktelemetrie',
+ temperature: 'Temperatuur',
+ spin: 'Spinstatus',
+ performance: 'Prestaties',
+ read: 'Lezen',
+ write: 'Schrijven',
+ historyPoints: 'historische meetpunten',
+ unsupported: 'Niet ondersteund',
+ history: 'Missing-diskhistorie',
+ missingTitle: 'Eerder ontbrekende disks',
+ missing: 'Ontbrekend',
+ noMissingHistory: 'Geen missing-diskhistorie beschikbaar.',
+ }, pools: {
+ eyebrow: 'Poolmonitoring',
+ title: 'Pools',
+ intro: 'Bekijk cache-, Btrfs- en ZFS-pools met capaciteit, leden, redundancy en scrubstatus. Pulse voert geen poolacties uit.',
+ detailEyebrow: 'Pooldetail',
+ detailIntro: 'Capaciteit, filesystem, leden, fouten en scrubresultaten van deze pool.',
+ source: 'Bron', observed: 'Geobserveerd', fresh: 'Actueel', stale: 'Verouderd of niet beschikbaar', available: 'Beschikbaar', unknown: 'Onbekend',
+ loading: 'Poolgegevens worden geladen…', errorTitle: 'Poolgegevens niet beschikbaar', errorDetail: 'De poolmomentopname kon niet worden geladen.', back: 'Terug naar pools', rows: 'pools', cards: 'Poolkaarten', metrics: 'Poolmetingen', capacity: 'Capaciteit', used: 'gebruikt', usedOf: 'van', free: 'vrij', profile: 'Profiel', redundancy: 'Redundantie', noProfile: 'profiel onbekend', noRedundancy: 'redundantie onbekend', healthy: 'Gezond', degraded: 'Aandacht', faulted: 'Defect', readOnly: 'Alle poolinformatie is alleen-lezen; controleren, repareren en wijzigen zijn niet beschikbaar.', empty: 'Geen betrouwbare poolgegevens beschikbaar.', capabilities: 'Mogelijkheden', capabilityTitle: 'Ondersteunde onderdelen', members: 'Leden', memberTitle: 'Poolleden', noMembers: 'Geen betrouwbare poolleden beschikbaar.', role: 'Rol', name: 'Naam', state: 'Status', errors: 'Fouten', filesystemErrors: 'Filesystemfouten', performance: 'Prestaties', ssdWear: 'SSD-slijtage', moverSignals: 'Mover-signalen', unsupported: 'Niet ondersteund', scrub: 'Scrub', noScrub: 'Scrubstatus niet beschikbaar', poolErrorTitle: 'Filesystem- en poolfouten', noErrors: 'Geen poolfouten gemeld.', history: 'Scrubhistorie', noHistory: 'Geen scrubhistorie beschikbaar.', noResult: 'resultaat onbekend', attention: 'Aandacht', completed: 'Voltooid' }, shares: {
+ eyebrow: 'Sharemonitoring', title: 'Shares', intro: 'Bekijk sharegebruik, opslagbeleid, poolrelaties en begrensde groeihistorie. Pulse somt geen bestandsinhoud op.', detailEyebrow: 'Sharedetail', detailIntro: 'Gebruik, opslagbeleid, plaatsing en groeihistorie van deze share.', source: 'Bron', observed: 'Geobserveerd', fresh: 'Actueel', stale: 'Verouderd of niet beschikbaar', available: 'Beschikbaar', unknown: 'Onbekend', loading: 'Sharegegevens worden geladen…', errorTitle: 'Sharegegevens niet beschikbaar', errorDetail: 'De sharemomentopname kon niet worden geladen.', back: 'Terug naar shares', rows: 'shares', cards: 'Sharekaarten', metrics: 'Sharemetingen', policy: 'Opslagbeleid', relation: 'Cache/pool-relatie', placement: 'Plaatsing', placementTitle: 'Opslagplaatsing', pool: 'Pool', size: 'Grootte', growth: 'Groei per dag', growthHistory: 'Groeihistorie', growthPoints: 'groeipunten', change: 'Verandering', noGrowth: 'Geen groeihistorie beschikbaar.', noPlacements: 'Geen opslagplaatsing beschikbaar.', unknownPolicy: 'beleid onbekend', unknownCachePolicy: 'cachebeleid onbekend', noCachePool: 'geen cachepool', noPrimaryPool: 'geen primaire pool', readOnly: 'Share-informatie is alleen-lezen; er worden geen bestanden geopend, opgesomd, verplaatst of gewijzigd.', empty: 'Geen betrouwbare sharegegevens beschikbaar.', scan: 'Scanplanning', deferred: 'uitgesteld', perRun: 'maximaal per run' }, storage: {
+ eyebrow: 'Opslagkaart', title: 'Opslagoverzicht', intro: 'Verbind array, pools en schijven in één leesbare kaart met tekstuele status en temperatuurhistorie.', source: 'Bronstatus', sourceTitle: 'Databruikbaarheid per opslagbron', arraySource: 'Array', diskSource: 'Schijven', poolSource: 'Pools', loading: 'Storagekaart wordt geladen…', errorTitle: 'Storagekaart niet beschikbaar', errorDetail: 'De opslaggegevens konden niet worden samengebracht.', readOnly: 'De opslagweergave is alleen-lezen.', accessible: 'Status gebruikt altijd tekst en iconen; kleur is nooit de enige betekenis.', mapNodes: 'maponderdelen', heatmapPoints: 'temperatuurmetingen', mapTitle: 'Array, pools en disks', mapDescription: 'Klik door naar een entiteitsdetail. De tekstuele tabel onder de kaart blijft beschikbaar als alternatief voor de visualisatie.', heatmapTitle: 'Schijftemperatuurhistorie', heatmapDescription: 'Temperatuurpunten blijven begrensd en tonen status naast de kleurindicatie.', mapKicker: 'Opslagkaart', mapNodesLabel: 'Onderdelen van de opslagkaart', accessibleSummary: 'Tekstalternatief', entity: 'Entiteit', type: 'Type', status: 'Status', detail: 'Detail', heatmap: 'Temperatuurkaart', disk: 'Schijf', observed: 'Geobserveerd', temperature: 'Temperatuur' }, capacity: {
+ eyebrow: 'Capaciteitsplanning', title: 'Capaciteitsprognose', intro: 'Bekijk een voorzichtige, begrensde prognose op basis van historische gebruiksgroei. Pulse toont geen datum wanneer de data daarvoor onvoldoende betrouwbaar is.', policy: 'Prognosebeleid', enabled: 'Ingeschakeld', disabled: 'Uitgeschakeld', method: 'Methode', window: 'Venster', minimum: 'Minimumaantal punten', points: 'Datapunten', current: 'Gebruik / capaciteit', rate: 'Groei', projected: 'Verwacht bereikt', qualified: 'Prognose is gekwalificeerd binnen de getoonde methode en het venster.', readOnly: 'Alleen-lezen', loading: 'Capaciteitsprognoses worden geladen…', errorTitle: 'Capaciteitsprognoses niet beschikbaar', errorDetail: 'De prognosemomentopname kon niet worden geladen.', observed: 'Gegenereerd', items: 'gekwalificeerde prognoses', assessments: 'beoordeelde shares', cards: 'Capaciteitsprognoses', day: 'dag', days: 'dagen', unknown: 'Onbekend', emptyTitle: 'Nog geen bruikbare capaciteitsprognose', empty: 'Er zijn nog geen shares met voldoende betrouwbare groeihistorie.', openShares: 'Bekijk shares en groeihistorie', share: 'Share', linearMedian: 'Mediane dagelijkse groei', insufficient: 'Onvoldoende data', high: 'Hoge betrouwbaarheid', medium: 'Gemiddelde betrouwbaarheid', low: 'Lage betrouwbaarheid', none: 'Nog niet gekwalificeerd', insufficientPoints: 'Er zijn minder historische metingen dan het ingestelde minimum.', insufficientSpan: 'De historie beslaat nog geen volledige minimumperiode.', historyStale: 'De groeihistorie is verouderd; er wordt geen prognosedatum getoond.', historyUnavailable: 'De groeihistorie is niet beschikbaar.', sourceUnavailable: 'De opslagbron is niet beschikbaar.', noEntities: 'Er zijn nog geen shares met capaciteitsmetingen.', capacityUnknown: 'De beschikbare opslagcapaciteit is onbekend of al bereikt.', noGrowth: 'Er is geen positieve groei om betrouwbaar door te rekenen.', bulkImport: 'Een bulkimport vertekent de groei; een precieze datum wordt onderdrukt.', irregular: 'De meetintervallen zijn te onregelmatig voor een precieze datum.', disabledByPolicy: 'Capaciteitsprognoses zijn uitgeschakeld door beleid.' }, processes: {
+ eyebrow: 'Processverkenner',
+ title: 'Topprocessen',
+ intro: 'Bekijk een begrensde momentopname van CPU- en geheugengebruik. Pulse toont geen command-line, environment of besturingsacties.',
+ source: 'Bron',
+ unknown: 'Onbekend',
+ privacy: 'Procesnamen zijn privacy-minimaal; besturingsacties zijn niet beschikbaar.',
+ loading: 'Processen worden geladen…',
+ errorTitle: 'Processen niet beschikbaar',
+ errorDetail: 'De procesmomentopname kon niet worden geladen.',
+ retry: 'Opnieuw laden',
+ rows: 'processen',
+ limitNote: '25 processen per bereikbare pagina',
+ search: 'Zoeken', searchPlaceholder: 'Procesnaam', containerFilter: 'Container', containerPlaceholder: 'Containernaam', previous: 'Vorige pagina', next: 'Volgende pagina', page: 'Pagina', mobileList: 'Proceskaarten',
+ list: 'Proceslijst',
+ top: 'Top-N',
+ sort: 'Sortering',
+ cpu: 'CPU',
+ memory: 'Geheugen',
+ name: 'Naam',
+ container: 'Container',
+ empty: 'Geen betrouwbare procesgegevens beschikbaar.',
+ }, host: {
+ eyebrow: 'Hostmonitoring',
+ title: 'Hostdetails',
+ intro: 'Bekijk de actuele hostidentiteit, resourcegebruik en brongezondheid. Ontbrekende of verouderde telemetrie blijft Onbekend.',
+ identity: 'Hostidentiteit',
+ source: 'Bron',
+ observed: 'Geobserveerd',
+ fresh: 'Actueel',
+ stale: 'Verouderd of niet beschikbaar',
+ unknown: 'Onbekend',
+ noIdentityDetails: 'Geen aanvullende identiteitsgegevens.',
+ loading: 'Hosttelemetrie wordt geladen…',
+ errorTitle: 'Hosttelemetrie niet beschikbaar',
+ errorDetail: 'De hostdetails konden niet worden geladen. Probeer het opnieuw.',
+ retry: 'Opnieuw laden',
+ metrics: 'Hostmetingen',
+ uptime: 'Uptime',
+ boot: 'Opgestart:',
+ noBoot: 'Boot-tijd niet beschikbaar.',
+ cpu: 'CPU-gebruik',
+ cores: 'cores',
+ load: 'Load 1 minuut',
+ memory: 'Geheugen',
+ available: 'beschikbaar',
+ time: 'Tijdgezondheid',
+ synchronized: 'Gesynchroniseerd',
+ notSynchronized: 'Niet gesynchroniseerd',
+ filesystems: 'Filesystems',
+ network: 'Netwerkinterfaces',
+ mount: 'Mount',
+ used: 'Gebruik',
+ inodes: 'Inodes',
+ interface: 'Interface',
+ errors: 'Fouten/drops',
+ noData: 'Geen betrouwbare gegevens beschikbaar.',
+ warnings: 'Bronwaarschuwingen',
+ hardware: 'Optionele hardware',
+ sensors: 'sensoren',
+ sensor: 'Sensor',
+ temperature: 'Temperatuur',
+ gpu: 'GPU',
+ device: 'Apparaat',
+ gpuUsage: 'Gebruik',
+ gpuMemory: 'Geheugen',
+ noSensors: 'Geen temperatuursensoren beschikbaar.',
+ noGpu: 'Geen GPU-capability beschikbaar.',
+ noReason: 'geen reden opgegeven',
+ },
+ applications: {
+ eyebrow: 'Applicatiemonitoring',
+ title: 'Applicaties',
+ intro: 'Bekijk geaggregeerde applicatiestatus met de bijdragende componenten en redenen.',
+ detailEyebrow: 'Applicatiedetail',
+ detailIntro: 'Status, bronversheid en componentbijdragen van deze applicatie.',
+ source: 'Bron',
+ fresh: 'Actueel',
+ stale: 'Verouderd of niet beschikbaar',
+ unknown: 'Onbekend',
+ healthy: 'Gezond',
+ degraded: 'Aandacht',
+ loading: 'Applicaties worden geladen…',
+ errorTitle: 'Applicaties niet beschikbaar',
+ errorDetail: 'De applicatiegegevens konden niet worden geladen.',
+ backToList: 'Terug naar applicaties',
+ openDashboard: 'Open in dashboards',
+ rows: 'applicaties',
+ list: 'Applicatielijst',
+ overview: 'Overzicht',
+ components: 'componenten',
+ critical: 'kritiek',
+ optional: 'optioneel',
+ overridden: 'handmatig aangepast',
+ discovered: 'ontdekt',
+ empty: 'Geen betrouwbare applicatiegegevens beschikbaar.',
+ }, overview: {
+ eyebrow: 'Operationeel overzicht',
+ title: 'Alles onder controle',
+ attentionTitle: 'Aandacht vereist',
+ unknownTitle: 'Status nog niet bevestigd',
+ intro: 'Bekijk de toestand van je infrastructuur. Ontbrekende of verouderde telemetrie blijft zichtbaar als Onbekend.',
+ systemStatus: 'Systeemstatus',
+ systemStatusDetail: 'De Pulse-basis is klaar voor configuratie.',
+ unknown: 'Onbekend',
+ unknownDetail: 'Er zijn nog geen databronnen verbonden.',
+ partialDetail: 'Databronnen zijn verbonden, maar minstens één vereist signaal is nog onbekend.',
+ nextStep: 'Volgende stap',
+ nextStepDetail: 'Verbind een bron om live status en historische trends te zien.',
+ statusLoading: 'Systeemstatus wordt geladen…',
+ statusLoadingDetail: 'De toestand blijft Onbekend tot een betrouwbare meting binnenkomt.',
+ healthyDetail: 'Alle bewaakte componenten melden een gezonde status.',
+ degradedDetail: 'Minstens één component vraagt aandacht.',
+ disabledDetail: 'Bewaking is uitgeschakeld; de toestand blijft Onbekend.',
+ unavailableDetail: 'De systeemstatus kon niet worden geladen; de toestand blijft Onbekend.',
+ staleDetail: 'De laatste systeemstatus is verouderd; de toestand blijft Onbekend.',
+ unauthorizedDetail: 'Meld je aan om de actuele systeemstatus te zien.',
+ forbiddenDetail: 'Je account heeft geen toegang tot de actuele systeemstatus.',
+ problems: 'Aandachtspunten',
+ noProblems: 'Geen gemelde aandachtspunten.',
+ generated: 'Laatst bijgewerkt',
+ retry: 'Opnieuw laden',
+ openStatus: 'Bekijk systeemstatus',
+ sourceLag: 'Bronversheid',
+ filterActive: 'Filter',
+ liveError: 'Live: ',
+ liveConnecting: 'Live verbinden…',
+ livePoints: 'Live punten',
+ metricLoading: 'Metric wordt geladen…',
+ metricUnavailable: 'Metric niet beschikbaar.',
+ metricPrefix: 'Metric',
+ noMetric: 'Geen metric geselecteerd',
+ clearFilter: 'Filter wissen',
+ metrics: 'Kernmetingen',
+ cpu: 'CPU-belasting',
+ memory: 'Geheugengebruik',
+ storage: 'Hoogste poolgebruik',
+ services: 'Services beschikbaar',
+ available: 'beschikbaar',
+ actionQueue: 'Actiewachtrij',
+ signalPathKicker: 'Operationele signaalketen',
+ signalPathTitle: 'Signaalpad',
+ signalPathIntro: 'Volg de actuele observatieketen van bron tot incident. Selecteer een stap voor bronstatus, kernmetingen en directe verdieping.',
+ signalPathStages: 'Stappen in het operationele signaalpad',
+ signalPathSelected: 'Geselecteerde stap',
+ signalPathState: 'Status',
+ signalPathOpen: 'Open',
+ signalPathDisclaimer: 'De lijn toont gegevensstroom en operationele afhankelijkheid; zij bewijst geen oorzaak van een incident.',
+ signalPathLoading: 'Wordt geladen',
+ signalPathUnavailable: 'Niet beschikbaar',
+ signalPathUnauthorized: 'Aanmelden vereist',
+ signalPathForbidden: 'Geen toegang',
+ signalPathPartial: 'Gedeeltelijke data',
+ signalPathNotConfigured: 'Niet geconfigureerd',
+ signalPathNoWorkloads: 'Geen workloads',
+ signalPathSourcesDetail: 'Pulse combineert alleen begrensde, alleen-lezen databronnen. Verouderde of ontbrekende telemetrie blijft zichtbaar als onbekend.',
+ signalPathHostDetail: 'Hostbelasting wordt beoordeeld met actuele CPU-, geheugen- en bronversheidssignalen.',
+ signalPathStorageDetail: 'De zwaarst belaste pool en de ernstigste operationele opslagtoestand bepalen deze stap.',
+ signalPathWorkloadsDetail: 'Workloads tonen de verhouding tussen actieve containers en containers die aandacht nodig hebben.',
+ signalPathServicesDetail: 'Servicebeschikbaarheid blijft afzonderlijk van hostgezondheid zodat bereikbaarheid niet wordt verondersteld.',
+ signalPathIncidentsDetail: 'Open incidenten groeperen signalen met zichtbare onzekerheid; correlatie is geen bewezen causaliteit.',
+ signalStorage: 'Opslag',
+ signalWorkloads: 'Workloads',
+ signalIncidents: 'Incidenten',
+ sources: 'Bronnen',
+ connectedSources: 'Gezonde bronnen',
+ freshness: 'Versheid',
+ memoryShort: 'Geheugen',
+ activeContainers: 'actieve containers',
+ openIncidents: 'open incidenten',
+ noOpenIncidents: 'Geen',
+ total: 'Totaal',
+ highestSeverity: 'Ernstigst',
+ loadingResources: 'Operationele bronnen worden geladen…',
+ resourceLoadingDetail: 'Deze overzichtsbron wordt geladen; er wordt nog geen toestand verondersteld.',
+ resourceUnavailableDetail: 'Deze overzichtsbron kon niet worden geladen. Probeer opnieuw of open de detailpagina voor broninformatie.',
+ resourceUnauthorizedDetail: 'Meld je opnieuw aan om deze overzichtsbron te lezen.',
+ resourceForbiddenDetail: 'Je account heeft geen toegang tot deze overzichtsbron. De toestand blijft Onbekend.',
+ resourcePartialDetail: 'De veilige paginalimiet is bereikt. De getoonde waarde is een ondergrens; open de detailpagina voor verdere inspectie.',
+ resourceStaleDetail: 'De laatst bekende waarden worden niet als actueel getoond omdat de bron verouderd of onbekend is.',
+ noTelemetry: 'Nog geen betrouwbare telemetrie',
+ noPools: 'Er zijn geen opslagpools in de actuele momentopname.',
+ storagePools: 'Opslagpools',
+ capacity: 'Capaciteit en toestand',
+ viewAll: 'Alles bekijken',
+ workloads: 'Workloads',
+ running: 'actief',
+ other: 'overig',
+ containersRunning: 'containers zijn actief',
+ recentIncidents: 'Recente incidenten',
+ noIncidents: 'Er zijn geen open incidenten geregistreerd.',
+ poolCapacityCritical: 'Maak ruimte vrij of breid de pool uit; de kritieke capaciteitsgrens is overschreden.',
+ poolCapacityAttention: 'Plan capaciteitsruimte voordat de pool de kritieke grens bereikt.',
+ },
+ dashboards: {
+ eyebrow: 'Dashboards',
+ title: 'Jouw dashboards',
+ intro: 'Maak straks een gericht overzicht voor operatie, capaciteit of een wallboard.',
+ empty: 'Er zijn nog geen dashboards geconfigureerd.',
+ action: 'Dashboard toevoegen',
+ catalog: 'Dashboardcatalogus',
+ listTitle: 'Beschikbare dashboards',
+ loading: 'Dashboards worden geladen…',
+ ready: 'Gereed',
+ unknown: 'Onbekend',
+ available: 'dashboards beschikbaar',
+ unnamed: 'Naamloos dashboard',
+ version: 'Versie',
+ personal: 'Persoonlijk',
+ shared: 'Gedeeld',
+ unauthorizedTitle: 'Aanmelden vereist',
+ unauthorizedDetail: 'Meld je aan om je dashboards te bekijken.',
+ errorTitle: 'Dashboards niet beschikbaar',
+ errorDetail: 'De dashboardcatalogus kon niet worden geladen.',
+ retry: 'Opnieuw laden',
+ emptyDetail: 'Maak een dashboard aan of wacht tot een systeemdashboard beschikbaar is.',
+ viewMode: 'Weergavemodus',
+ back: 'Terug naar dashboards',
+ noDescription: 'Geen beschrijving beschikbaar.',
+ controls: 'Dashboardfilters',
+ timeRange: 'Periode',
+ filter: 'Widgetfilter',
+ filterPlaceholder: 'Filter op widgetnaam',
+ live: 'Live',
+ minutes: 'minuten',
+ hour: 'uur',
+ hours: 'uur',
+ days: 'dagen',
+ fixedView: 'Weergave is vastgezet; bewerken is niet actief.',
+ edit: 'Bewerken',
+ noMatchingWidgets: 'Geen widgets gevonden',
+ clearFilterHint: 'Pas het filter aan om widgets te tonen.',
+ widgetCollection: 'Dashboardwidgets',
+ widgetError: 'Widget niet beschikbaar',
+ widgetErrorDetail: 'Deze widget kon niet worden weergegeven. Andere widgets blijven beschikbaar.',
+ noData: 'Nog geen telemetrie',
+ dataPending: 'Wacht op een betrouwbare bronmeting',
+ filterActive: 'Filter',
+ liveError: 'Live: ',
+ liveConnecting: 'Live verbinden…',
+ livePoints: 'Live punten',
+ metricLoading: 'Metric wordt geladen…',
+ metricUnavailable: 'Metric niet beschikbaar.',
+ metricPrefix: 'Metric',
+ noMetric: 'Geen metric geselecteerd',
+ clearFilter: 'Filter wissen',
+ liveUnavailable: 'verbinding niet beschikbaar.',
+ points: 'punten',
+ widgetsWithData: 'widgets met bruikbare brondata',
+ runtime: {
+ current: 'Actueel', loading: 'Laden…', empty: 'Geen data', error: 'Bronfout',
+ sourceUnavailable: 'Bron niet beschikbaar', telemetryLoading: 'Telemetrie laden…', noCurrentData: 'Geen actuele gegevens',
+ retryDetail: 'De widget blijft begrensd verversen zodra de bron data levert.',
+ unavailableDetail: 'De bron kon niet veilig worden gelezen.', checkedComponents: 'gecontroleerde componenten',
+ semanticMetric: 'Semantische metriek', events: 'Gebeurtenissen', inventory: 'Inventaris',
+ },
+ },
+ widgets: {
+ stat: 'Kengetal',
+ timeseries: 'Tijdreeks',
+ gauge: 'Meter',
+ rankedList: 'Ranglijst',
+ statusGrid: 'Statusoverzicht',
+ table: 'Tabel',
+ heatmap: 'Heatmap',
+ eventTimeline: 'Gebeurtenistijdlijn',
+ storageMap: 'Opslagkaart',
+ topology: 'Topologie',
+ network: 'Netwerkgezondheid',
+ serviceMatrix: 'Servicematrix',
+ alertSummary: 'Meldingssamenvatting',
+ text: 'Notitie',
+ queryInspector: 'Query-inspecteur',
+ unknown: 'Widget',
+ },
+ metrics: {
+ fresh: 'Actueel',
+ delayed: 'Vertraagd',
+ stale: 'Verouderd',
+ unavailable: 'Niet beschikbaar',
+ loading: 'Metric wordt geladen…',
+ connecting: 'Live-verbinding wordt opgebouwd…',
+ noMetric: 'Geen metric geselecteerd.',
+ noReliableData: 'Geen betrouwbare meetpunten beschikbaar.',
+ dataGap: 'Datagat',
+ gapDescription: 'Er is minstens één datagat zichtbaar.',
+ gapNotice: 'Datagat of ontbrekende meetpunten gedetecteerd.',
+ sourceStatus: 'Bronstatus',
+ notHealthy: 'De waarde wordt niet als gezond geïnterpreteerd.',
+ exportCsv: 'Exporteer CSV',
+ legend: 'Legenda',
+ chartTitle: 'Tijdreeks voor',
+ metric: 'meting',
+ totalSeries: 'Totaal',
+ summary: 'Tekstuele samenvatting per reeks',
+ series: 'Reeks',
+ latest: 'Laatste',
+ count: 'Aantal',
+ minimum: 'Minimum',
+ maximum: 'Maximum',
+ average: 'Gemiddeld',
+ fixedRange: 'Vast bereik',
+ derivedRange: 'Bereik uit data',
+ trend: 'Trend van de laatste meetpunten',
+ range: 'bereik',
+ seriesCount: 'reeksen',
+ pointCount: 'meetpunten',
+ sourceAge: 'Bronmeting',
+ secondsAgo: 'seconden geleden',
+ minutesAgo: 'minuten geleden',
+ hoursAgo: 'uur geleden',
+ sourceWarning: 'Bronwaarschuwing',
+ inspectorForbidden: 'Technische querydetails zijn niet beschikbaar voor deze rol.',
+ inspectorTitle: 'Query-inspectie',
+ semanticMetric: 'Semantische metric',
+ generatedQuery: 'Gegenereerde query',
+ estimatedSamples: 'Geschatte samples',
+ seriesLimit: 'Reekslimiet',
+ pointLimit: 'Puntlimiet',
+ inspectCost: 'Geschatte querykosten',
+ },
+ inventory: {
+ eyebrow: 'Inventaris en bronnen',
+ title: 'Wat Pulse kan zien',
+ intro: 'Bekijk bronnen, entiteiten en de herkomst van observaties. Verouderde of ontbrekende telemetrie blijft zichtbaar als Onbekend.',
+ sources: 'Databronnen',
+ entities: 'Entiteiten',
+ unknown: 'Onbekend',
+ sourceUnknown: 'Geen recente bronmeting beschikbaar',
+ empty: 'Er zijn nog geen entiteiten gevonden.',
+ loading: 'Inventaris wordt geladen…',
+ error: 'De inventaris kon niet worden geladen.',
+ retry: 'Opnieuw laden',
+ provenance: 'Bron en herkomst',
+ ready: 'Gereed',
+ operational: 'Operationeel',
+ summary: 'Inventarissamenvatting', visibleEntities: 'Zichtbare entiteiten in de huidige selectie', sourceCoverage: 'Entiteiten met brongebonden observaties',
+ manualCorrections: 'Handmatige correcties', overrideProtection: 'Discovery behoudt deze effectieve waarden', searchTitle: 'Doorzoekbare inventaris', search: 'Zoeken',
+ searchPlaceholder: 'Naam of canonieke sleutel', type: 'Type', allTypes: 'Alle types', status: 'Status', allStatuses: 'Alle statussen', sort: 'Sortering',
+ sourcesShort: 'bronnen', factsShort: 'feiten', relationsShort: 'relaties', overridesShort: 'correcties', loadMore: 'Meer entiteiten laden', previous: 'Vorige pagina', next: 'Volgende pagina', page: 'Pagina', mobileList: 'Entiteitskaarten',
+ stale: 'Verouderd', missingValue: 'Geen waarde', yes: 'Ja', no: 'Nee', loadingDetail: 'Entiteit wordt geladen…', detailUnavailable: 'Entiteit niet beschikbaar', back: 'Terug naar inventaris',
+ firstSeen: 'Eerst gezien', facts: 'Bronfeiten', staleFacts: 'verouderde feiten', relations: 'Relaties', aliases: 'Aliassen', effective: 'Effectieve waarden',
+ effectiveTitle: 'Wat Pulse momenteel gebruikt', noEffective: 'Er zijn nog geen effectieve waarden beschikbaar.', manualOverride: 'Handmatige correctie', discovered: 'Ontdekt',
+ overrideWins: 'Deze correctie heeft voorrang; ontdekte bronfeiten blijven hieronder beschikbaar.', observed: 'Waargenomen', topology: 'Topologie', noRelations: 'Geen relaties geregistreerd; er wordt geen verbinding verondersteld.',
+ confirmed: 'bevestigd', inferred: 'afgeleid', missingRelation: 'Ontbrekende entiteit', identity: 'Bronidentiteit', noAliases: 'Geen bronaliassen geregistreerd.',
+ allEvidence: 'Alle bronfeiten en correcties', noFacts: 'Geen bronfeiten geregistreerd.', overrides: 'Correcties', noOverrides: 'Geen handmatige correcties geregistreerd.',
+ },
+ alerts: {
+ eyebrow: 'Meldingen', title: 'Meldingen en incidenten', intro: 'Prioriteer actieve signalen en beheer daarna veilige, versieerbare regels en tijdelijke uitzonderingen.',
+ sectionNavigation: 'Werkruimte voor meldingen', sectionOperations: 'Actieve meldingen', sectionOperationsDetail: 'Prioriteiten en erkenning', sectionRules: 'Alertregels', sectionRulesDetail: 'Detectie en drempels', sectionControls: 'Stiltes en onderhoud', sectionControlsDetail: 'Tijdelijke uitzonderingen', operationSummary: 'Samenvatting van actieve meldingen', activeAlerts: 'Actief', activeAlertsDetail: 'vraagt operationele aandacht', criticalAlerts: 'Kritiek actief', criticalAlertsDetail: 'hoogste prioriteit', acknowledgedAlerts: 'Erkend', acknowledgedAlertsDetail: 'opgepakt door een operator', operationsLoading: 'Actieve meldingen worden geladen…', operationsError: 'Actieve meldingen konden niet veilig worden geladen.', resultLimit: '{count} van {total} meldingen zichtbaar; verfijn via de samenvatting.', confirmAcknowledge: 'Deze melding erkennen? De evaluatie en geschiedenis blijven behouden.', confirmUnacknowledge: 'De erkenning van deze melding intrekken?', confirmRuleEnable: 'Deze alertregel inschakelen?', confirmRuleDisable: 'Deze alertregel uitschakelen?', confirmRevoke: 'Deze tijdelijke uitzondering intrekken? De geschiedenis blijft behouden.',
+ empty: 'Geen actieve meldingen', rules: 'Alertregels', ruleList: 'Geregistreerde regels', newRule: 'Nieuwe regel', noRules: 'Nog geen alertregels geregistreerd.', unnamed: 'Naamloze regel', enabled: 'Ingeschakeld', disabled: 'Uitgeschakeld', editor: 'Regelbewerking', editRule: 'Regel bewerken', createRule: 'Nieuwe regel', name: 'Naam', severity: 'Ernst', inputType: 'Signaalbron', inputTypeHelp: 'Kies welk begrensd signaal deze regel beoordeelt.', inputMetric: 'Meting uit de catalogus', inputEvent: 'Gebeurtenissen tellen', inputEntityStatus: 'Status van een onderdeel', inputDatasourceHealth: 'Gezondheid van een meetbron', metric: 'Meting', metricHelp: 'Kies een goedgekeurde meting; vrije PromQL is niet toegestaan.', metricLoading: 'Metingen worden geladen…', metricUnavailable: 'De metriccatalogus is niet beschikbaar.', chooseMetric: 'Kies een meting', operator: 'Voorwaarde', greaterThan: 'Groter dan', greaterThanOrEqual: 'Groter dan of gelijk aan', lessThan: 'Kleiner dan', lessThanOrEqual: 'Kleiner dan of gelijk aan', equalTo: 'Gelijk aan', notEqualTo: 'Niet gelijk aan', matches: 'Komt overeen met patroon', absent: 'Ontbreekt', threshold: 'Drempelwaarde', recoveryThreshold: 'Hersteldrempel', recoveryThresholdHelp: 'Bepaalt wanneer een actieve melding na stabiel herstel sluit.', interval: 'Controleer elke (seconden)', pending: 'Wacht vóór melding (seconden)', resolve: 'Bevestig herstel na (seconden)', cooldown: 'Pauze tussen meldingen (seconden)', cooldownHelp: 'Voorkomt herhaalde meldingen binnen deze periode.', unknownBehavior: 'Bij ontbrekende gegevens', unknownRetain: 'Actieve melding als onbekend behouden', unknownBecome: 'Status op onbekend zetten', unknownIgnore: 'Korte onderbreking negeren', suppressWhen: 'Onderdruk bij bevestigde oorzaak', suppressWhenHelp: 'Kies alleen oorzaken die deze melding aantoonbaar overbodig maken.', causeHost: 'Host niet bereikbaar', causeDns: 'DNS-storing', causeSource: 'Meetbron niet beschikbaar', technicalDetails: 'Technische regelgegevens', titleKey: 'Interne titelsleutel', bodyKey: 'Interne tekstsleutel', invalidName: 'Vul een regelnaam in.', invalidMetric: 'Kies een geldige meting uit de catalogus.', invalidThreshold: 'Vul een geldige drempelwaarde in.', invalidTiming: 'Controle- en wachttijden vallen buiten de toegestane grenzen.', save: 'Regel opslaan', enable: 'Inschakelen', disable: 'Uitschakelen', preview: 'Test-preview', previewTitle: 'Voorbeeld evalueren', previewDetail: 'Deze test heeft geen opslag-, evaluatie- of auditbijwerking.', sampleValue: 'Voorbeeldwaarde', runPreview: 'Preview uitvoeren', loading: 'Alertregels worden geladen…', errorTitle: 'Alertregels niet beschikbaar', errorDetail: 'De alertregelgegevens konden niet veilig worden geladen.', unauthorizedTitle: 'Geen toegang tot alertregels', unauthorizedDetail: 'Je hebt geen rechten om alertregels te bekijken.', previewError: 'De preview kon niet worden uitgevoerd.', saveError: 'De regel kon niet worden opgeslagen.', toggleError: 'De regelstatus kon niet worden gewijzigd.', conflict: 'De regel is intussen gewijzigd; laad de pagina opnieuw.', saved: 'Regel opgeslagen.', stateSaved: 'Regelstatus opgeslagen.', controls: 'Onderdrukking en onderhoud', controlsTitle: 'Tijdelijke onderdrukking en onderhoud', controlsIntro: 'Tijdelijke onderdrukking en gepland onderhoud zijn begrensd, zichtbaar en worden niet uit de meldingsgeschiedenis verwijderd.', controlHistory: 'Controleerbaar', silenceTitle: 'Nieuwe tijdelijke onderdrukking', maintenanceTitle: 'Nieuw onderhoudsvenster', controlName: 'Naam', controlReason: 'Reden', silenceSeverity: 'Ernst', entityType: 'Entiteitstype', entityHost: 'Host', entityContainer: 'Container', entityService: 'Service', entityDisk: 'Disk', entityPool: 'Pool', matcherHelp: 'Kies welke ernst tijdelijk wordt onderdrukt.', selectorHelp: 'Kies het betrokken entiteitstype.', startsAt: 'Starttijd', expiresAt: 'Vervaltijd', endsAt: 'Eindtijd', createControl: 'Opslaan', previewMatcher: 'Voorbeeld bekijken', controlPreview: 'Treffers', controlPreviewError: 'Het voorbeeld kon niet worden uitgevoerd.', controlSaveError: 'De wijziging kon niet worden opgeslagen.', controlSaved: 'Wijziging opgeslagen.', noControls: 'Geen geregistreerde perioden.', silenceHistory: 'Geschiedenis tijdelijke onderdrukking', maintenanceHistory: 'Onderhoudsgeschiedenis', revoke: 'Intrekken', controlActive: 'Actief', controlScheduled: 'Gepland', controlExpired: 'Verlopen', controlRevoked: 'Ingetrokken', operations: 'Meldingsbediening', alertList: 'Actieve en recente meldingen', historyPreserved: 'Geschiedenis behouden', operationsIntro: 'Erkenning verandert alleen de operationele status; evaluaties en historie blijven behouden.', noActiveAlerts: 'Geen actieve of recente meldingen.', acknowledge: 'Erkennen', unacknowledge: 'Erkenning intrekken', operationError: 'De meldingsstatus kon niet worden gewijzigd.', operationSaved: 'Meldingsstatus opgeslagen.', occurrence: 'historie-item', occurrences: 'historie-items', revision: 'revisie'
+ },
+ presentation: {
+ status: { healthy: 'Gezond', operational: 'Operationeel', available: 'Beschikbaar', running: 'Actief', stopped: 'Gestopt', restarting: 'Wordt herstart', paused: 'Gepauzeerd', starting: 'Wordt gestart', down: 'Niet bereikbaar', up: 'Beschikbaar', online: 'Online', offline: 'Offline', missing: 'Ontbreekt', faulted: 'Defect', sleeping: 'Slapend', idle: 'Inactief', completed: 'Voltooid', failed: 'Mislukt', cached: 'Uit cache', unhealthy: 'Ongezond', attention: 'Aandacht', degraded: 'Verstoord', warning: 'Waarschuwing', info: 'Informatie', error: 'Fout', critical: 'Kritiek', unknown: 'Onbekend', unavailable: 'Niet beschikbaar', disabled: 'Uitgeschakeld', stale: 'Verouderd', fresh: 'Actueel', pending: 'In afwachting', firing: 'Actieve melding', acknowledged: 'Erkend', resolved: 'Opgelost', silenced: 'Tijdelijk onderdrukt', suppressed: 'Onderdrukt door oorzaak', maintenance: 'Gepland onderhoud', active: 'Actief', scheduled: 'Gepland', expired: 'Verlopen', revoked: 'Ingetrokken' },
+ reason: { sourceHealthUnknown: 'De gezondheid van deze bron is onbekend.', sourceUnavailable: 'De meetbron is niet beschikbaar; controleer de bronverbinding en recente metingen.', notConfigured: 'Dit onderdeel is nog niet geconfigureerd; open de instellingen om het te activeren.', filesystemRootNotConfigured: 'Bestandssysteemmetingen zijn niet geconfigureerd; controleer de veilige hostmount in de broninstellingen.', lastRunFailed: 'De laatste verwerking is mislukt; controleer de taakstatus en foutdetails.', lastRunSucceeded: 'De laatste verwerking is geslaagd.', noRecentSample: 'Er is geen recente meting; controleer of de collector actief is.', stale: 'De laatste meting is verouderd; controleer de bronverbinding en collector.', insufficientHistory: 'Er is nog onvoldoende historie voor een betrouwbare beoordeling.', hostUnreachable: 'De host is niet bereikbaar; controleer netwerk en bronconfiguratie.', dnsFailure: 'De DNS-controle is mislukt; controleer naamresolutie voor het doel.', backupVerified: 'De backup is geverifieerd.', backupStale: 'De laatste geverifieerde backup is verlopen; maak en verifieer een nieuwe backup.', backupVerificationFailed: 'De backupverificatie is mislukt; controleer de backupbestemming en integriteit.', authenticatedSession: 'De huidige aanmeldsessie is geldig en via OIDC verkregen.', authenticatedSessionNotObserved: 'Nog geen geldige aanmeldsessie waargenomen; meld opnieuw aan via de identiteitsprovider.', databaseReady: 'De databaseverbinding is beschikbaar.', sourceSampled: 'De bron levert actuele, bruikbare metingen.', providerNotSampled: 'De identiteitsprovider is geconfigureerd maar nog niet via een geldige sessie bevestigd.', sourceNotSampled: 'De bron is geconfigureerd maar heeft nog geen meting geleverd; controleer de collector.', notificationsNotSampled: 'Notificaties zijn nog niet uitgevoerd; configureer een kanaal of laat deze optionele functie uitgeschakeld.', probesNotRecorded: 'Servicecontroles hebben nog geen run geregistreerd; configureer veilige doelen of laat deze optionele functie uitgeschakeld.', workerNotRecorded: 'De achtergrondverwerking heeft nog geen heartbeat geregistreerd; controleer de worker.', noVerifiedBackup: 'Er is nog geen geverifieerde backup; maak een backup via Systeemstatus.', containerPrefix: 'De container is', servicePrefix: 'De service is', noDetail: 'Geen aanvullende uitleg beschikbaar.', technical: 'Open de technische details voor de exacte broncode en controleer de bijbehorende configuratie.' },
+ component: { database: 'Database', worker: 'Achtergrondverwerking', query: 'Meetquery’s', storage: 'Opslag', backup: 'Back-up', notifications: 'Notificaties', oidc: 'Aanmelding', probes: 'Servicecontroles', other: 'Systeemonderdeel' },
+ metric: { hostCpu: 'CPU-gebruik van de host', hostMemory: 'Geheugengebruik van de host', containerCpu: 'CPU-gebruik per container', containerMemory: 'Geheugengebruik per container', diskTemperature: 'Temperatuur per disk', maximumDiskTemperature: 'Hoogste disktemperatuur', poolUtilization: 'Gebruikte poolcapaciteit', serviceResponseTime: 'Reactietijd van services', serviceAvailability: 'Beschikbaarheid van services', minimumServiceAvailability: 'Laagste servicebeschikbaarheid', other: 'Goedgekeurde meting' },
+ unit: { bytes: 'bytes', seconds: 'seconden', ratio: 'verhouding', value: 'waarde' },
+ },
+ sourceStatus: {
+ status: 'Bronstatus',
+ data: 'Data',
+ fresh: 'Actueel en bruikbaar',
+ stale: 'Verouderd',
+ unavailable: 'Niet bruikbaar',
+ neverReceived: 'Nog niet ontvangen',
+ observed: 'Laatst ontvangen',
+ technical: 'Technische broninformatie',
+ reasonCode: 'Broncode',
+ sourceId: 'Bron-ID',
+ },
+ incidents: {
+ listEyebrow: 'Incidenten', listTitle: 'Operationeel overzicht', listIntro: 'Gegroepeerde signalen met onderbouwde correlatie en zichtbare onzekerheid.', openIncidents: 'Open incidenten',
+
+ detailEyebrow: 'Incidentdetail', statusAndConfidence: 'Status en zekerheid', ownership: 'Eigenaarschap', followUp: 'Incident opvolgen', ownerLabel: 'Gebruikers-ID van eigenaar', ownerPlaceholder: 'UUID van eigenaar', saveOwner: 'Eigenaar opslaan', ownerNote: 'Deze actie wijzigt alleen metadata; Pulse voert geen herstel- of infrastructuuractie uit.', timeline: 'Tijdlijn', signalsAndNotes: 'Signalen en notities', timelineItems: 'tijdlijnitems', noTimeline: 'Nog geen tijdlijnitems.', notes: 'Notities', operatorContext: 'Context voor operators', newNote: 'Nieuwe notitie', noteHelp: 'Notities worden als platte tekst opgeslagen; HTML wordt verwijderd.', addNote: 'Notitie toevoegen', externalWorkflow: 'Externe workflow', workflowReady: 'Koppeling voorbereid', workflowIntro: 'Een externe workflowlink kan later worden toegevoegd. Pulse start geen herstelactie en voert geen wijzigingen buiten de observatie- en notitielaag uit.', workflowPlaceholder: 'Binnenkort beschikbaar', started: 'Gestart', correlationMethod: 'Correlatiemethode', confidence: 'Betrouwbaarheid', revision: 'Revisie', correlationNote: 'Correlatie is een onderbouwde aanwijzing, geen bewezen causaliteit. Betrouwbaarheid beschrijft de correlatieregel, niet de zekerheid van de oorzaak.'
+ }, onboarding: {
+ eyebrow: 'Eerste configuratie', title: 'Pulse klaarzetten', intro: 'Controleer de veilige basis en kies welke standaardonderdelen je wilt installeren.', resumeIntro: 'Je kunt verdergaan waar de vorige configuratiepoging is gestopt.', capabilities: 'Capabiliteiten', readiness: 'Gereedheid', ready: 'Gereed', action: 'Actie nodig', unknown: 'Onbekend', runtimeReady: 'Actuele telemetrie wordt ontvangen via de veilige runtimebron.', capabilityNames: { auth: 'Aanmelding', database: 'Database', prometheus: 'Meetgegevens', unraid: 'Unraid-bron', services: 'Servicebewaking', 'default-dashboard': 'Standaarddashboard', 'default-rules': 'Standaardmeldingen' }, inProgress: 'Bezig', completed: 'Afgerond', choices: 'Keuzes', defaultsTitle: 'Standaardonderdelen', defaultsIntro: 'De standaardinstallatie is additief: bestaande dashboards en regels worden niet verwijderd of overschreven.', dashboardChoice: 'Standaarddashboard', rulesChoice: 'Standaardmeldingen', installDefault: 'Installeer Overzicht', keepDefaults: 'Behoud standaardmeldingen', skip: 'Overslaan', safeNote: 'Endpoints, tokens en andere geheime waarden worden nooit in deze stap getoond. Pulse voert geen server- of infrastructuuractie uit.', complete: 'Configuratie afronden', saving: 'Opslaan…', saved: 'Onboarding opgeslagen.', saveError: 'De onboarding kon niet worden opgeslagen.', loading: 'Onboarding wordt geladen…', errorTitle: 'Onboarding niet beschikbaar', errorDetail: 'De configuratiestatus kon niet veilig worden geladen.', retry: 'Opnieuw laden',
+ completedIntro: 'De eerste configuratie is afgerond. Controleer de actieve keuzes en open herconfiguratie alleen wanneer dat bewust nodig is.', completedSummary: 'Actieve configuratie', configurationActive: 'Pulse is geconfigureerd', configurationActiveDetail: 'De oorspronkelijke installatieacties worden niet opnieuw aangeboden zolang je geen expliciete herconfiguratie opent.', dashboardInstalled: 'Overzicht actief', rulesInstalled: 'Standaardmeldingen actief', skipped: 'Bewust overgeslagen', reconfigure: 'Herconfiguratie openen', reconfigureNote: 'Herconfiguratie is additief, vereist een beheerder en overschrijft geen bestaande dashboards of regels.', saveReconfiguration: 'Herconfiguratie opslaan', cancel: 'Annuleren', confirmReconfigure: 'Deze onboardingkeuzes opnieuw toepassen? Bestaande dashboards en regels blijven behouden.', adminRequired: 'Alleen een beheerder kan onboardingkeuzes wijzigen.'
+ }, wallboard: {
+ eyebrow: 'Wallboard',
+ title: 'Operationeel wallboard',
+ intro: 'Een fullscreen, alleen-lezen overzicht voor permanente zichtbaarheid.',
+ readOnly: 'Alleen-lezen sessie',
+ loading: 'Wallboard wordt geladen…',
+ errorTitle: 'Wallboard niet beschikbaar',
+ errorDetail: 'De dashboardgegevens konden niet veilig worden geladen.',
+ noDashboards: 'Geen dashboards beschikbaar voor het wallboard.',
+ connected: 'Verbonden',
+ reconnecting: 'Verbinding herstellen…',
+ unavailable: 'Bron niet beschikbaar',
+ transport: 'Transport',
+ data: 'Data',
+ dataUsable: 'Bruikbaar',
+ dataLoading: 'Wordt geladen',
+ dataEmpty: 'Geen actuele data',
+ priority: 'Operationele prioriteit',
+ overall: 'Systeem',
+ storage: 'Opslag',
+ services: 'Services',
+ incidents: 'Incidenten',
+ problems: 'problemen',
+ open: 'open',
+ unknown: 'Onbekend',
+ slide: 'Slide',
+ lastUpdated: 'Laatst bijgewerkt',
+ enterFullscreen: 'Volledig scherm',
+ exitFullscreen: 'Volledig scherm verlaten',
+ rotate: 'Automatisch wisselen',
+ every: 'elke',
+ seconds: 'seconden',
+ dashboard: 'dashboard',
+ dashboards: 'dashboards',
+ }, systemStatus: {
+ eyebrow: 'Systeemstatus', title: 'Pulse-systeemstatus', intro: 'Bekijk veilige gezondheidsinformatie, bronversheid en de laatste bekende backupstatus.', current: 'Huidige status', version: 'Versie', generated: 'Gegenereerd', components: 'Componenten', componentTitle: 'Gezondheid en beschikbaarheid', backup: 'Backupstatus', sourceLag: 'Bronversheid', source: 'bron', sources: 'bronnen', noSources: 'Geen geconfigureerde bronnen.', healthy: 'Gezond', degraded: 'Aandacht', disabled: 'Uitgeschakeld', unknown: 'Onbekend', notAvailable: 'Niet beschikbaar', seconds: 'seconden geleden', minutes: 'minuten geleden', hourAgo: 'uur geleden', hoursAgo: 'uur geleden', dayAgo: 'dag geleden', daysAgo: 'dagen geleden', loading: 'Systeemstatus wordt geladen…', errorTitle: 'Systeemstatus niet beschikbaar', errorDetail: 'De systeemstatus kon niet veilig worden geladen.', unauthorized: 'Aanmelden vereist', unauthorizedDetail: 'Meld je aan om de systeemstatus te bekijken.', forbidden: 'Geen toegang', forbiddenDetail: 'Je account heeft geen rechten om de systeemstatus te bekijken.',
+ commit: 'Commit', migration: 'Migratie', built: 'Gebouwd', verified: 'Geverifieerd',
+ createBackup: 'Maak geverifieerde backup', creatingBackup: 'Backup wordt gemaakt…', backupCreated: 'De geverifieerde backup is aangemaakt.', backupCreateFailed: 'De backup kon niet worden aangemaakt. Alleen beheerders kunnen deze actie uitvoeren; controleer ook de backupbestemming.', backupFreshness: 'Een geverifieerde backup ouder dan 24 uur krijgt altijd de status Aandacht.',
+ }, settings: {
+ eyebrow: 'Instellingen',
+ title: 'Pulse configureren',
+ intro: 'Open bestaande beheerfuncties vanuit één veilige, taakgerichte index.',
+ source: 'Databronnen',
+ sourceDetail: 'Bronstatus wordt gecontroleerd',
+ sourcesCurrent: 'bronnen actueel',
+ language: 'Taal',
+ languageValue: 'Nederlands (België)',
+ onboarding: 'Eerste configuratie', more: 'Meer',
+ onboardingDetail: 'Controleer bronnen en installeer veilige standaardonderdelen.',
+ openOnboarding: 'Open onboarding', current: 'Huidige omgeving', environment: 'Status en voorkeuren', languageDetail: 'De interface gebruikt de projectbrede standaardlocale.', management: 'Beheerfuncties', healthTitle: 'Gezondheid en herstel', healthDetail: 'Controleer release-, bron- en backupwaarheid of bekijk de eerste configuratie.', systemStatus: 'Systeemstatus en backup', systemStatusDetail: 'Gezondheid, bronversheid, release-informatie en geverifieerde backups.', alertingTitle: 'Detectie en onderdrukking', alertingDetail: 'Beheer welke signalen aandacht vragen en wanneer tijdelijke uitzonderingen gelden.', alertRules: 'Alertregels', alertRulesDetail: 'Versiebeheer, drempels, hysterese en veilige preview.', alertControls: 'Stiltes en onderhoud', alertControlsDetail: 'Tijdelijke onderdrukking en geplande onderhoudsvensters.', presentationTitle: 'Presentatie en inventaris', presentationDetail: 'Beheer dashboards en controleer ontdekte onderdelen en bronherkomst.', dashboards: 'Dashboards', dashboardsDetail: 'Open, maak en bewerk begrensde operationele dashboards.', inventory: 'Inventory en bronherkomst', inventoryDetail: 'Bekijk entiteiten, feiten, relaties en handmatige correcties.', viewAccess: 'Bekijken: aangemeld', adminActions: 'Backupactie: beheerder', adminChanges: 'Wijzigen: beheerder', editorChanges: 'Wijzigen: editor', operatorChanges: 'Wijzigen: operator',
+ },
+ states: {
+ loading: 'Pulse wordt geladen…',
+ errorTitle: 'Er ging iets mis',
+ errorDetail: 'De pagina kon niet worden geladen. Probeer het opnieuw.',
+ retry: 'Opnieuw proberen',
+ unauthorizedTitle: 'Geen toegang',
+ unauthorizedDetail: 'Je account heeft geen toegang tot deze pagina.',
+ returnHome: 'Terug naar overzicht',
+ },
+ auth: {
+ signIn: 'Aanmelden',
+ signInAgain: 'Opnieuw aanmelden',
+ signInPending: 'Aanmelden wordt geopend…',
+ signInHint: 'Je wordt doorgestuurd naar de aanmeldpagina en daarna teruggebracht naar deze pagina.',
+ dismiss: 'Melding sluiten',
+ requiredTitle: 'Aanmelden vereist',
+ requiredDetail: 'Meld je aan om Pulse-gegevens te zien.',
+ expiredTitle: 'Sessie verlopen',
+ expiredDetail: 'Je sessie is verlopen. Meld je opnieuw aan om actuele gegevens te zien.',
+ failedTitle: 'Aanmelden niet voltooid',
+ failedDetail: 'Het aanmelden is niet afgerond. Probeer het opnieuw of neem contact op met je beheerder.',
+ cancelledTitle: 'Aanmelden geannuleerd',
+ cancelledDetail: 'Je hebt het aanmelden afgebroken. Meld je aan om Pulse-gegevens te zien.',
+ noticeLabel: 'Aanmeldstatus',
+ },
+ editor: {
+ shell: {
+ eyebrow: 'Bewerkmodus',
+ title: 'Dashboard aanpassen',
+ intro: 'Wijzigingen blijven lokaal als concept tot je ze opslaat.',
+ dirty: 'Niet opgeslagen',
+ undo: 'Ongedaan maken',
+ redoLabel: 'Opnieuw uitvoeren',
+ redo: 'Opnieuw',
+ cancel: 'Annuleren',
+ save: 'Opslaan',
+ saving: 'Opslaan…',
+ conflictTitle: 'Serverconflict',
+ conflictDetail: 'De server heeft een nieuwere versie.',
+ reloadServer: 'Herlaad serverversie',
+ confirmExitTitle: 'Niet-opgeslagen wijzigingen',
+ confirmExitDetail: 'Wil je het concept verlaten?',
+ keepEditing: 'Blijf bewerken',
+ leaveWithoutSaving: 'Verlaat zonder opslaan',
+ library: 'Widgetbibliotheek',
+ advanced: 'Dashboardvariabelen, sjablonen en gegevensoverdracht',
+ canvas: 'Dashboardindeling',
+ layout: 'Layout',
+ addWidget: 'Widget toevoegen',
+ selectedWidget: 'Geselecteerde widget',
+ selectWidgetHint: 'Selecteer een widget om acties te bekijken.',
+ },
+ widgetTypes: {
+ stat: 'Kengetal',
+ timeseries: 'Tijdreeks',
+ gauge: 'Meter',
+ rankedList: 'Ranglijst',
+ statusGrid: 'Statusoverzicht',
+ table: 'Tabel',
+ heatmap: 'Heatmap',
+ eventTimeline: 'Gebeurtenistijdlijn',
+ storageMap: 'Opslagkaart',
+ topology: 'Topologie',
+ serviceMatrix: 'Servicematrix',
+ alertSummary: 'Meldingssamenvatting',
+ text: 'Notitie',
+ fallback: 'Widget',
+ newWidget: 'Nieuwe widget',
+ untitled: 'Widget zonder titel',
+ copySuffix: ' (kopie)',
+ },
+ viewports: {
+ desktop: 'Desktop',
+ tablet: 'Tablet',
+ mobile: 'Mobiel',
+ wallboard: 'Wallboard',
+ },
+ card: {
+ locked: 'Vergrendeld',
+ movable: 'Verplaatsbaar',
+ moveUp: 'Widget omhoog: ',
+ moveDown: 'Widget omlaag: ',
+ moveUpVisible: 'Omhoog',
+ moveDownVisible: 'Omlaag',
+ unlockLabel: 'Widget ontgrendelen: ',
+ lockLabel: 'Widget vergrendelen: ',
+ unlock: 'Ontgrendel',
+ lock: 'Vergrendel',
+ show: 'Toon',
+ hide: 'Verberg',
+ inViewport: ' in ',
+ duplicate: 'Dupliceer',
+ remove: 'Verwijder',
+ resizeLabel: 'Breedte aanpassen: ',
+ resizeVisible: 'Breedte',
+ resizeHint: 'Gebruik pijltjestoetsen links en rechts om de breedte aan te passen.',
+ widthAnnouncement: 'Breedte',
+ columns: 'kolommen',
+ widthField: 'Breedte in kolommen',
+ incompleteConfig: 'Configuratie onvolledig: openen en herstellen.',
+ lockedHint: 'Vergrendeld: kan niet worden verplaatst.',
+ dragHint: 'Sleep deze kaart in de actieve layout of gebruik ↑↓.',
+ },
+ messages: {
+ importLoaded: 'Import geladen als lokaal concept. Controleer en sla op om een nieuwe versie te maken.',
+ serverLoaded: 'Serverversie geladen. Lokale wijzigingen zijn verwijderd.',
+ serverLoadFailed: 'Serverversie kon niet worden geladen.',
+ previewBlocked: 'Herstel de validatiefouten voordat je een voorbeeld maakt.',
+ previewFailed: 'Voorbeeld kon niet worden geladen.',
+ previewFailedConnection: 'Voorbeeld kon niet worden geladen. Controleer de verbinding.',
+ saveBlockedWidgets: 'Opslaan geblokkeerd: herstel eerst de gemarkeerde widgetconfiguratie.',
+ saveBlockedVariables: 'Opslaan geblokkeerd: herstel eerst de dashboardvariabelen.',
+ saveConflict: 'Conflict: de serverversie is gewijzigd. Herlaad de serverversie voordat je verdergaat.',
+ saveFailed: 'Opslaan is niet gelukt.',
+ saveFailedConnection: 'Opslaan is niet gelukt. Controleer de verbinding.',
+ },
+ validation: {
+ idRequired: 'Een widget-id is verplicht.',
+ typeUnsupported: 'Dit widgettype wordt niet ondersteund.',
+ titleRequired: 'Een titel is verplicht.',
+ titleTooLong: 'De titel mag maximaal 120 tekens bevatten.',
+ sourceUnsupported: 'Kies een ondersteund brontype.',
+ metricRequired: 'Een semantische metric is verplicht.',
+ metricNoPromql: 'Gebruik een semantische metric; vrije PromQL is niet toegestaan.',
+ rangeUnsupported: 'Kies een begrensde periode.',
+ aggregationUnsupported: 'Kies een ondersteunde aggregatie.',
+ limitRange: 'De limiet moet een geheel getal tussen 1 en 1000 zijn.',
+ decimalsRange: 'Decimalen moeten tussen 0 en 6 liggen.',
+ maxBelowMin: 'Maximum moet groter of gelijk aan minimum zijn.',
+ intervalRange: 'Verversing moet tussen 1 en 300 seconden liggen.',
+ },
+ config: {
+ kicker: 'Configuratie',
+ fallbackTitle: 'Widget configureren',
+ intro: 'Velden worden gecontroleerd vóór een versie wordt opgeslagen.',
+ general: 'Algemeen',
+ title: 'Titel',
+ description: 'Beschrijving',
+ data: 'Data',
+ source: 'Bron',
+ sourceSemanticMetric: 'Semantische metric',
+ sourceInventory: 'Inventaris',
+ sourceEvents: 'Gebeurtenissen',
+ sourceAlerts: 'Meldingen',
+ sourceIncidents: 'Incidenten',
+ sourceText: 'Tekst',
+ metric: 'Metric',
+ metricPlaceholder: 'host.cpu.utilization',
+ range: 'Periode',
+ rangeLive: 'Live',
+ range15m: '15 minuten',
+ range1h: '1 uur',
+ range6h: '6 uur',
+ range24h: '24 uur',
+ range7d: '7 dagen',
+ aggregation: 'Aggregatie',
+ aggregationAvg: 'Gemiddelde',
+ aggregationMin: 'Minimum',
+ aggregationMax: 'Maximum',
+ aggregationSum: 'Som',
+ aggregationLast: 'Laatste',
+ limit: 'Maximale rijen',
+ visualization: 'Visualisatie',
+ unit: 'Eenheid',
+ decimals: 'Decimalen',
+ minimum: 'Minimum',
+ maximum: 'Maximum',
+ layout: 'Layout',
+ width: 'Breedte in kolommen',
+ widthHint: 'De breedte geldt voor de actieve layout en is ook met pijltjestoetsen op de kaart aan te passen.',
+ behavior: 'Gedrag',
+ refresh: 'Verversing (sec.)',
+ hideWhenEmpty: 'Verberg bij lege data',
+ preview: 'Voorbeeld',
+ previewState: 'Toestand',
+ previewStateLoading: 'Laden',
+ previewStateEmpty: 'Leeg',
+ previewStateError: 'Fout',
+ previewStateStale: 'Verouderd',
+ previewLoading: 'Voorbeeld laden…',
+ previewRefresh: 'Voorbeeld vernieuwen',
+ limitsPrefix: 'Begrenzing: ',
+ limitsSeries: ' series, ',
+ limitsPoints: ' punten, ',
+ limitsRows: ' rijen.',
+ },
+ transfer: {
+ kicker: 'Portabiliteit',
+ title: 'Import, export en templates',
+ export: 'Exporteer JSON',
+ import: 'Importeer gevalideerde JSON',
+ templates: 'Templates',
+ templateEmpty: 'Leeg dashboard',
+ templateOperations: 'Operationeel sjabloon',
+ templateEmptyName: 'Nieuw dashboard',
+ errors: {
+ notAnObject: 'JSON moet een dashboardobject bevatten.',
+ schemaVersion: 'Onbekende schemaVersion. Gebruik schema 1 of 2.',
+ widgetsArray: 'widgets moet een array zijn.',
+ variablesArray: 'variables moet een array zijn.',
+ tooManyWidgets: 'Een dashboard mag maximaal 200 widgets bevatten.',
+ tooManyVariables: 'Een dashboard mag maximaal 30 variabelen bevatten.',
+ tooDeep: 'Dashboardstructuur is te diep genest.',
+ unsafeContent: 'Script- of HTML-inhoud is niet toegestaan.',
+ tooLarge: 'Importbestand is groter dan 2 MiB.',
+ invalidJson: 'Importbestand bevat geen geldige JSON.',
+ },
+ },
+ variables: {
+ kicker: 'Dashboardcontext',
+ title: 'Variabelen',
+ add: 'Variabele toevoegen',
+ intro: 'Variabelen worden veilig als context toegepast; entity- en serverkeuzes gebruiken alleen bekende opties.',
+ empty: 'Geen variabelen. Voeg er één toe voor herbruikbare server- of periodekeuzes.',
+ name: 'Naam',
+ label: 'Label',
+ type: 'Type',
+ default: 'Standaardwaarde',
+ options: 'Toegestane opties, komma-gescheiden',
+ optionsPlaceholder: 'primary,backup',
+ remove: 'Verwijder variabele',
+ newLabel: 'Nieuwe variabele',
+ periodLabel: 'Periode',
+ errors: {
+ tooMany: 'Er zijn maximaal 30 variabelen toegestaan.',
+ nameFormat: 'Gebruik een naam met letters, cijfers en underscores.',
+ nameUnique: 'Variabelenamen moeten uniek zijn.',
+ typeUnsupported: 'Kies een ondersteund type.',
+ labelRequired: 'Een label is verplicht.',
+ tooManyOptions: 'Er zijn maximaal 1000 opties toegestaan.',
+ defaultNotAllowed: 'De standaardwaarde moet een geautoriseerde optie zijn.',
+ },
+ },
+ },
+ accessibility: {
+ skipToContent: 'Ga naar hoofdinhoud',
+ },
+} as const;
diff --git a/apps/web/src/dashboardScope.ts b/apps/web/src/dashboardScope.ts
new file mode 100644
index 0000000..c8bebc8
--- /dev/null
+++ b/apps/web/src/dashboardScope.ts
@@ -0,0 +1,24 @@
+type DashboardVariable = { name?: unknown; default?: unknown };
+
+// Resolve only exact `$name` references declared by the dashboard. The query
+// planner remains the authority for accepted scope keys and values; unresolved
+// references are preserved so they fail visibly instead of widening a query.
+export function resolveDashboardScope(
+ rawScope: Record,
+ variables: unknown[],
+): Record {
+ const defaults = new Map();
+ for (const candidate of variables) {
+ if (!candidate || typeof candidate !== 'object') continue;
+ const variable = candidate as DashboardVariable;
+ if (typeof variable.name === 'string' && variable.name !== '' && typeof variable.default === 'string') {
+ defaults.set(variable.name, variable.default);
+ }
+ }
+ return Object.fromEntries(Object.entries(rawScope).flatMap(([key, value]) => {
+ if (typeof value !== 'string') return [];
+ const reference = /^\$([A-Za-z][A-Za-z0-9_-]{0,63})$/.exec(value);
+ if (!reference) return [[key, value]];
+ return [[key, defaults.get(reference[1]) ?? value]];
+ }));
+}
diff --git a/apps/web/src/listQuery.ts b/apps/web/src/listQuery.ts
new file mode 100644
index 0000000..a4a5ef4
--- /dev/null
+++ b/apps/web/src/listQuery.ts
@@ -0,0 +1,15 @@
+export function queryValue(name: string, allowed?: readonly string[], fallback = ''): string {
+ if (typeof window === 'undefined') return fallback;
+ const value = new URLSearchParams(window.location.search).get(name) ?? fallback;
+ return !allowed || allowed.includes(value) ? value : fallback;
+}
+
+export function replaceListQuery(values: Record): void {
+ if (typeof window === 'undefined') return;
+ const params = new URLSearchParams(window.location.search);
+ for (const [key, value] of Object.entries(values)) {
+ if (value) params.set(key, value); else params.delete(key);
+ }
+ const query = params.toString();
+ window.history.replaceState({}, '', window.location.pathname + (query ? `?${query}` : ''));
+}
diff --git a/apps/web/src/liveBuffer.ts b/apps/web/src/liveBuffer.ts
new file mode 100644
index 0000000..e393c23
--- /dev/null
+++ b/apps/web/src/liveBuffer.ts
@@ -0,0 +1,168 @@
+export type LiveFreshness = 'fresh' | 'delayed' | 'stale' | 'unavailable';
+export type LiveSample = { series: string; timestamp: string; value: number | null; freshness: LiveFreshness; labels?: Record };
+export type BufferedPoint = { timestamp: number; value: number | null; freshness: LiveFreshness; labels?: Record };
+
+function clonePoint(point: BufferedPoint): BufferedPoint {
+ return { ...point, labels: point.labels ? { ...point.labels } : undefined };
+}
+
+export class SeriesRingBuffer {
+ private readonly values: Array;
+ private head = 0;
+ private count = 0;
+ readonly capacity: number;
+
+ constructor(capacity: number) {
+ this.capacity = capacity;
+ if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('Ring buffer capacity must be a positive integer.');
+ this.values = new Array(capacity);
+ }
+
+ get length(): number { return this.count; }
+
+ append(point: BufferedPoint): void {
+ const index = this.count < this.capacity ? (this.head + this.count) % this.capacity : this.head;
+ this.values[index] = clonePoint(point);
+ if (this.count < this.capacity) this.count += 1;
+ else this.head = (this.head + 1) % this.capacity;
+ }
+
+ appendMany(points: readonly BufferedPoint[]): void { points.forEach((point) => this.append(point)); }
+
+ snapshot(): BufferedPoint[] {
+ const result: BufferedPoint[] = [];
+ for (let index = 0; index < this.count; index += 1) {
+ const point = this.values[(this.head + index) % this.capacity];
+ if (point) result.push(clonePoint(point));
+ }
+ return result;
+ }
+
+ clear(): void {
+ this.values.fill(undefined);
+ this.head = 0;
+ this.count = 0;
+ }
+}
+
+export function historicalSamplesFromData(data: unknown): LiveSample[] {
+ if (!data || typeof data !== 'object' || !Array.isArray((data as { result?: unknown[] }).result)) return [];
+ const result: LiveSample[] = [];
+ ((data as { result: unknown[] }).result).forEach((item) => {
+ if (!item || typeof item !== 'object') return;
+ const record = item as { metric?: Record; values?: unknown[] };
+ const labels = record.metric ?? {};
+ const series = labels.__name__ || JSON.stringify(Object.fromEntries(Object.entries(labels).sort(([a], [b]) => a.localeCompare(b))));
+ if (!series || !Array.isArray(record.values)) return;
+ record.values.forEach((raw) => {
+ if (!Array.isArray(raw) || raw.length < 2) return;
+ const seconds = Number(raw[0]);
+ const value = Number(raw[1]);
+ if (!Number.isFinite(seconds)) return;
+ result.push({ series, timestamp: new Date(seconds * 1000).toISOString(), value: Number.isFinite(value) ? value : null, freshness: 'fresh', labels });
+ });
+ });
+ return result;
+}
+
+/** Series that stopped reporting for this long are dropped by `evictStale`. */
+export const DEFAULT_SERIES_TTL_MS = 900_000;
+/** Hard ceiling on distinct series keys; the least recently updated is dropped first. */
+export const DEFAULT_MAX_SERIES = 64;
+
+export class LiveSeriesStore {
+ private readonly series = new Map();
+ private readonly lastSeen = new Map();
+ readonly capacity: number;
+ readonly maxSeries: number;
+
+ constructor(capacity = 240, maxSeries = DEFAULT_MAX_SERIES) {
+ this.capacity = capacity;
+ this.maxSeries = maxSeries;
+ if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('Series capacity must be a positive integer.');
+ if (!Number.isInteger(maxSeries) || maxSeries < 1) throw new RangeError('Series count limit must be a positive integer.');
+ }
+
+ get seriesCount(): number { return this.series.size; }
+
+ append(samples: readonly LiveSample[]): void {
+ samples.forEach((sample) => {
+ const timestamp = Date.parse(sample.timestamp);
+ if (!sample.series || Number.isNaN(timestamp)) return;
+ let buffer = this.series.get(sample.series);
+ if (!buffer) {
+ buffer = new SeriesRingBuffer(this.capacity);
+ this.series.set(sample.series, buffer);
+ }
+ buffer.append({ timestamp, value: sample.value, freshness: sample.freshness, labels: sample.labels });
+ this.lastSeen.set(sample.series, timestamp);
+ });
+ this.enforceSeriesLimit();
+ }
+
+ /**
+ * Drops series whose most recent point is older than `ttlMs`. Only the ring
+ * buffers were bounded before, so a long-running wallboard accumulated map
+ * keys for every series name it ever saw.
+ */
+ evictStale(ttlMs = DEFAULT_SERIES_TTL_MS, now = Date.now()): number {
+ let removed = 0;
+ [...this.lastSeen.entries()].forEach(([key, seen]) => {
+ if (now - seen <= ttlMs) return;
+ this.series.delete(key);
+ this.lastSeen.delete(key);
+ removed += 1;
+ });
+ return removed;
+ }
+
+ private enforceSeriesLimit(): void {
+ if (this.series.size <= this.maxSeries) return;
+ const ordered = [...this.lastSeen.entries()].sort((a, b) => a[1] - b[1]);
+ for (const [key] of ordered) {
+ if (this.series.size <= this.maxSeries) break;
+ this.series.delete(key);
+ this.lastSeen.delete(key);
+ }
+ }
+
+ snapshot(): Record {
+ const result: Record = {};
+ this.series.forEach((buffer, key) => { result[key] = buffer.snapshot(); });
+ return result;
+ }
+
+ pointCount(): number {
+ let count = 0;
+ this.series.forEach((buffer) => { count += buffer.length; });
+ return count;
+ }
+
+ clear(): void { this.series.clear(); this.lastSeen.clear(); }
+}
+
+export interface ChartSeries {
+ key: string;
+ points: BufferedPoint[];
+}
+
+export class LiveChartAdapter {
+ private readonly store: LiveSeriesStore;
+
+ constructor(capacity = 240, maxSeries = DEFAULT_MAX_SERIES) { this.store = new LiveSeriesStore(capacity, maxSeries); }
+
+ append(samples: readonly LiveSample[]): void { this.store.append(samples); }
+
+ /** Drops series that stopped reporting; see `LiveSeriesStore.evictStale`. */
+ evictStale(ttlMs = DEFAULT_SERIES_TTL_MS, now = Date.now()): number { return this.store.evictStale(ttlMs, now); }
+
+ get seriesCount(): number { return this.store.seriesCount; }
+
+ snapshot(): ChartSeries[] {
+ return Object.entries(this.store.snapshot()).map(([key, points]) => ({ key, points }));
+ }
+
+ get pointCount(): number { return this.store.pointCount(); }
+
+ clear(): void { this.store.clear(); }
+}
\ No newline at end of file
diff --git a/apps/web/src/liveClient.ts b/apps/web/src/liveClient.ts
new file mode 100644
index 0000000..6ba0509
--- /dev/null
+++ b/apps/web/src/liveClient.ts
@@ -0,0 +1,308 @@
+import type { MetricQueryRequest } from './metricClient';
+import type { LiveSample } from './liveBuffer';
+
+export type LiveStatus = 'subscribed' | 'resync-required' | 'paused' | 'unsubscribed';
+export type LiveEvent =
+ | { type: 'samples'; subscriptionId: string; sequence: number; samples: LiveSample[] }
+ | { type: 'status'; subscriptionId: string; state: LiveStatus; detail?: string }
+ | { type: 'error'; subscriptionId?: string; code: string; message: string };
+
+type SocketLike = {
+ readyState: number;
+ onopen: (() => void) | null;
+ onmessage: ((event: { data: unknown }) => void) | null;
+ onerror: (() => void) | null;
+ onclose: (() => void) | null;
+ send: (payload: string) => void;
+ close: () => void;
+};
+
+export type SocketFactory = (url: string) => SocketLike;
+export type LiveListener = (event: LiveEvent) => void;
+export type LiveSubscription = { key: string; unsubscribe: () => void };
+
+const defaultOpenTimeoutMs = 5000;
+const maxReconnectDelayMs = 10000;
+const subscriptionReleaseGraceMs = 250;
+const socketIdleCloseGraceMs = 10000;
+const heartbeatIntervalMs = 30000;
+
+export class LiveClientError extends Error {
+ readonly code: string;
+ constructor(code: string, message: string) { super(message); this.code = code; }
+}
+
+type SharedSubscription = {
+ key: string;
+ id: string;
+ request: MetricQueryRequest;
+ listeners: Set;
+ releaseTimer: ReturnType | null;
+ sent: boolean;
+ lastSequence: number;
+};
+
+function sortedValue(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(sortedValue);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, sortedValue(item)]));
+ }
+ return value;
+}
+
+export function liveQueryKey(request: MetricQueryRequest): string {
+ // `from` and `to` seed the historical query only. Once subscribed, the live
+ // sampler continuously evaluates the semantic metric at `stepSeconds`;
+ // rotating to a dashboard with the same metric must therefore share the
+ // existing stream instead of opening a new socket for a newer seed window.
+ const { range, ...semantic } = request;
+ return JSON.stringify(sortedValue({ ...semantic, range: { stepSeconds: range.stepSeconds } }));
+}
+
+function defaultSocketFactory(url: string): SocketLike {
+ const parsed = new URL(url, window.location.href);
+ parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
+ return new WebSocket(parsed.toString()) as unknown as SocketLike;
+}
+
+export class LiveClient {
+ private readonly url: string;
+ private readonly factory: SocketFactory;
+ private socket: SocketLike | null = null;
+ private opening: Promise | null = null;
+ private reconnectTimer: ReturnType | null = null;
+ private idleCloseTimer: ReturnType | null = null;
+ private heartbeatTimer: ReturnType | null = null;
+ private reconnectAttempt = 0;
+ private heartbeatSequence = 0;
+ private sequence = 0;
+ private hidden = false;
+ private readonly subscriptions = new Map();
+ private readonly byId = new Map();
+ private readonly visibilityHandler: (() => void) | null;
+
+ constructor(url = '/api/v1/live', factory: SocketFactory = defaultSocketFactory) {
+ this.url = url;
+ this.factory = factory;
+ if (typeof document !== 'undefined') {
+ this.hidden = document.visibilityState === 'hidden';
+ this.visibilityHandler = () => {
+ this.hidden = document.visibilityState === 'hidden';
+ this.resubscribeAll();
+ };
+ document.addEventListener('visibilitychange', this.visibilityHandler);
+ } else {
+ this.visibilityHandler = null;
+ }
+ }
+
+ subscribe(request: MetricQueryRequest, listener: LiveListener): LiveSubscription {
+ this.cancelIdleClose();
+ const key = liveQueryKey(request);
+ let shared = this.subscriptions.get(key);
+ if (!shared) {
+ shared = { key, id: 'browser-' + (++this.sequence), request, listeners: new Set(), releaseTimer: null, sent: false, lastSequence: 0 };
+ this.subscriptions.set(key, shared);
+ this.byId.set(shared.id, shared);
+ }
+ if (shared.releaseTimer) clearTimeout(shared.releaseTimer);
+ shared.releaseTimer = null;
+ shared.listeners.add(listener);
+ let active = true;
+ void this.ensureOpen().then(() => {
+ if (active && this.subscriptions.get(key) === shared) this.sendSubscribe(shared);
+ }).catch((error: unknown) => {
+ this.notify(shared as SharedSubscription, { type: 'error', subscriptionId: shared?.id, code: error instanceof LiveClientError ? error.code : 'LIVE_CONNECTION_UNAVAILABLE', message: error instanceof Error ? error.message : 'Live verbinding is niet beschikbaar.' });
+ this.scheduleReconnect();
+ });
+ return { key, unsubscribe: () => {
+ if (!active) return;
+ active = false;
+ this.removeListener(shared as SharedSubscription, listener);
+ }};
+ }
+
+ /**
+ * Drops shared subscriptions that no longer have listeners. `removeListener`
+ * already does this on the happy path; this is the safety net for a component
+ * tree that unmounts without a matching unsubscribe (dashboard rotation on a
+ * wallboard, a widget that threw), so neither map grows for the lifetime of
+ * the process.
+ */
+ releaseUnused(): void {
+ [...this.subscriptions.values()].forEach((shared) => {
+ if (shared.listeners.size > 0) return;
+ this.scheduleRelease(shared);
+ });
+ }
+
+ close(): void {
+ this.stopHeartbeat();
+ this.cancelIdleClose();
+ this.subscriptions.forEach((shared) => {
+ if (shared.releaseTimer) clearTimeout(shared.releaseTimer);
+ shared.releaseTimer = null;
+ });
+ this.subscriptions.clear();
+ this.byId.clear();
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ this.reconnectAttempt = 0;
+ const socket = this.socket;
+ this.socket = null;
+ this.opening = null;
+ socket?.close();
+ }
+
+ private removeListener(shared: SharedSubscription, listener: LiveListener): void {
+ shared.listeners.delete(listener);
+ if (shared.listeners.size > 0) return;
+ this.scheduleRelease(shared);
+ }
+
+ private scheduleRelease(shared: SharedSubscription): void {
+ if (shared.releaseTimer) return;
+ shared.releaseTimer = setTimeout(() => {
+ shared.releaseTimer = null;
+ if (shared.listeners.size > 0 || this.subscriptions.get(shared.key) !== shared) return;
+ this.subscriptions.delete(shared.key);
+ this.byId.delete(shared.id);
+ if (shared.sent && this.socket?.readyState === 1) {
+ this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
+ }
+ if (this.subscriptions.size === 0 && this.socket) this.scheduleIdleClose();
+ }, subscriptionReleaseGraceMs);
+ }
+
+ private scheduleIdleClose(): void {
+ if (this.idleCloseTimer || !this.socket) return;
+ this.idleCloseTimer = setTimeout(() => {
+ this.idleCloseTimer = null;
+ if (this.subscriptions.size === 0) this.close();
+ }, socketIdleCloseGraceMs);
+ }
+
+ private cancelIdleClose(): void {
+ if (this.idleCloseTimer) clearTimeout(this.idleCloseTimer);
+ this.idleCloseTimer = null;
+ }
+
+ private ensureOpen(): Promise {
+ this.cancelIdleClose();
+ if (this.socket?.readyState === 1) return Promise.resolve();
+ if (this.opening) return this.opening;
+ const socket = this.factory(this.url);
+ this.socket = socket;
+ this.opening = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ socket.close();
+ reject(new LiveClientError('LIVE_CONNECTION_TIMEOUT', 'Live verbinding reageert niet.'));
+ }, defaultOpenTimeoutMs);
+ socket.onopen = () => {
+ clearTimeout(timer);
+ this.reconnectAttempt = 0;
+ this.startHeartbeat();
+ resolve();
+ this.byId.forEach((shared) => this.sendSubscribe(shared));
+ };
+ socket.onerror = () => { clearTimeout(timer); reject(new LiveClientError('LIVE_CONNECTION_UNAVAILABLE', 'Live verbinding is niet beschikbaar.')); };
+ socket.onclose = () => {
+ clearTimeout(timer);
+ this.stopHeartbeat();
+ if (this.socket === socket) this.socket = null;
+ this.byId.forEach((shared) => {
+ shared.sent = false;
+ shared.lastSequence = 0;
+ this.notify(shared, { type: 'status', subscriptionId: shared.id, state: 'resync-required', detail: 'Live verbinding wordt hersteld.' });
+ });
+ if (this.subscriptions.size > 0) this.scheduleReconnect();
+ };
+ socket.onmessage = (event) => this.handleMessage(event.data);
+ }).finally(() => {
+ if (this.opening) this.opening = null;
+ });
+ return this.opening;
+ }
+
+ private scheduleReconnect(): void {
+ if (this.reconnectTimer || this.subscriptions.size === 0) return;
+ const delay = Math.min(maxReconnectDelayMs, 1000 * (2 ** this.reconnectAttempt));
+ this.reconnectAttempt += 1;
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = null;
+ void this.ensureOpen().catch(() => this.scheduleReconnect());
+ }, delay);
+ }
+
+ private startHeartbeat(): void {
+ this.stopHeartbeat();
+ this.heartbeatTimer = setInterval(() => {
+ if (this.socket?.readyState !== 1 || this.subscriptions.size === 0) return;
+ this.heartbeatSequence += 1;
+ this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'ping', nonce: 'browser-' + this.heartbeatSequence }));
+ }, heartbeatIntervalMs);
+ }
+
+ private stopHeartbeat(): void {
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = null;
+ }
+
+ private effectiveInterval(shared: SharedSubscription): number {
+ const requested = Math.max(1, Math.min(300, Math.round(shared.request.range.stepSeconds)));
+ return this.hidden ? Math.min(300, Math.max(requested, requested * 5)) : requested;
+ }
+
+ private sendSubscribe(shared: SharedSubscription): void {
+ if (shared.sent || this.socket?.readyState !== 1) return;
+ shared.sent = true;
+ this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'subscribe', subscriptionId: shared.id, query: shared.request, intervalSeconds: this.effectiveInterval(shared) }));
+ }
+
+ private resubscribeAll(): void {
+ if (this.socket?.readyState !== 1) return;
+ this.byId.forEach((shared) => {
+ if (shared.sent) {
+ this.socket?.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
+ shared.sent = false;
+ shared.lastSequence = 0;
+ }
+ this.sendSubscribe(shared);
+ });
+ }
+
+ private requestResync(shared: SharedSubscription): void {
+ this.notify(shared, { type: 'status', subscriptionId: shared.id, state: 'resync-required', detail: 'Live data bevat een gat; synchronisatie wordt herhaald.' });
+ if (this.socket?.readyState === 1 && shared.sent) {
+ this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
+ shared.sent = false;
+ }
+ shared.lastSequence = 0;
+ this.sendSubscribe(shared);
+ }
+
+ private handleMessage(data: unknown): void {
+ if (typeof data !== 'string') return;
+ let message: Record;
+ try { message = JSON.parse(data) as Record; } catch { return; }
+ const id = typeof message.subscriptionId === 'string' ? message.subscriptionId : undefined;
+ const shared = id ? this.byId.get(id) : undefined;
+ if (message.type === 'samples' && shared && Array.isArray(message.samples) && typeof message.sequence === 'number') {
+ if (message.sequence <= shared.lastSequence) return;
+ if (shared.lastSequence > 0 && message.sequence > shared.lastSequence + 1) {
+ this.requestResync(shared);
+ return;
+ }
+ shared.lastSequence = message.sequence;
+ this.notify(shared, { type: 'samples', subscriptionId: shared.id, sequence: message.sequence, samples: message.samples as LiveSample[] });
+ } else if (message.type === 'status' && shared && typeof message.state === 'string') {
+ this.notify(shared, { type: 'status', subscriptionId: shared.id, state: message.state as LiveStatus, detail: typeof message.detail === 'string' ? message.detail : undefined });
+ } else if (message.type === 'error' && shared) {
+ this.notify(shared, { type: 'error', subscriptionId: shared.id, code: String(message.code ?? 'LIVE_ERROR'), message: String(message.message ?? 'Live fout.') });
+ }
+ }
+
+ private notify(shared: SharedSubscription, event: LiveEvent): void {
+ [...shared.listeners].forEach((listener) => listener(event));
+ }
+}
diff --git a/apps/web/src/locale.ts b/apps/web/src/locale.ts
new file mode 100644
index 0000000..46e87d0
--- /dev/null
+++ b/apps/web/src/locale.ts
@@ -0,0 +1,33 @@
+export const UI_LOCALE = 'nl-BE';
+export const UI_TIME_ZONE = 'Europe/Brussels';
+
+const numberFormatter = new Intl.NumberFormat(UI_LOCALE);
+const decimalFormatter = new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits: 1 });
+const dateTimeFormatter = new Intl.DateTimeFormat(UI_LOCALE, { dateStyle: 'medium', timeStyle: 'short', timeZone: UI_TIME_ZONE });
+export const NEVER_RECEIVED = 'Nooit ontvangen';
+
+export function formatNumber(value: number, maximumFractionDigits?: number): string {
+ if (!Number.isFinite(value)) return '—';
+ return (maximumFractionDigits === undefined ? numberFormatter : new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits })).format(value);
+}
+
+export function formatDecimal(value: number): string {
+ return Number.isFinite(value) ? decimalFormatter.format(value) : '—';
+}
+
+export function formatDateTime(value?: string): string {
+ if (!hasReceivedTimestamp(value)) return NEVER_RECEIVED;
+ const date = new Date(value);
+ return dateTimeFormatter.format(date);
+}
+
+/** Rejects transport zero-values and invalid input before they reach a visible . */
+export function hasReceivedTimestamp(value?: string): value is string {
+ if (!value?.trim()) return false;
+ const date = new Date(value);
+ return Number.isFinite(date.valueOf()) && date.valueOf() > 0 && date.getUTCFullYear() > 1;
+}
+
+export function formatPercent(value?: number, maximumFractionDigits = 1): string {
+ return value === undefined || !Number.isFinite(value) ? '—' : new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits, style: 'percent' }).format(value / 100);
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..cf91907
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import './styles.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/apps/web/src/metricClient.ts b/apps/web/src/metricClient.ts
new file mode 100644
index 0000000..0c83786
--- /dev/null
+++ b/apps/web/src/metricClient.ts
@@ -0,0 +1,40 @@
+import { UI_LOCALE, UI_TIME_ZONE } from './locale';
+export type MetricFreshness = 'fresh' | 'delayed' | 'stale' | 'unavailable';
+export type MetricRangePreset = 'live' | '15m' | '1h' | '6h' | '24h' | '7d';
+export type MetricRange = { from: string; to: string; stepSeconds: number };
+export type MetricQueryRequest = { metric: string; scope?: Record; range: MetricRange; aggregation?: string; groupBy?: string[]; maxSeries?: number; maxPoints?: number };
+export type MetricInspector = { semanticMetric: string; generatedQuery: string; cost: { series: number; points: number; estimatedSamples: number }; limits: { maxSeries: number; maxPoints: number } };
+export type MetricQueryResponse = { status: string; data: unknown; warnings?: string[]; provenance: { source: string; metric: string; catalogVersion: string; cacheKey: string }; sourceObservedAt: string; receivedAt: string; freshness: MetricFreshness; cacheHit: boolean; inspector?: MetricInspector };
+export type MetricProblem = { code: string; detail: string; fields?: Record };
+
+export class MetricApiError extends Error {
+ constructor(readonly status: number, readonly problem?: MetricProblem) { super(problem?.detail ?? ((status >= 500 || status === 404 || status === 0) ? 'De metricbron is tijdelijk niet beschikbaar.' : 'Metricquery mislukt.')); }
+ get actionable(): string { if (this.status >= 500 || this.status === 404) return 'De metricbron is tijdelijk niet beschikbaar.'; if (this.problem?.code === 'QUERY_POINT_LIMIT' || this.problem?.code === 'QUERY_SERIES_LIMIT' || this.problem?.code === 'QUERY_COST_LIMIT') return 'Verklein de periode of beperk het aantal reeksen.'; return this.message; }
+}
+
+export class MetricClient {
+ // Resolve the ambient fetch at request time. Runtime widget modules are
+ // evaluated before App installs the shared session watcher; capturing the
+ // native function here would bypass that wrapper and can also invoke an
+ // unbound browser fetch implementation.
+ constructor(private readonly fetcher: typeof fetch = (input, init) => fetch(input, init)) {}
+ async queryRange(request: MetricQueryRequest, signal?: AbortSignal): Promise {
+ const response = await this.fetcher('/api/v1/metrics/query-range', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), signal });
+ if (!response.ok) {
+ let problem: MetricProblem | undefined;
+ try { problem = await response.json() as MetricProblem; } catch { /* safe fallback below */ }
+ throw new MetricApiError(response.status, problem);
+ }
+ return await response.json() as MetricQueryResponse;
+ }
+}
+
+export function rangeForPreset(preset: MetricRangePreset, now = new Date()): MetricRange {
+ const seconds: Record = { live: 300, '15m': 900, '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800 };
+ const to = new Date(now.getTime());
+ const from = new Date(to.getTime() - seconds[preset] * 1000);
+ const stepSeconds = preset === 'live' ? 15 : Math.max(15, Math.ceil(seconds[preset] / 4000));
+ return { from: from.toISOString(), to: to.toISOString(), stepSeconds };
+}
+
+export function displayTime(iso: string, locale = UI_LOCALE): string { const date = new Date(iso); return Number.isNaN(date.getTime()) ? 'Onbekende tijd' : new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeStyle: 'short', timeZone: UI_TIME_ZONE }).format(date); }
diff --git a/apps/web/src/overviewSignals.ts b/apps/web/src/overviewSignals.ts
new file mode 100644
index 0000000..1f9e0df
--- /dev/null
+++ b/apps/web/src/overviewSignals.ts
@@ -0,0 +1,57 @@
+export type SignalTone = 'healthy' | 'attention' | 'critical' | 'stale' | 'unknown';
+
+export type SignalSource = {
+ state?: string;
+ freshness?: string;
+};
+
+export type SignalContainer = {
+ state?: string;
+ health?: string;
+ intentionalStop?: boolean;
+};
+
+export const signalToneRank: Record = {
+ critical: 0,
+ attention: 1,
+ stale: 2,
+ unknown: 3,
+ healthy: 4,
+};
+
+export function signalToneFromState(state: string | undefined): SignalTone {
+ const normalized = state?.trim().toLowerCase();
+ if (normalized === 'critical' || normalized === 'faulted' || normalized === 'down' || normalized === 'failed' || normalized === 'error') return 'critical';
+ if (normalized === 'attention' || normalized === 'degraded' || normalized === 'warning' || normalized === 'restarting' || normalized === 'paused' || normalized === 'removing') return 'attention';
+ if (normalized === 'stale') return 'stale';
+ if (normalized === 'healthy' || normalized === 'normal' || normalized === 'up' || normalized === 'running' || normalized === 'fresh') return 'healthy';
+ return 'unknown';
+}
+
+export function worstSignalTone(tones: SignalTone[]): SignalTone {
+ if (tones.length === 0) return 'unknown';
+ return tones.reduce((worst, tone) => signalToneRank[tone] < signalToneRank[worst] ? tone : worst, 'healthy');
+}
+
+/** A successful HTTP response is not healthy when its provenance is stale or unknown. */
+export function sourceSignalTone(source: SignalSource | undefined): SignalTone {
+ if (!source) return 'unknown';
+ const freshness = source.freshness?.trim().toLowerCase();
+ if (freshness === 'stale') return 'stale';
+ if (freshness !== 'fresh') return 'unknown';
+ return signalToneFromState(source.state);
+}
+
+/** Mirrors the backend application-state projection without treating an intentional stop as a failure. */
+export function containerSignalTone(container: SignalContainer): SignalTone {
+ const state = container.state?.trim().toLowerCase();
+ const health = container.health?.trim().toLowerCase();
+ if (state === 'running') {
+ if (health === 'healthy') return 'healthy';
+ if (health === 'unhealthy') return 'attention';
+ return 'unknown';
+ }
+ if (state === 'restarting' || state === 'paused' || state === 'removing') return 'attention';
+ if (state === 'exited' || state === 'dead' || state === 'stopped') return container.intentionalStop ? 'unknown' : 'critical';
+ return 'unknown';
+}
diff --git a/apps/web/src/presentation.ts b/apps/web/src/presentation.ts
new file mode 100644
index 0000000..e57f8f2
--- /dev/null
+++ b/apps/web/src/presentation.ts
@@ -0,0 +1,181 @@
+import { copy } from './copy';
+
+const statusLabels: Record = {
+ healthy: copy.presentation.status.healthy,
+ operational: copy.presentation.status.operational,
+ available: copy.presentation.status.available,
+ running: copy.presentation.status.running,
+ paused: copy.presentation.status.paused,
+ starting: copy.presentation.status.starting,
+ exited: copy.presentation.status.stopped,
+ stopped: copy.presentation.status.stopped,
+ restarting: copy.presentation.status.restarting,
+ down: copy.presentation.status.down,
+ up: copy.presentation.status.up,
+ online: copy.presentation.status.online,
+ offline: copy.presentation.status.offline,
+ missing: copy.presentation.status.missing,
+ faulted: copy.presentation.status.faulted,
+ sleeping: copy.presentation.status.sleeping,
+ idle: copy.presentation.status.idle,
+ completed: copy.presentation.status.completed,
+ failed: copy.presentation.status.failed,
+ cached: copy.presentation.status.cached,
+ passed: 'Geslaagd',
+ unsupported: 'Niet ondersteund',
+ valid: 'Geldig',
+ invalid: 'Ongeldig',
+ unhealthy: copy.presentation.status.unhealthy,
+ attention: copy.presentation.status.attention,
+ degraded: copy.presentation.status.degraded,
+ warning: copy.presentation.status.warning,
+ info: copy.presentation.status.info,
+ error: copy.presentation.status.error,
+ critical: copy.presentation.status.critical,
+ unknown: copy.presentation.status.unknown,
+ unavailable: copy.presentation.status.unavailable,
+ disabled: copy.presentation.status.disabled,
+ normal: copy.presentation.status.healthy,
+ stale: copy.presentation.status.stale,
+ fresh: copy.presentation.status.fresh,
+ pending: copy.presentation.status.pending,
+ firing: copy.presentation.status.firing,
+ acknowledged: copy.presentation.status.acknowledged,
+ resolved: copy.presentation.status.resolved,
+ silenced: copy.presentation.status.silenced,
+ suppressed: copy.presentation.status.suppressed,
+ maintenance: copy.presentation.status.maintenance,
+ active: copy.presentation.status.active,
+ scheduled: copy.presentation.status.scheduled,
+ expired: copy.presentation.status.expired,
+ revoked: copy.presentation.status.revoked,
+};
+
+const reasonLabels: Record = {
+ source_health_unknown: copy.presentation.reason.sourceHealthUnknown,
+ source_unavailable: copy.presentation.reason.sourceUnavailable,
+ not_configured: copy.presentation.reason.notConfigured,
+ last_run_failed: copy.presentation.reason.lastRunFailed,
+ last_run_succeeded: copy.presentation.reason.lastRunSucceeded,
+ no_recent_sample: copy.presentation.reason.noRecentSample,
+ source_stale: copy.presentation.reason.stale,
+ stale_probe: copy.presentation.reason.stale,
+ stale: copy.presentation.reason.stale,
+ stale_source: copy.presentation.reason.stale,
+ filesystem_root_not_configured: copy.presentation.reason.filesystemRootNotConfigured,
+ unavailable: copy.presentation.reason.sourceUnavailable,
+ insufficient_history: copy.presentation.reason.insufficientHistory,
+ host_unreachable: copy.presentation.reason.hostUnreachable,
+ dns_failure: copy.presentation.reason.dnsFailure,
+ source_unavailable_dependency: copy.presentation.reason.sourceUnavailable,
+ backup_verified: copy.presentation.reason.backupVerified,
+ backup_stale: copy.presentation.reason.backupStale,
+ backup_verification_failed: copy.presentation.reason.backupVerificationFailed,
+ authenticated_session: copy.presentation.reason.authenticatedSession,
+ authenticated_session_not_observed: copy.presentation.reason.authenticatedSessionNotObserved,
+ database_ready: copy.presentation.reason.databaseReady,
+ source_sampled: copy.presentation.reason.sourceSampled,
+ provider_health_not_sampled: copy.presentation.reason.providerNotSampled,
+ source_health_not_sampled: copy.presentation.reason.sourceNotSampled,
+ source_not_configured: copy.presentation.reason.notConfigured,
+ delivery_health_not_sampled: copy.presentation.reason.notificationsNotSampled,
+ probe_heartbeat_not_recorded: copy.presentation.reason.probesNotRecorded,
+ heartbeat_not_recorded: copy.presentation.reason.workerNotRecorded,
+ no_verified_backup: copy.presentation.reason.noVerifiedBackup,
+ none: copy.presentation.reason.noDetail,
+};
+
+const componentLabels: Record = {
+ api: 'API', database: copy.presentation.component.database, worker: copy.presentation.component.worker,
+ prometheus: 'Prometheus', query: copy.presentation.component.query, unraid: 'Unraid',
+ storage: copy.presentation.component.storage, backup: copy.presentation.component.backup,
+ notifications: copy.presentation.component.notifications, oidc: copy.presentation.component.oidc,
+ probes: copy.presentation.component.probes,
+};
+
+const metricLabels: Record = {
+ 'host.cpu.utilization': copy.presentation.metric.hostCpu,
+ 'host.memory.utilization': copy.presentation.metric.hostMemory,
+ 'container.cpu.utilization': copy.presentation.metric.containerCpu,
+ 'container.memory.used': copy.presentation.metric.containerMemory,
+ 'storage.disk.temperature': copy.presentation.metric.diskTemperature,
+ 'storage.disk.temperature.maximum': copy.presentation.metric.maximumDiskTemperature,
+ 'storage.pool.utilization': copy.presentation.metric.poolUtilization,
+ 'service.response_time': copy.presentation.metric.serviceResponseTime,
+ 'service.availability': copy.presentation.metric.serviceAvailability,
+ 'service.availability.minimum': copy.presentation.metric.minimumServiceAvailability,
+};
+
+export function presentStatus(value?: string): string {
+ const normalized = value?.trim().toLowerCase() ?? '';
+ return statusLabels[normalized] ?? copy.presentation.status.unknown;
+}
+
+const operationalRank: Record = { healthy: 0, normal: 0, attention: 1, degraded: 2, unknown: 3, faulted: 4, critical: 4 };
+
+/** Keeps device health and capacity separate while making the worst signal primary. */
+export function operationalStorageState(deviceState?: string, capacityState?: string): string {
+ const device = deviceState?.trim().toLowerCase() || 'unknown';
+ const capacity = capacityState?.trim().toLowerCase() || 'unknown';
+ return (operationalRank[capacity] ?? operationalRank.unknown) > (operationalRank[device] ?? operationalRank.unknown) ? capacity : device;
+}
+
+export function presentReason(value?: string): string {
+ const normalized = value?.trim().toLowerCase() ?? '';
+ if (!normalized) return copy.presentation.reason.noDetail;
+ if (reasonLabels[normalized]) return reasonLabels[normalized];
+ if (normalized.startsWith('container_')) return `${copy.presentation.reason.containerPrefix} ${presentStatus(normalized.slice(10)).toLowerCase()}.`;
+ if (normalized.startsWith('service_')) return `${copy.presentation.reason.servicePrefix} ${presentStatus(normalized.slice(8)).toLowerCase()}.`;
+ if (/^[a-z0-9]+(?:[._-][a-z0-9]+)+$/.test(normalized)) return copy.presentation.reason.technical;
+ return value?.trim() || copy.presentation.reason.noDetail;
+}
+
+export function presentComponent(value: string): string {
+ return componentLabels[value.trim().toLowerCase()] ?? copy.presentation.component.other;
+}
+
+export function presentMetric(value: string): string {
+ return metricLabels[value] ?? copy.presentation.metric.other;
+}
+
+const entityLabels: Record = { host: 'Host', container: 'Container', 'container-service': 'Compose-service', 'container-instance': 'Containerinstantie', application: 'Applicatie', 'application-project': 'Compose-project', 'application-instance': 'Zelfstandige applicatie', service: 'Service', probe: 'Servicecontrole', disk: 'Schijf', pool: 'Pool', share: 'Share', array: 'Array', process: 'Proces', network: 'Netwerk' };
+export function presentEntityType(value?: string): string { return entityLabels[value?.trim().toLowerCase() ?? ''] ?? 'Onderdeel'; }
+
+const inventoryFieldLabels: Record = { runtimeState: 'Runtime-status', health: 'Gezondheid', restartCount: 'Herstarts', intentionalStop: 'Bewust gestopt', metricsAvailable: 'Metrics beschikbaar', lifecycleAvailable: 'Lifecycle beschikbaar', image: 'Image', project: 'Compose-project', composeService: 'Compose-service', groupingMode: 'Groepering', componentCount: 'Componenten' };
+export function presentInventoryField(value: string): string { return inventoryFieldLabels[value] ?? 'Bronkenmerk'; }
+
+const relationLabels: Record = { depends_on: 'is afhankelijk van', depends: 'is afhankelijk van', backs: 'ondersteunt', exposes: 'biedt aan', contains: 'bevat', member_of: 'is lid van', runs_on: 'draait op', connected_to: 'is verbonden met' };
+export function presentRelationType(value?: string): string { return relationLabels[value?.trim().toLowerCase() ?? ''] ?? 'heeft een relatie met'; }
+
+const arrayRoleLabels: Record = { data: 'Gegevensschijf', parity: 'Pariteit', cache: 'Cache', member: 'Lid' };
+export function presentArrayRole(value?: string): string { return arrayRoleLabels[value?.trim().toLowerCase() ?? ''] ?? 'Schijf'; }
+
+const storagePolicyLabels: Record = { highwater: 'Hoogwater', 'high-water': 'Hoogwater', fillup: 'Opvullen', 'fill-up': 'Opvullen', mostfree: 'Meeste vrije ruimte', 'most-free': 'Meeste vrije ruimte', yes: 'Voorkeur voor cache', no: 'Alleen primaire opslag', prefer: 'Cache heeft voorkeur', only: 'Alleen cache' };
+export function presentStoragePolicy(value?: string, fallback = 'Onbekend beleid'): string { return storagePolicyLabels[value?.trim().toLowerCase() ?? ''] ?? fallback; }
+
+const eventLabels: Record = {
+ 'container.state_changed': 'Containerstatus gewijzigd', 'container.health_changed': 'Containergezondheid gewijzigd', 'container.restart': 'Container herstart', 'container.intentional_stop_changed': 'Bewuste stop gewijzigd',
+ 'array.degraded': 'Array vraagt aandacht', 'array.missing': 'Arraylid ontbreekt', 'array.recovered': 'Array hersteld', 'array.parity_changed': 'Paritystatus gewijzigd',
+ 'pool.degraded': 'Pool vraagt aandacht', 'pool.faulted': 'Pool defect', 'pool.recovered': 'Pool hersteld', 'pool.scrub_failed': 'Poolscrub mislukt',
+};
+export function presentEventType(value?: string): string { return eventLabels[value?.trim().toLowerCase() ?? ''] ?? 'Operationele wijziging'; }
+
+const eventSummaries: Record = {
+ 'container.state_changed': 'De runtime-status van de container is gewijzigd.', 'container.health_changed': 'De gerapporteerde containergezondheid is gewijzigd.', 'container.restart': 'De container is opnieuw gestart.', 'container.intentional_stop_changed': 'De markering voor een bewuste stop is gewijzigd.',
+ 'array.degraded': 'De array meldt een toestand die aandacht vereist.', 'array.missing': 'De array meldt een ontbrekend lid.', 'array.recovered': 'De array is terug operationeel.', 'array.parity_changed': 'De paritystatus of het aantal parityfouten is gewijzigd.',
+ 'pool.degraded': 'De pool meldt een toestand die aandacht vereist.', 'pool.faulted': 'De pool meldt een defecte toestand.', 'pool.recovered': 'De pool is hersteld.', 'pool.scrub_failed': 'De laatste poolscrub is mislukt.',
+};
+export function presentEventSummary(type?: string, _summary?: string): string { return eventSummaries[type?.trim().toLowerCase() ?? ''] ?? 'Een bron heeft een operationele wijziging gemeld.'; }
+
+export function presentUnit(value: string): string {
+ if (value === 'percent') return '%';
+ if (value === 'bytes') return copy.presentation.unit.bytes;
+ if (value === 'celsius') return '°C';
+ if (value === 'seconds') return copy.presentation.unit.seconds;
+ if (value === 'ratio') return copy.presentation.unit.ratio;
+ return copy.presentation.unit.value;
+}
+
+export function plural(count: number, singular: string, pluralForm: string): string {
+ return count === 1 ? singular : pluralForm;
+}
diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts
new file mode 100644
index 0000000..598570b
--- /dev/null
+++ b/apps/web/src/routes.ts
@@ -0,0 +1,15 @@
+export type RoutePath = string;
+
+export const routeFromLocation = (pathname: string): RoutePath => {
+ if (pathname.startsWith('/dashboards/') && pathname.length > '/dashboards/'.length) return pathname;
+ if (pathname.startsWith('/containers/') && pathname.length > '/containers/'.length) return pathname;
+ if (pathname.startsWith('/services/') && pathname.length > '/services/'.length) return pathname;
+ if (pathname.startsWith('/disks/') && pathname.length > '/disks/'.length) return pathname;
+ if (pathname.startsWith('/pools/') && pathname.length > '/pools/'.length) return pathname;
+ if (pathname.startsWith('/shares/') && pathname.length > '/shares/'.length) return pathname;
+ if (pathname.startsWith('/applications/') && pathname.length > '/applications/'.length) return pathname;
+ if (pathname.startsWith('/incidents/') && pathname.length > '/incidents/'.length) return pathname;
+ if (pathname.startsWith('/inventory/') && pathname.length > '/inventory/'.length) return pathname;
+ const supported = ['/', '/topology', '/network', '/host', '/array', '/disks', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/applications', '/inventory', '/dashboards', '/wallboard', '/alerts', '/events', '/incidents', '/settings', '/status', '/onboarding', '/loading', '/error', '/unauthorized', '/404'];
+ return supported.includes(pathname) ? pathname : '/404';
+};
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
new file mode 100644
index 0000000..938e24a
--- /dev/null
+++ b/apps/web/src/styles.css
@@ -0,0 +1,1478 @@
+:root {
+ color: #f9fafb;
+ background: #0b1018;
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --bg: #0b1018;
+ --surface: #111319;
+ --surface-raised: #131c28;
+ --border: #243245;
+ --text: #f9fafb;
+ --muted: #94a3b8;
+ --accent: #69a7ff;
+ --unknown: #91a1b5;
+ --ready: #4fd19b;
+ --attention: #f2b84b;
+ --critical: #f16d75;
+}
+
+* { box-sizing: border-box; }
+body { margin: 0; min-width: 320px; background: var(--bg); }
+button, a { font: inherit; }
+button { cursor: pointer; }
+.skip-link { position: absolute; left: 1rem; top: -5rem; z-index: 2; padding: .7rem 1rem; border-radius: .5rem; background: var(--accent); color: #08111e; font-weight: 700; }
+.skip-link:focus { top: 1rem; }
+.app-shell { display: grid; grid-template-columns: 17rem 1fr; min-height: 100vh; }
+.sidebar { display: flex; flex-direction: column; gap: 2.5rem; padding: 2rem 1.25rem; border-right: 1px solid var(--border); background: #0e1622; }
+.brand { display: flex; align-items: center; gap: .75rem; color: var(--text); text-decoration: none; }
+.brand strong, .brand small { display: block; }
+.brand small { margin-top: .2rem; color: var(--muted); font-size: .72rem; }
+.brand-mark { display: grid; width: 2.2rem; height: 2.2rem; place-items: center; border-radius: .65rem; background: var(--accent); color: #091321; font-weight: 800; }
+.nav-list { display: grid; gap: .35rem; margin: 0; padding: 0; list-style: none; }
+.nav-link { display: block; padding: .75rem .85rem; border-radius: .5rem; color: var(--muted); text-decoration: none; }
+.nav-link:hover, .nav-link:focus-visible { background: var(--surface-raised); color: var(--text); }
+.nav-link--active { background: #1b3454; color: var(--text); font-weight: 700; }
+.sidebar-status { margin-top: auto; padding: 1rem; border: 1px solid var(--border); border-radius: .7rem; color: var(--muted); font-size: .78rem; line-height: 1.5; }
+.sidebar-status .status-badge { margin-bottom: .6rem; }
+.content { width: min(100%, 78rem); padding: clamp(2rem, 5vw, 5rem); }
+.page-intro { max-width: 48rem; margin-bottom: 2.5rem; }
+.eyebrow, .card-kicker { margin: 0 0 .75rem; color: var(--accent); font-size: .75rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; }
+h1, h2, p { margin-top: 0; }
+h1 { margin-bottom: 1rem; font-size: clamp(2.2rem, 5vw, 4rem); letter-spacing: -.04em; line-height: 1.05; }
+h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
+.intro, .card-copy { max-width: 44rem; color: var(--muted); line-height: 1.7; }
+.card-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(16rem, .8fr); gap: 1rem; }
+.card { padding: 1.5rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); box-shadow: 0 1rem 3rem #05080d33; }
+.card--wide { min-height: 13rem; }
+.card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
+.status-badge { display: inline-flex; align-items: center; gap: .45rem; width: fit-content; padding: .35rem .6rem; border-radius: 99rem; background: #202a35; color: var(--unknown); font-size: .78rem; font-weight: 750; white-space: nowrap; }
+.status-badge--ready { background: #173b29; color: var(--ready); }
+.status-badge--attention { background: #402b1d; color: var(--attention); }
+.status-badge--critical { background: color-mix(in srgb, var(--critical) 18%, var(--surface)); color: var(--critical); }
+.status-icon { display: inline-grid; width: 1.05rem; height: 1.05rem; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-size: .7rem; }
+.button { margin-top: 1rem; padding: .7rem 1rem; border: 1px solid var(--accent); border-radius: .5rem; background: var(--accent); color: #091321; font-weight: 750; }
+.button:hover, .button:focus-visible { filter: brightness(1.08); }
+.button--secondary { border-color: var(--border); background: transparent; color: var(--text); }
+.empty-state { display: grid; min-height: 15rem; place-items: center; align-content: center; gap: .8rem; text-align: center; }
+.empty-state h2 { max-width: 25rem; }
+.empty-state-icon, .state-icon { color: var(--accent); font-size: 2.5rem; }
+.settings-list { max-width: 48rem; padding: 0 1.5rem; }
+.settings-overview { max-width: 72rem; padding-block: 1.25rem; }
+.settings-hub { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; max-width: 72rem; margin-top: 1rem; }
+.settings-hub-card { min-width: 0; }
+.settings-hub-card h3 { margin: 0 0 .4rem; font-size: 1.08rem; }
+.settings-hub-card ul { display: grid; margin: 1rem 0 0; padding: 0; list-style: none; }
+.settings-hub-card li { border-top: 1px solid var(--border); }
+.settings-hub-card a { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 5.25rem; padding: .8rem 0; color: var(--text); text-decoration: none; }
+.settings-hub-card a:hover strong, .settings-hub-card a:focus-visible strong { color: var(--accent); }
+.settings-hub-card a > span:first-child { display: grid; gap: .25rem; min-width: 0; }
+.settings-hub-card small { color: var(--muted); line-height: 1.4; }
+.settings-access { display: grid; flex: none; gap: .25rem; justify-items: end; max-width: 8rem; color: var(--muted); font-size: .72rem; line-height: 1.35; text-align: right; }
+.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1.2rem 0; border-bottom: 1px solid var(--border); }
+.setting-row:last-child { border-bottom: 0; }
+.setting-row strong { font-size: .9rem; }
+.state-page { display: grid; min-height: 65vh; place-items: center; align-content: center; text-align: center; }
+.state-page p { max-width: 28rem; color: var(--muted); line-height: 1.6; }
+.state-page-actions, .overview-status-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .7rem; margin-top: 1rem; }
+.overview-status-actions { justify-content: flex-start; }
+.compact-list { display: grid; gap: .4rem; margin: .6rem 0 0; padding-left: 1.1rem; color: var(--muted); font-size: .85rem; }
+.compact-list strong { color: var(--text); }
+.auth-banner { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-bottom: 1rem; padding: .85rem 1rem; border: 1px solid var(--unknown); border-radius: .65rem; background: var(--surface); color: var(--text); }
+.auth-banner-icon { display: inline-flex; align-items: center; justify-content: center; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--unknown); color: #08111e; font-weight: 800; }
+.auth-banner-text { display: grid; gap: .2rem; margin-right: auto; }
+.auth-banner-text span { color: var(--muted); }
+.auth-banner .button { margin-top: 0; }
+@media (max-width: 700px) { .auth-banner { align-items: stretch; flex-direction: column; } .auth-banner .button { width: 100%; text-align: center; } }
+.inventory-panel { margin-top: 1rem; max-width: 60rem; }
+.inventory-list { display: grid; gap: .25rem; margin: 1.5rem 0 0; padding: 0; list-style: none; }
+.inventory-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 0; border-top: 1px solid var(--border); }
+.inventory-list small { display: block; margin-top: .25rem; color: var(--muted); }
+.inventory-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
+.inventory-summary h2 { margin-bottom: .2rem; font-size: 2rem; }
+.inventory-filters { display: grid; grid-template-columns: minmax(14rem, 2fr) repeat(3, minmax(8rem, 1fr)); gap: .75rem; margin: 1.25rem 0; }
+.inventory-filters label { display: grid; gap: .4rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
+.inventory-filters input, .inventory-filters select { width: 100%; min-height: 2.7rem; padding: .6rem .75rem; border: 1px solid var(--border); border-radius: .5rem; background: var(--surface-raised); color: var(--text); }
+.inventory-entity-list .entity-link { min-width: 0; flex: 1; color: var(--text); text-decoration: none; }
+.inventory-entity-list .entity-link:hover strong, .inventory-entity-list .entity-link:focus-visible strong { color: var(--accent); }
+.inventory-row-meta { display: block; margin-top: .4rem; color: var(--muted); font-size: .75rem; }
+.inventory-back { display: inline-block; color: var(--accent); text-decoration: none; }
+.inventory-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 2rem; margin-bottom: 2rem; }
+.inventory-detail-header h1 { margin-bottom: .4rem; }
+.inventory-effective { margin-bottom: 1rem; }
+.inventory-value-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .7rem; margin: 1.25rem 0 0; }
+.inventory-value-grid > div { padding: 1rem; border: 1px solid var(--border); border-radius: .65rem; background: var(--surface-raised); }
+.inventory-value-grid dt { margin-bottom: .5rem; color: var(--muted); font-size: .75rem; font-weight: 700; }
+.inventory-value-grid dd { margin: 0; overflow-wrap: anywhere; }
+.inventory-value-grid dd small { display: block; margin-top: .55rem; color: var(--muted); line-height: 1.5; }
+.provenance-chip { display: inline-flex; margin-left: .55rem; padding: .2rem .45rem; border-radius: 99rem; background: #173b29; color: var(--ready); font-size: .7rem; font-weight: 700; }
+.provenance-chip--override { background: #1b3454; color: var(--accent); }
+.provenance-chip--stale { background: #402b1d; color: var(--attention); }
+.source-status-details { display: grid; gap: .55rem; margin-top: .85rem; }
+.source-status-details p { margin: 0; }
+.source-status-line { display: flex; flex-wrap: wrap; gap: .45rem 1rem; color: var(--text); font-size: .85rem; }
+.source-status-reason, .source-status-observed { color: var(--muted); font-size: .82rem; line-height: 1.55; }
+.source-status-technical { color: var(--muted); font-size: .78rem; }
+.source-status-technical summary { width: fit-content; color: var(--accent); cursor: pointer; }
+.source-status-technical dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .4rem .75rem; margin: .65rem 0 0; }
+.source-status-technical dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--text); }
+.storage-source-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; margin-top: 1rem; }
+.storage-source-grid > article { min-width: 0; padding: 1rem; border: 1px solid var(--border); border-radius: .65rem; background: var(--surface-raised); }
+.storage-source-grid h3 { margin: 0; font-size: .95rem; }
+.inventory-detail-grid { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(15rem, .8fr); gap: 1rem; }
+.inventory-technical { margin-top: 1rem; }
+.inventory-evidence { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; margin-top: 1rem; }
+.loader { width: 2rem; height: 2rem; margin-bottom: 1.5rem; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
+@media (max-width: 700px) {
+ .app-shell { display: block; }
+ .sidebar { gap: 1.5rem; padding: 1rem; border-right: 0; border-bottom: 1px solid var(--border); }
+ .nav-list { display: flex; overflow-x: auto; gap: .25rem; }
+ .nav-link { white-space: nowrap; }
+ .sidebar-status { display: none; }
+ .content { padding: 2rem 1rem 3rem; }
+ .card-grid { grid-template-columns: 1fr; }
+ .settings-hub { grid-template-columns: 1fr; }
+ .inventory-summary, .inventory-value-grid, .inventory-detail-grid, .inventory-evidence { grid-template-columns: 1fr; }
+ .inventory-filters { grid-template-columns: 1fr; }
+ .inventory-detail-header { flex-direction: column; gap: .5rem; }
+ .storage-source-grid { grid-template-columns: 1fr; }
+ .setting-row { align-items: flex-start; flex-direction: column; }
+}
+
+.event-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
+.event-summary h2, .event-summary-action strong { display: block; margin: .3rem 0; color: var(--text); font-size: clamp(1.35rem, 2vw, 2rem); }
+.event-summary-action { width: 100%; border-color: color-mix(in srgb, var(--critical) 45%, var(--border)); color: inherit; text-align: left; cursor: pointer; }
+.event-summary-action strong { color: var(--critical); }
+.event-summary-action:hover, .event-summary-action:focus-visible { border-color: var(--accent); }
+.event-summary-action small { color: var(--muted); }
+.list-filters.event-filters { grid-template-columns: minmax(13rem, 1.5fr) repeat(3, minmax(9rem, 1fr)) auto; align-items: end; }
+.event-filters .button { min-height: 44px; }
+.event-list { display: grid; gap: .35rem; margin: 1rem 0; padding: 0; list-style: none; }
+.event-list > li { display: grid; grid-template-columns: minmax(7.5rem, auto) minmax(0, 1fr); align-items: start; gap: .9rem; padding: .75rem 0; border-top: 1px solid var(--border); }
+.event-list > li:first-child { border-top: 0; }
+.event-row-content { min-width: 0; }
+.event-row-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
+.event-row-heading time, .event-row-heading > span { flex: none; color: var(--muted); font-size: .78rem; }
+.event-list h3 { margin: 0; font-size: .95rem; }
+.event-list p { margin: .2rem 0; color: var(--text); }
+.event-list small { color: var(--muted); }
+.event-empty { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 1rem; }
+.event-limit-note { margin-top: 1rem; }
+.inline-technical { margin-top: .65rem; color: var(--muted); font-size: .78rem; }
+.inline-technical summary { min-height: 44px; cursor: pointer; color: var(--accent); }
+@media (max-width: 1000px) { .list-filters.event-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .event-filters .button { align-self: end; } }
+@media (max-width: 700px) { .event-summary, .list-filters.event-filters { grid-template-columns: 1fr; } .event-summary { gap: .75rem; } .event-list > li { grid-template-columns: minmax(6.25rem, auto) minmax(0, 1fr); gap: .6rem; } .event-row-heading { align-items: flex-start; flex-direction: column; gap: .2rem; } .event-empty { align-items: stretch; flex-direction: column; } }
+
+
+.dashboard-list-panel { max-width: 72rem; }
+.dashboard-list { display: grid; gap: .25rem; margin: 1.5rem 0 0; padding: 0; list-style: none; }
+.dashboard-list li { border-top: 1px solid var(--border); }
+.dashboard-list-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 1rem 0; border: 0; background: transparent; color: var(--text); text-align: left; }
+.dashboard-list-item:hover, .dashboard-list-item:focus-visible { color: var(--accent); }
+.dashboard-list-item small { display: block; margin-top: .3rem; color: var(--muted); }
+.dashboard-list-meta { display: flex; align-items: center; gap: .75rem; color: var(--muted); font-size: .8rem; white-space: nowrap; }
+.dashboard-count { color: var(--muted); font-size: .85rem; }
+.dashboard-message { padding: 2rem 0; color: var(--muted); }
+.dashboard-message h2 { color: var(--text); }
+.dashboard-message--empty { display: grid; place-items: center; text-align: center; }
+.dashboard-view { width: 100%; }
+.back-link { margin-bottom: 1.5rem; border: 0; background: transparent; color: var(--accent); font-weight: 700; }
+.dashboard-view-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 2rem; margin-bottom: 1.5rem; }
+.dashboard-view-meta { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .75rem; color: var(--muted); font-size: .85rem; }
+.dashboard-controls { display: flex; flex-wrap: wrap; align-items: end; gap: 1rem; margin-bottom: 1.25rem; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; background: var(--surface); }
+.dashboard-controls label { display: grid; gap: .35rem; color: var(--muted); font-size: .8rem; font-weight: 700; }
+.dashboard-controls select, .dashboard-controls input { min-width: 10rem; padding: .65rem .75rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface-raised); color: var(--text); }
+.metric-query-status { max-width: 28rem; color: var(--muted); font-size: .8rem; }
+.dashboard-controls input { min-width: 15rem; }
+.view-mode-note { margin-left: auto; color: var(--muted); font-size: .78rem; }
+.dashboard-grid { display: grid; grid-template-columns: repeat(18, minmax(0, 1fr)); gap: 1rem; align-items: stretch; }
+.dashboard-grid > * { grid-column: span var(--widget-span, 6); min-width: 0; }
+.widget-card { min-height: 12rem; padding: 1.25rem; border: 1px solid var(--border); border-radius: .8rem; background: var(--surface); box-shadow: 0 1rem 3rem #05080d33; }
+.widget-card--error { border-color: var(--unknown); }
+.widget-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
+.widget-card h3 { margin: 0 0 .35rem; font-size: 1rem; }
+.widget-description { color: var(--muted); font-size: .85rem; line-height: 1.5; }
+.widget-placeholder { display: grid; min-height: 7rem; place-items: center; align-content: center; gap: .35rem; margin-top: 1rem; border: 1px dashed var(--border); border-radius: .6rem; background: var(--surface-raised); color: var(--muted); text-align: center; }
+.widget-placeholder strong { color: var(--text); font-size: .9rem; }
+.widget-placeholder small { font-size: .75rem; }
+.widget-placeholder-icon { color: var(--accent); font-size: 1.7rem; }
+.dashboard-runtime-state { display: grid; min-height: 7rem; align-content: center; gap: .45rem; margin-top: 1rem; padding: 1rem; border: 1px dashed var(--border); border-radius: .6rem; background: var(--surface-raised); color: var(--muted); }
+.dashboard-runtime-state strong { color: var(--text); }
+.dashboard-runtime-state--error { border-color: var(--critical); background: color-mix(in srgb, var(--critical) 10%, var(--surface)); }
+.dashboard-runtime-stat { display: grid; gap: .4rem; margin-top: 1rem; padding: 1rem; border: 1px solid var(--border); border-radius: .6rem; background: var(--surface-raised); }
+.dashboard-runtime-stat strong { color: var(--text); font-size: clamp(1.5rem, 4vw, 2.5rem); }
+.dashboard-runtime-stat span, .dashboard-runtime-stat small { color: var(--muted); }
+.dashboard-event-list { display: grid; gap: .5rem; margin: 1rem 0 0; padding: 0; list-style: none; }
+.dashboard-event-list li { display: grid; grid-template-columns: .75rem minmax(0, 1fr); gap: .65rem; align-items: start; padding: .65rem .75rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface-raised); }
+.dashboard-event-list li span:last-child { display: grid; min-width: 0; gap: .2rem; }
+.dashboard-event-list strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
+.dashboard-event-list small { color: var(--muted); }
+.event-severity { width: .6rem; height: .6rem; margin-top: .35rem; border-radius: 50%; background: var(--muted); }
+.event-severity--warning { background: var(--attention); }
+.event-severity--critical { background: var(--critical); }
+.event-severity--info { background: var(--accent); }
+@media (max-width: 900px) {
+ .dashboard-grid { grid-template-columns: repeat(8, minmax(0, 1fr)); }
+ .dashboard-grid > * { grid-column: span min(var(--widget-span, 4), 8); }
+ .dashboard-view-header { gap: 1rem; }
+}
+@media (max-width: 700px) {
+ .dashboard-grid { grid-template-columns: 1fr; }
+ .dashboard-grid > * { grid-column: span 1; }
+ .dashboard-view-header { flex-direction: column; }
+ .dashboard-view-meta { justify-content: flex-start; }
+ .dashboard-controls { align-items: stretch; flex-direction: column; }
+ .dashboard-controls select, .dashboard-controls input { width: 100%; }
+ .view-mode-note { margin-left: 0; }
+ .dashboard-list-item, .dashboard-list-meta { align-items: flex-start; flex-direction: column; }
+ .dashboard-list-meta { gap: .35rem; }
+}
+
+.dashboard-editor { width: 100%; }
+.editor-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; }
+.editor-actions { display: flex; gap: .6rem; }
+.editor-actions .button { margin-top: 0; }
+.editor-advanced { margin-bottom: 1rem; border: 1px solid var(--border); border-radius: .75rem; background: var(--surface); }
+.editor-advanced > summary { display: flex; align-items: center; min-height: 44px; padding: .8rem 1rem; cursor: pointer; font-weight: 700; }
+.editor-advanced[open] > summary { border-bottom: 1px solid var(--border); }
+.editor-advanced > .card { margin: 1rem; box-shadow: none; }
+.editor-toolbar { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; background: var(--surface); }
+.editor-toolbar label { display: grid; gap: .35rem; color: var(--muted); font-size: .8rem; font-weight: 700; }
+.editor-toolbar select { min-width: 12rem; padding: .65rem .75rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface-raised); color: var(--text); }
+.editor-toolbar .button { margin-top: 0; }
+.editor-message { margin: 0; color: var(--unknown); font-size: .85rem; }
+.editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 16rem; gap: 1rem; align-items: start; }
+.dashboard-grid--edit { min-height: 12rem; padding: .75rem; border: 1px dashed var(--accent); border-radius: .8rem; background: #0e1622; }
+.dashboard-grid--edit .editor-widget { cursor: grab; user-select: none; }
+.dashboard-grid--edit .editor-widget:active { cursor: grabbing; }
+.editor-widget--selected { border-color: var(--accent); box-shadow: 0 0 0 2px #83b8ff44; }
+.editor-widget--locked { cursor: default !important; border-color: var(--unknown); }
+.editor-widget--hidden { opacity: .55; }
+.editor-lock-state { color: var(--muted); font-size: .72rem; }
+.editor-widget-actions { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: 1rem; }
+.editor-widget-actions button { padding: .3rem .45rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-raised); color: var(--text); font-size: .72rem; }
+.editor-widget-actions button:hover, .editor-widget-actions button:focus-visible { border-color: var(--accent); }
+.editor-widget-actions .editor-resize-handle { margin-left: auto; color: var(--accent); cursor: ew-resize; }
+.editor-drag-hint { margin: .7rem 0 0; color: var(--muted); font-size: .72rem; }
+.editor-inspector { position: sticky; top: 1rem; }
+@media (max-width: 900px) { .editor-layout { grid-template-columns: 1fr; } .editor-inspector { position: static; } }
+@media (max-width: 700px) { .editor-header { flex-direction: column; } .editor-actions { width: 100%; } .editor-actions .button { flex: 1; } .editor-toolbar { align-items: stretch; flex-direction: column; } .editor-toolbar select, .editor-toolbar .button { width: 100%; } .dashboard-grid--edit .editor-widget { grid-column: span 1; } }
+
+
+.editor-config-drawer { display: grid; gap: 1rem; }
+.editor-config-drawer h2 { margin-bottom: 0; }
+.config-section, .config-preview { display: grid; gap: .65rem; padding-top: .8rem; border-top: 1px solid var(--border); }
+.config-section h3, .config-preview h3 { margin: 0; font-size: .9rem; }
+.config-section label, .config-preview label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
+.config-section input, .config-section select, .config-section textarea, .config-preview select { width: 100%; padding: .55rem .6rem; border: 1px solid var(--border); border-radius: .4rem; background: var(--surface-raised); color: var(--text); font: inherit; }
+.config-section input:focus-visible, .config-section select:focus-visible, .config-section textarea:focus-visible, .config-preview select:focus-visible { border-color: var(--accent); outline: 2px solid #83b8ff66; outline-offset: 1px; }
+.config-section [aria-invalid="true"] { border-color: var(--unknown); }
+.config-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; }
+.checkbox-field { display: flex !important; align-items: center; gap: .45rem !important; min-height: 2.3rem; }
+.checkbox-field input { width: auto; }
+.field-error { margin: -.3rem 0 0; color: var(--unknown); font-size: .78rem; }
+.editor-widget-warning { color: var(--unknown); }
+.config-preview { gap: .7rem; }
+.config-preview .button { margin-top: 0; }
+.preview-state { display: grid; gap: .35rem; padding: .7rem; border-left: 3px solid var(--accent); border-radius: .35rem; background: var(--surface-raised); color: var(--text); font-size: .78rem; }
+.preview-state span { color: var(--muted); }
+.preview-state--empty { border-color: var(--unknown); }
+.preview-state--error { border-color: var(--critical); }
+.preview-state--stale { border-color: var(--attention); }
+@media (max-width: 700px) { .config-fields { grid-template-columns: 1fr; } .editor-config-drawer { margin-top: 0; } }
+
+.dashboard-grid--edit[data-viewport="mobile"] { grid-template-columns: 1fr; }
+.dashboard-grid--edit[data-viewport="mobile"] > * { grid-column: span 1; }
+.dashboard-grid--edit[data-viewport="tablet"] { grid-template-columns: repeat(8, minmax(0, 1fr)); }
+.dashboard-grid--edit[data-viewport="wallboard"] { grid-template-columns: repeat(24, minmax(0, 1fr)); }
+
+.editor-dirty { margin: 0; color: var(--unknown); font-size: .8rem; font-weight: 700; }
+.editor-actions .button:disabled { cursor: not-allowed; opacity: .5; }
+.editor-banner { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-bottom: 1rem; padding: .85rem 1rem; border: 1px solid var(--unknown); border-radius: .65rem; background: var(--surface); color: var(--text); }
+.editor-banner span { color: var(--muted); }
+.editor-banner .button { margin-top: 0; }
+.editor-banner--conflict { border-color: var(--critical); }
+.button--danger { border-color: var(--critical); background: var(--critical); color: var(--text); }
+@media (max-width: 700px) { .editor-actions { flex-wrap: wrap; } .editor-actions .button { flex: 1 1 45%; } .editor-banner { align-items: stretch; flex-direction: column; } .editor-banner .button { width: 100%; } }
+
+.variable-editor { margin-bottom: 1rem; }
+.variable-editor .card-heading { align-items: center; }
+.variable-list { display: grid; gap: .75rem; margin-top: 1rem; }
+.variable-row { display: grid; gap: .6rem; padding-top: .8rem; border-top: 1px solid var(--border); }
+.variable-row .button { margin-top: 0; }
+.variable-remove { justify-self: start; }
+
+.widget-placeholder { width: 100%; font: inherit; cursor: pointer; }
+.widget-placeholder:hover, .widget-placeholder:focus-visible { border-color: var(--accent); color: var(--text); }
+.clear-cross-filter { margin-top: 0; }
+
+.dashboard-transfer { display: grid; gap: .8rem; margin-bottom: 1rem; }
+.dashboard-transfer .card-heading { align-items: center; }
+.dashboard-transfer .button { margin-top: 0; }
+.transfer-import { display: grid; gap: .35rem; color: var(--muted); font-size: .8rem; font-weight: 700; }
+.transfer-import input { color: var(--text); font: inherit; }
+.template-list { display: flex; flex-wrap: wrap; gap: .5rem; }
+
+.metric-widget { display: grid; gap: .85rem; margin-top: 1rem; min-width: 0; }
+.metric-notice { margin: 0; padding: .85rem 1rem; border: 1px dashed var(--border); border-radius: .6rem; color: var(--muted); line-height: 1.5; }
+.metric-notice--warning { border-color: var(--unknown); color: var(--text); }
+.metric-stat { display: grid; gap: .5rem; min-height: 7rem; align-content: center; }
+.metric-value { color: var(--text); font-size: clamp(2rem, 6vw, 3.5rem); font-variant-numeric: tabular-nums; font-weight: 750; letter-spacing: -.04em; line-height: 1; }
+.metric-value-meta, .metric-gauge-range, .metric-chart-toolbar, .metric-summary { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; color: var(--muted); font-size: .75rem; }
+.metric-freshness { display: inline-flex; align-items: center; width: fit-content; padding: .2rem .45rem; border-radius: .35rem; background: var(--surface-raised); color: var(--muted); font-size: .72rem; font-weight: 700; }
+.metric-freshness--fresh { color: var(--ready); }
+.metric-freshness--delayed, .metric-freshness--stale, .metric-freshness--unavailable { color: var(--unknown); }
+.metric-sparkline { width: 100%; height: 2rem; overflow: visible; }
+.metric-sparkline path { fill: none; stroke: var(--accent); stroke-width: 2; }
+.metric-gauge { display: grid; gap: .55rem; min-height: 9rem; align-content: center; }
+.metric-gauge-visual { position: relative; height: .85rem; overflow: hidden; border-radius: 999px; background: linear-gradient(90deg, var(--ready), var(--unknown)); }
+.metric-gauge-visual::after { position: absolute; top: 0; right: calc(100% - var(--gauge-ratio)); bottom: 0; left: var(--gauge-ratio); background: var(--surface-raised); content: ''; }
+.metric-gauge-visual meter { position: absolute; width: 1px; height: 1px; opacity: 0; }
+.metric-gauge-value { font-size: 2.2rem; font-variant-numeric: tabular-nums; font-weight: 750; }
+.metric-gauge-range { justify-content: space-between; }
+.metric-chart-toolbar { justify-content: space-between; }
+.metric-toolbar-status { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
+.metric-gap-label { padding: .2rem .45rem; border-radius: .35rem; background: #f2bd6822; color: var(--unknown); font-weight: 700; }
+.metric-export { margin: 0; padding: .4rem .6rem; font-size: .75rem; }
+.metric-chart-figure { margin: 0; min-width: 0; }
+.metric-chart { display: block; width: 100%; min-height: 11rem; overflow: visible; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface-raised); }
+.metric-chart-axis { stroke: var(--border); stroke-width: 1; }
+.metric-chart-line { fill: none; stroke-width: 2.25; vector-effect: non-scaling-stroke; }
+.metric-chart-line--0, .metric-legend-swatch--0 { stroke: #83b8ff; background: #83b8ff; }
+.metric-chart-line--1, .metric-legend-swatch--1 { stroke: #70d69a; background: #70d69a; }
+.metric-chart-line--2, .metric-legend-swatch--2 { stroke: #f2bd68; background: #f2bd68; }
+.metric-chart-line--3, .metric-legend-swatch--3 { stroke: #d399ff; background: #d399ff; }
+.metric-chart-line--4, .metric-legend-swatch--4 { stroke: #ff8f8f; background: #ff8f8f; }
+.metric-chart-line--5, .metric-legend-swatch--5 { stroke: #62d9d2; background: #62d9d2; }
+.metric-chart-caption { margin-top: .45rem; color: var(--muted); font-size: .75rem; }
+.metric-legend { display: flex; flex-wrap: wrap; gap: .55rem .9rem; margin: 0; padding: 0; list-style: none; color: var(--muted); font-size: .75rem; }
+.metric-legend li { display: inline-flex; align-items: center; gap: .35rem; max-width: 100%; overflow-wrap: anywhere; }
+.metric-legend-swatch { width: .65rem; height: .2rem; border-radius: 99px; }
+.metric-summary { padding-top: .15rem; border-top: 1px solid var(--border); }
+.metric-accessible-summary { color: var(--muted); font-size: .78rem; }
+.metric-accessible-summary summary { cursor: pointer; color: var(--accent); }
+.metric-accessible-summary table { width: 100%; margin-top: .6rem; border-collapse: collapse; text-align: left; }
+.metric-accessible-summary th, .metric-accessible-summary td { padding: .4rem; border-top: 1px solid var(--border); font-variant-numeric: tabular-nums; }
+@media (max-width: 700px) { .metric-chart-toolbar { align-items: stretch; flex-direction: column; } .metric-export { width: 100%; } .metric-summary { align-items: flex-start; flex-direction: column; } }
+.metric-inspector { display: grid; gap: .7rem; min-width: 0; }
+.metric-inspector h4 { margin: 0; color: var(--text); font-size: .95rem; }
+.metric-inspector dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; margin: 0; }
+.metric-inspector dl div { padding: .55rem .65rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface-raised); }
+.metric-inspector dt { color: var(--muted); font-size: .7rem; }
+.metric-inspector dd { margin: .2rem 0 0; color: var(--text); font-variant-numeric: tabular-nums; font-weight: 700; overflow-wrap: anywhere; }
+.metric-inspector-label { margin: 0; color: var(--muted); font-size: .75rem; font-weight: 700; }
+.metric-inspector-query { max-height: 10rem; overflow: auto; margin: 0; padding: .75rem; border: 1px solid var(--border); border-radius: .5rem; background: #0b1018; color: var(--accent); font-size: .75rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
+@media (max-width: 700px) { .metric-inspector dl { grid-template-columns: 1fr; } }
+.host-summary { max-width: 72rem; }
+.host-provenance, .host-reason { margin: .8rem 0 0; color: var(--muted); font-size: .8rem; line-height: 1.5; }
+.host-reason { color: var(--text); }
+.host-reason strong { color: var(--unknown); }
+.host-metric-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; }
+.host-metric-grid .card { min-width: 0; }
+.host-metric-grid h2 { font-variant-numeric: tabular-nums; }
+.host-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; }
+.host-table-wrap { overflow-x: auto; }
+.host-table { width: 100%; border-collapse: collapse; color: var(--text); font-size: .82rem; }
+.host-table th, .host-table td { padding: .7rem .45rem; border-top: 1px solid var(--border); text-align: left; vertical-align: top; white-space: nowrap; }
+.host-table td { color: var(--muted); font-variant-numeric: tabular-nums; }
+.host-table th { font-weight: 700; }
+.host-table small { display: block; margin-top: .25rem; color: var(--muted); font-weight: 400; }
+.host-warnings { margin-top: 1rem; max-width: 72rem; color: var(--muted); }
+.host-warnings p:last-child { margin-bottom: 0; }
+@media (max-width: 1100px) { .host-metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+@media (max-width: 700px) { .host-metric-grid, .host-detail-grid { grid-template-columns: 1fr; } }
+.host-capabilities { display: grid; gap: .35rem; margin: 1rem 0; padding: 0; list-style: none; }
+.host-capabilities li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .55rem .65rem; border: 1px solid var(--border); border-radius: .4rem; background: var(--surface-raised); font-size: .78rem; }
+.host-capabilities small { color: var(--muted); }
+.process-summary, .process-panel { max-width: 72rem; }
+.process-count { margin: .8rem 0 0; color: var(--muted); font-size: .8rem; }
+.process-sort { display: flex; align-items: center; gap: .5rem; color: var(--muted); font-size: .8rem; }
+.process-sort select { padding: .55rem .65rem; border: 1px solid var(--border); border-radius: .4rem; background: var(--surface-raised); color: var(--text); }
+.process-panel { margin-top: 1rem; }
+.list-filters { display: grid; grid-template-columns: minmax(14rem, 2fr) repeat(3, minmax(9rem, 1fr)); gap: .75rem; margin: 1rem 0; }
+.list-filters label { display: grid; gap: .4rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
+.list-filters input, .list-filters select { width: 100%; min-height: 44px; padding: .6rem .75rem; border: 1px solid var(--border); border-radius: .5rem; background: var(--surface-raised); color: var(--text); }
+.list-pager { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-top: 1rem; }
+.list-pager span { color: var(--muted); font-size: .8rem; }
+.list-pager button:disabled { cursor: not-allowed; opacity: .5; }
+.mobile-data-list { display: none; margin: 0; padding: 0; list-style: none; }
+@media (max-width: 700px) {
+ .process-sort { align-items: stretch; flex-direction: column; }
+ .process-sort select { width: 100%; }
+ .list-filters { grid-template-columns: 1fr; }
+ .desktop-data-view { display: none; }
+ .mobile-data-list { display: grid; gap: .75rem; }
+ .mobile-data-list > li, .inventory-entity-list > li { display: grid; align-items: stretch; gap: .7rem; padding: 1rem; border: 1px solid var(--border); border-radius: .65rem; background: var(--surface-raised); content-visibility: auto; contain-intrinsic-size: auto 12rem; }
+ .mobile-data-list > li > div { display: flex; flex-wrap: wrap; gap: .5rem; }
+ .mobile-data-list a { color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
+ .mobile-data-list dl { display: grid; gap: .55rem; margin: 0; }
+ .mobile-data-list dl > div { display: grid; grid-template-columns: minmax(6rem, .7fr) minmax(0, 1.3fr); gap: .75rem; }
+ .mobile-data-list dt { color: var(--muted); font-size: .75rem; font-weight: 700; }
+ .mobile-data-list dd { min-width: 0; margin: 0; overflow-wrap: anywhere; text-align: right; }
+ .list-pager { display: grid; grid-template-columns: 1fr auto 1fr; }
+ .list-pager .button { min-width: 0; min-height: 44px; padding-inline: .65rem; }
+}
+.container-summary, .container-panel { max-width: 72rem; }
+.container-count { margin: .8rem 0 0; color: var(--muted); font-size: .8rem; }
+.container-panel { margin-top: 1rem; }
+.container-table small { max-width: 18rem; overflow: hidden; text-overflow: ellipsis; }
+
+.entity-link { color: var(--text); text-decoration: none; }
+.entity-link:hover, .entity-link:focus-visible { text-decoration: underline; }
+.detail-actions { display: flex; flex-wrap: wrap; gap: .65rem; margin-top: 1rem; }
+.container-provenance { margin: .8rem 0 0; color: var(--muted); font-size: .8rem; }
+.container-detail-status { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-top: 1rem; }
+.status-note { color: var(--muted); font-size: .8rem; }
+.technical-details { max-width: 72rem; margin-top: 1rem; }
+.technical-details summary { cursor: pointer; font-weight: 700; }
+.technical-grid { display: grid; grid-template-columns: minmax(8rem, 12rem) 1fr; gap: .65rem 1rem; margin: 1rem 0; font-size: .82rem; }
+.technical-grid dt { color: var(--muted); }
+.technical-grid dd { margin: 0; overflow-wrap: anywhere; }
+.technical-list { margin: .5rem 0 0; padding-left: 1.25rem; color: var(--muted); }
+.application-components { display: grid; gap: .5rem; margin-top: 1rem; }
+.application-component { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .8rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface-raised); }
+.application-component small { display: block; margin-top: .25rem; color: var(--muted); }
+@media (max-width: 700px) { .detail-actions { flex-direction: column; align-items: stretch; } .technical-grid { grid-template-columns: 1fr; gap: .25rem; } .technical-grid dd { margin-bottom: .5rem; } .application-component { align-items: flex-start; flex-direction: column; } }
+
+.ranked-list, .status-grid-widget { display: grid; gap: .35rem; margin: 1rem 0 0; padding: 0; list-style: none; }
+.ranked-list-item, .status-grid-item { display: flex; align-items: center; width: 100%; gap: .65rem; padding: .65rem; border: 1px solid var(--border); border-radius: .4rem; background: var(--surface-raised); color: var(--text); text-align: left; cursor: pointer; }
+.ranked-list-item:hover, .ranked-list-item:focus-visible, .status-grid-item:hover, .status-grid-item:focus-visible { border-color: var(--accent); }
+.ranked-list-rank { color: var(--muted); font-variant-numeric: tabular-nums; min-width: 1.5rem; }
+.ranked-list-item small, .status-grid-item small { display: block; margin-top: .2rem; color: var(--muted); }
+.ranked-list-value { margin-left: auto; font-variant-numeric: tabular-nums; }
+.status-grid-item .status-dot { width: .65rem; height: .65rem; flex: 0 0 .65rem; border-radius: 50%; background: var(--unknown); }
+.status-dot--healthy { background: var(--ready); }
+.status-dot--degraded, .status-dot--down { background: var(--warning); }
+.status-dot--critical { background: var(--danger); }
+
+.storage-visual { max-width: 72rem; margin-top: 1rem; }
+.storage-map-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: .5rem; margin: 1rem 0 0; padding: 0; list-style: none; }
+.storage-map-node { min-width: 0; }
+.storage-map-node a { display: grid; gap: .3rem; min-height: 6rem; padding: .65rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface-raised); color: var(--text); text-decoration: none; }
+.storage-map-node a:hover, .storage-map-node a:focus-visible { border-color: var(--accent); outline: none; }
+.storage-map-node strong, .storage-map-node small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.storage-map-node span { color: var(--muted); font-size: .72rem; }
+.storage-visual-state { display: inline-flex; align-items: center; gap: .35rem; color: var(--text) !important; font-size: .72rem !important; }
+.storage-map-node small { color: var(--muted); font-size: .68rem; }
+.storage-accessible-summary { margin-top: 1rem; color: var(--muted); font-size: .8rem; }
+.storage-accessible-summary summary { cursor: pointer; color: var(--accent); }
+.storage-heatmap { display: grid; grid-template-columns: repeat(16, minmax(1.8rem, 1fr)); gap: .25rem; margin-top: 1rem; }
+.storage-heatmap-cell { display: grid; min-height: 2rem; place-items: center; border: 1px solid var(--border); border-radius: .25rem; color: var(--text); font-size: .68rem; font-variant-numeric: tabular-nums; }
+.storage-heatmap-cell--healthy { background: #173b29; }
+.storage-heatmap-cell--degraded { background: #4a3518; }
+.storage-heatmap-cell--unknown { background: #252b35; }
+@media (max-width: 1000px) { .storage-map-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } .storage-heatmap { grid-template-columns: repeat(12, minmax(1.8rem, 1fr)); } }
+@media (max-width: 700px) { .storage-map-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .storage-heatmap { grid-template-columns: repeat(8, minmax(1.8rem, 1fr)); } }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+.service-source, .service-panel, .service-detail-summary, .service-certificate { max-width: 72rem; margin-top: 1rem; }
+.service-source { margin-top: 0; }
+.service-provenance, .service-count { color: var(--muted); font-size: .8rem; }
+.service-table-wrap { overflow-x: auto; }
+.service-table { width: 100%; border-collapse: collapse; color: var(--text); font-size: .82rem; }
+.service-table th, .service-table td { padding: .8rem .55rem; border-top: 1px solid var(--border); text-align: left; vertical-align: top; white-space: nowrap; }
+.service-table td { color: var(--muted); font-variant-numeric: tabular-nums; }
+.service-table th { font-weight: 700; }
+.service-link { display: block; color: var(--text); text-decoration: none; }
+.service-link:hover, .service-link:focus-visible { color: var(--accent); text-decoration: underline; }
+.service-link small { display: block; margin-top: .25rem; color: var(--muted); font-weight: 400; }
+.service-status { display: inline-flex; align-items: center; gap: .45rem; width: max-content; padding: .35rem .6rem; border-radius: 99rem; font-size: .78rem; font-weight: 750; white-space: nowrap; }
+.service-status--up { background: #173b29; color: var(--ready); }
+.service-status--degraded, .service-status--down { background: #4a3518; color: var(--unknown); }
+.service-status--unknown { background: #252b35; color: var(--muted); }
+.service-reason { margin: 1rem 0 0; color: var(--text); line-height: 1.5; }
+.service-reason strong { color: var(--unknown); }
+.service-details-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; margin: 1.25rem 0 0; }
+.service-details-grid dt { color: var(--muted); font-size: .75rem; }
+.service-details-grid dd { margin: .3rem 0 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.service-details-grid dd small { display: block; margin-top: .25rem; color: var(--muted); font-size: .75rem; }
+.service-certificate-state { color: var(--unknown); font-size: .8rem; font-weight: 700; }
+.service-read-only { margin: 1rem 0 0; }
+@media (max-width: 900px) { .service-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 700px) { .service-table th, .service-table td { white-space: normal; min-width: 8rem; } .service-table th:first-child { min-width: 11rem; } .service-details-grid { grid-template-columns: 1fr; } .service-panel, .service-source, .service-detail-summary, .service-certificate { margin-top: 1rem; } }
+@media (prefers-reduced-motion: reduce) { .loader { animation: none; } }
+.service-table th small { display: block; margin-top: .25rem; color: var(--muted); font-weight: 400; }
+.topology-controls { display: flex; flex-wrap: wrap; align-items: end; gap: 1rem; margin-bottom: 1rem; }
+.topology-controls label { display: grid; gap: .35rem; min-width: 12rem; color: var(--muted); font-size: .8rem; font-weight: 700; }
+.topology-controls input, .topology-controls select { padding: .65rem .75rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface-raised); color: var(--text); font: inherit; }
+.topology-filter-count, .topology-count { margin-left: auto; color: var(--muted); font-size: .8rem; }
+.topology-widget { padding: 1.5rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); box-shadow: 0 1rem 3rem #05080d33; }
+.topology-widget--compact { padding: 0; border: 0; box-shadow: none; }
+.topology-disclaimer, .topology-limit-note { color: var(--muted); font-size: .8rem; line-height: 1.5; }
+.topology-layout { display: grid; grid-template-columns: minmax(14rem, .8fr) minmax(0, 1.2fr); gap: 1.25rem; margin-top: 1rem; }
+.topology-layout h3 { margin: 0 0 .65rem; font-size: .9rem; }
+.topology-node-list, .topology-edge-list { display: grid; gap: .5rem; margin: 0; padding: 0; list-style: none; }
+.topology-node { display: grid; gap: .25rem; padding: .75rem .85rem; border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: .5rem; background: var(--surface-raised); color: var(--text); text-decoration: none; }
+a.topology-node:hover, a.topology-node:focus-visible { border-color: var(--accent); color: var(--accent); }
+.topology-node--unknown { border-left-color: var(--unknown); border-style: dashed; }
+.topology-node strong { overflow-wrap: anywhere; }
+.topology-node small { color: var(--muted); font-size: .72rem; }
+.topology-edge { display: grid; gap: .45rem; padding: .75rem .85rem; border: 1px solid var(--border); border-left: 3px solid var(--unknown); border-radius: .5rem; background: var(--surface-raised); }
+.topology-edge--confirmed { border-left-color: var(--ready); }
+.topology-edge-route { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: .5rem; align-items: center; font-weight: 700; }
+.topology-edge-route span { overflow-wrap: anywhere; }
+.topology-edge-route span:last-child { text-align: right; }
+.topology-edge-meta { display: flex; flex-wrap: wrap; gap: .45rem .8rem; color: var(--muted); font-size: .72rem; }
+.topology-limit-note { margin: .8rem 0 0; }
+@media (max-width: 700px) { .topology-controls { align-items: stretch; flex-direction: column; } .topology-controls label { min-width: 0; } .topology-controls input, .topology-controls select { width: 100%; } .topology-filter-count { margin-left: 0; } .topology-layout { grid-template-columns: 1fr; } .topology-edge-route { grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); } }
+.network-health-widget, .network-panel { max-width: 72rem; margin-top: 1rem; }
+.network-health-widget { padding: 1.5rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); box-shadow: 0 1rem 3rem #05080d33; }
+.network-health-widget--compact { padding: 0; border: 0; box-shadow: none; }
+.network-source, .network-disclaimer { color: var(--muted); font-size: .8rem; }
+.network-health-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; margin-top: 1rem; }
+.network-health-item { display: grid; gap: .55rem; padding: .85rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface-raised); }
+.network-health-item h3 { margin: 0; font-size: .85rem; }
+.network-health-item p, .network-health-item small { margin: 0; color: var(--muted); font-size: .75rem; line-height: 1.45; overflow-wrap: anywhere; }
+.network-state { display: inline-flex; align-items: center; gap: .35rem; width: fit-content; padding: .25rem .45rem; border-radius: 99rem; font-size: .72rem; font-weight: 750; white-space: nowrap; }
+.network-state--up { background: #173b29; color: var(--ready); }
+.network-state--degraded, .network-state--down { background: #4a3518; color: var(--text); }
+.network-state--unknown { background: #252b35; color: var(--muted); }
+.network-detail-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(16rem, .8fr); gap: 1rem; }
+.network-table-wrap { overflow-x: auto; }
+.network-table { width: 100%; border-collapse: collapse; color: var(--text); font-size: .8rem; }
+.network-table caption { padding: .5rem 0; color: var(--muted); text-align: left; }
+.network-table th, .network-table td { padding: .7rem .5rem; border-top: 1px solid var(--border); text-align: left; vertical-align: top; white-space: nowrap; }
+.network-table td { color: var(--muted); font-variant-numeric: tabular-nums; }
+.network-table th small { display: block; margin-top: .3rem; }
+.network-certificate-list, .network-event-list { display: grid; gap: .6rem; margin: 1rem 0 0; padding: 0; list-style: none; }
+.network-certificate-list li, .network-event-list li { display: grid; gap: .25rem; padding: .7rem 0; border-top: 1px solid var(--border); }
+.network-certificate-list span, .network-certificate-list small, .network-event-list span { color: var(--muted); font-size: .75rem; }
+@media (max-width: 900px) { .network-health-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 700px) { .network-health-grid, .network-detail-grid { grid-template-columns: 1fr; } .network-table th, .network-table td { white-space: normal; min-width: 7rem; } }
+
+.alert-section-nav { position: sticky; z-index: 4; top: .75rem; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .5rem; max-width: 72rem; margin-bottom: 1rem; padding: .45rem; border: 1px solid var(--border); border-radius: .8rem; background: color-mix(in srgb, var(--surface) 94%, transparent); box-shadow: 0 .8rem 2rem #05080d40; backdrop-filter: blur(12px); }
+.alert-section-nav button { display: grid; gap: .2rem; min-height: 56px; padding: .65rem .8rem; border: 1px solid transparent; border-radius: .55rem; background: transparent; color: var(--muted); text-align: left; cursor: pointer; }
+.alert-section-nav button span { color: var(--text); font-weight: 780; }
+.alert-section-nav button small { line-height: 1.35; }
+.alert-section-nav button:hover, .alert-section-nav button:focus-visible { border-color: var(--accent); color: var(--text); }
+.alert-section-nav button[aria-current="page"] { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); background: color-mix(in srgb, var(--accent) 12%, var(--surface-raised)); color: var(--accent); }
+.alert-layout { display: grid; grid-template-columns: minmax(17rem, .75fr) minmax(0, 1.5fr); gap: 1rem; max-width: 72rem; align-items: start; }
+.alert-rule-button { display: grid; gap: .25rem; padding: 0; border: 0; background: transparent; color: var(--text); text-align: left; }
+.alert-rule-button small { color: var(--muted); }
+.rule-state { color: var(--muted); font-size: .78rem; }
+.rule-state--enabled { color: var(--ready); }
+.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-top: 1.5rem; }
+.form-grid label, .preview-panel label { display: grid; gap: .4rem; color: var(--muted); font-size: .82rem; font-weight: 700; }
+.form-grid input, .form-grid select, .preview-panel input { width: 100%; padding: .65rem .7rem; border: 1px solid var(--border); border-radius: .45rem; background: #0e1622; color: var(--text); }
+.form-grid small { font-weight: 400; line-height: 1.4; }
+.form-grid__wide { grid-column: 1 / -1; }
+.guided-options { display: flex; flex-wrap: wrap; gap: .65rem 1rem; margin: 0; padding: .85rem; border: 1px solid var(--border); border-radius: .55rem; }
+.guided-options legend { padding: 0 .35rem; color: var(--muted); font-size: .82rem; font-weight: 700; }
+.guided-options label { display: inline-flex; align-items: center; gap: .5rem; min-height: 2.75rem; color: var(--text); }
+.guided-options input { width: 1.1rem; height: 1.1rem; accent-color: var(--accent); }
+.guided-options small { flex-basis: 100%; color: var(--muted); }
+.button:disabled { cursor: not-allowed; filter: saturate(.35); opacity: .45; }
+.detail-actions { display: flex; flex-wrap: wrap; gap: .65rem; }
+.detail-actions .button { margin-top: 1.5rem; }
+.form-message { margin-top: 1rem; color: var(--unknown); }
+.preview-panel { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
+.preview-panel h3 { margin: 0; font-size: 1rem; }
+.preview-result { padding: .75rem; border: 1px solid var(--ready); border-radius: .45rem; color: var(--ready); }
+@media (max-width: 900px) { .alert-layout { grid-template-columns: 1fr; } }
+@media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; } .form-grid__wide { grid-column: auto; } }
+.alert-controls { max-width: 72rem; }
+.alert-control-grid, .alert-control-lists { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; }
+.control-form { display: grid; gap: .7rem; padding: 1rem; border: 1px solid var(--border); border-radius: .55rem; }
+.control-form h3, .alert-control-lists h3 { margin: 0; }
+.control-form label { display: grid; gap: .35rem; color: var(--muted); font-size: .82rem; font-weight: 700; }
+.control-form input, .control-form select { width: 100%; padding: .65rem .7rem; border: 1px solid var(--border); border-radius: .45rem; background: #0e1622; color: var(--text); }
+.control-form small { color: var(--muted); font-weight: 400; line-height: 1.4; }
+@media (max-width: 700px) { .alert-control-grid, .alert-control-lists { grid-template-columns: 1fr; } }
+.alert-operations { display: grid; gap: 1rem; max-width: 72rem; }
+.alert-operation-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
+.alert-operation-summary button { display: grid; gap: .25rem; min-height: 7rem; padding: 1rem; border: 1px solid var(--border); border-radius: .7rem; background: var(--surface); color: var(--text); text-align: left; cursor: pointer; }
+.alert-operation-summary button:hover, .alert-operation-summary button:focus-visible, .alert-operation-summary button[aria-pressed="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); }
+.alert-operation-summary span { color: var(--muted); font-size: .76rem; font-weight: 780; letter-spacing: .04em; text-transform: uppercase; }
+.alert-operation-summary strong { font-size: clamp(1.5rem, 2.5vw, 2.15rem); }
+.alert-operation-summary small { color: var(--muted); }
+.alert-operation-summary .alert-summary-critical { border-color: color-mix(in srgb, var(--critical) 42%, var(--border)); }
+.alert-operation-summary .alert-summary-critical strong { color: var(--critical); }
+.alert-operation-list-items { display: grid; margin: 1rem 0 0; padding: 0; list-style: none; }
+.alert-operation-list-items li { display: grid; grid-template-columns: minmax(7.5rem, auto) minmax(0, 1fr) auto; align-items: center; gap: .8rem; min-height: 4.1rem; padding: .65rem 0; border-top: 1px solid var(--border); }
+.alert-operation-button { display: grid; gap: .25rem; min-width: 0; padding: .55rem 0; border: 0; background: transparent; color: var(--text); text-align: left; }
+.alert-operation-button small { color: var(--muted); }
+.alert-operation-action { min-height: 44px; margin: 0; }
+.alert-detail { margin-top: 1rem; padding: 1rem; border: 1px solid var(--border); border-radius: .55rem; }
+.alert-detail h3 { margin: 0 0 .35rem; }
+.alert-detail p { margin: 0 0 .35rem; color: var(--muted); }
+@media (max-width: 700px) { .alert-section-nav, .alert-operation-summary { grid-template-columns: 1fr; } .alert-section-nav { position: static; } .alert-operation-summary { gap: .65rem; } .alert-operation-summary button { min-height: 5.5rem; } .alert-operation-list-items li { grid-template-columns: minmax(6.25rem, auto) minmax(0, 1fr); } .alert-operation-action { grid-column: 2; justify-self: start; } }
+/* M8-10 incident list, detail and timeline */
+.incident-list-panel, .incident-timeline-panel, .incident-notes-panel, .incident-workflow-placeholder { max-width: 72rem; margin-top: 1rem; }
+.incident-list { display: grid; gap: .25rem; margin: 1.5rem 0 0; padding: 0; list-style: none; }
+.incident-list li { border-top: 1px solid var(--border); }
+.incident-list-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 1rem 0; border: 0; background: transparent; color: var(--text); text-align: left; }
+.incident-list-item:hover, .incident-list-item:focus-visible { color: var(--accent); }
+.incident-list-item small { display: block; max-width: 48rem; margin-top: .3rem; color: var(--muted); line-height: 1.45; }
+.incident-list-meta { display: flex; align-items: center; gap: .75rem; color: var(--muted); font-size: .8rem; white-space: nowrap; }
+.incident-detail-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(16rem, .8fr); gap: 1rem; }
+.incident-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .8rem 1rem; margin: 1.25rem 0; }
+.incident-facts div { min-width: 0; }
+.incident-facts dt { color: var(--muted); font-size: .75rem; font-weight: 700; }
+.incident-facts dd { margin: .25rem 0 0; overflow-wrap: anywhere; }
+.uncertainty-note { margin: 0; padding: .85rem 1rem; border-left: 3px solid var(--unknown); background: #2a2115; color: var(--muted); line-height: 1.55; }
+.incident-field { display: grid; gap: .4rem; margin-top: 1rem; color: var(--muted); font-size: .82rem; font-weight: 700; }
+.incident-field input, .incident-field textarea, .incident-workflow-placeholder input { width: 100%; padding: .65rem .7rem; border: 1px solid var(--border); border-radius: .45rem; background: #0e1622; color: var(--text); font: inherit; }
+.incident-field textarea { min-height: 7rem; resize: vertical; }
+.incident-timeline { display: grid; gap: 0; margin: 1.25rem 0 0; padding: 0 0 0 1rem; list-style: none; border-left: 2px solid var(--border); }
+.incident-timeline li { display: grid; grid-template-columns: 10rem minmax(0, 1fr); gap: 1rem; padding: .9rem 0 .9rem 1rem; border-bottom: 1px solid var(--border); }
+.incident-timeline li:last-child { border-bottom: 0; }
+.incident-timeline time { color: var(--muted); font-size: .78rem; }
+.incident-timeline p { margin: .3rem 0 0; color: var(--muted); line-height: 1.5; overflow-wrap: anywhere; }
+.incident-workflow-placeholder input:disabled { color: var(--muted); opacity: .8; }
+@media (max-width: 700px) {
+ .incident-detail-grid { grid-template-columns: 1fr; }
+ .incident-list-item { align-items: flex-start; flex-direction: column; }
+ .incident-list-meta { flex-wrap: wrap; white-space: normal; }
+ .incident-facts { grid-template-columns: 1fr; }
+ .incident-timeline li { grid-template-columns: 1fr; gap: .35rem; }
+}/* M9-01 first-run onboarding */
+.onboarding-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(18rem, .9fr); gap: 1rem; max-width: 78rem; }
+.onboarding-card { min-width: 0; }
+.onboarding-capabilities { display: grid; gap: .35rem; margin: 1.25rem 0 0; padding: 0; list-style: none; }
+.onboarding-capabilities li { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: .8rem 0; border-top: 1px solid var(--border); }
+.onboarding-capabilities li span:first-child { display: grid; gap: .25rem; min-width: 0; }
+.onboarding-capabilities small { color: var(--muted); line-height: 1.45; overflow-wrap: anywhere; }
+.onboarding-state { padding: .25rem .5rem; border-radius: 99rem; color: var(--muted); background: #252b35; font-size: .72rem; font-weight: 750; white-space: nowrap; }
+.onboarding-state--ready, .onboarding-state--configured, .onboarding-state--development { color: var(--ready); background: #173b29; }
+.onboarding-state--incomplete, .onboarding-state--not-ready { color: var(--unknown); background: #4a3518; }
+.onboarding-choice { display: grid; gap: .55rem; margin: 1.25rem 0 0; padding: 0; border: 0; color: var(--text); }
+.onboarding-choice legend { margin-bottom: .35rem; color: var(--muted); font-size: .82rem; font-weight: 750; }
+.onboarding-choice label { display: flex; align-items: center; gap: .5rem; }
+.onboarding-safe-note { margin: 1.25rem 0; padding: .8rem 1rem; border-left: 3px solid var(--unknown); background: #2a2115; color: var(--muted); line-height: 1.5; }
+.onboarding-summary { display: grid; gap: .75rem; margin: 1.25rem 0; }
+.onboarding-summary div { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .8rem 0; border-top: 1px solid var(--border); }
+.onboarding-summary dt { color: var(--muted); }
+.onboarding-summary dd { margin: 0; font-weight: 750; text-align: right; }
+.onboarding-complete .button { min-height: 44px; }
+.setting-row small { display: block; margin-top: .25rem; color: var(--muted); }
+@media (max-width: 800px) { .onboarding-grid { grid-template-columns: 1fr; } }
+@media (max-width: 560px) { .onboarding-capabilities li { align-items: flex-start; flex-direction: column; gap: .45rem; } }
+.mobile-navigation { display: none; }
+@media (max-width: 700px) {
+ .desktop-navigation { display: none; }
+ .mobile-navigation { display: grid; gap: .75rem; }
+ .mobile-primary-list { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .2rem; }
+ .mobile-primary-list .nav-link { display: grid; min-height: 44px; place-items: center; padding: .45rem .25rem; text-align: center; white-space: normal; font-size: .76rem; line-height: 1.2; }
+ .mobile-more { border-top: 1px solid var(--border); }
+ .mobile-more > summary { display: flex; min-height: 44px; align-items: center; justify-content: center; cursor: pointer; color: var(--muted); font-weight: 750; list-style: none; }
+ .mobile-more > summary::-webkit-details-marker { display: none; }
+ .mobile-more > summary::before { margin-right: .45rem; content: '+'; }
+ .mobile-more[open] > summary::before { content: '−'; }
+ .mobile-more .nav-list { display: grid; gap: .2rem; margin-top: .35rem; }
+ .mobile-more .nav-link { min-height: 44px; }
+ .mobile-navigation button, .mobile-navigation select, .mobile-navigation input { min-height: 44px; }
+ .back-link { min-height: 44px; padding: .45rem .25rem; }
+ .content button { min-height: 44px; }
+ .content select, .content input:not([type="checkbox"]):not([type="radio"]), .content textarea { min-height: 44px; }
+ .editor-widget-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .editor-widget-actions button { min-height: 44px; min-width: 44px; }
+ .editor-widget-actions .editor-resize-handle { margin-left: 0; }
+ .metric-chart { min-height: 12rem; }
+ .metric-legend { gap: .65rem; }
+}
+.app-shell--wallboard { display: block; width: 100vw; height: 100vh; min-height: 100vh; overflow: hidden; }
+.app-shell--wallboard .content { width: 100%; height: 100%; max-width: none; overflow: hidden; padding: 1rem; }
+.wallboard-shell { display: grid; height: 100%; min-height: 0; grid-template-rows: auto auto auto minmax(0, 1fr); overflow: hidden; }
+.wallboard-shell--state { display: grid; min-height: 80vh; place-items: center; align-content: center; text-align: center; }
+.wallboard-header { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; margin-bottom: .65rem; }
+.wallboard-header h1 { margin-bottom: .15rem; font-size: clamp(1.75rem, 2.4vw, 2.75rem); }
+.wallboard-header .eyebrow { margin-bottom: .2rem; }
+.wallboard-header .intro { margin-bottom: 0; font-size: .85rem; }
+.wallboard-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .75rem; }
+.wallboard-actions .button { margin-top: 0; }
+.wallboard-connection { padding: .45rem .7rem; border-radius: 99rem; background: #252b35; color: var(--muted); font-size: .8rem; font-weight: 750; }
+.wallboard-connection--connected { background: #173b29; color: var(--ready); }
+.wallboard-connection--reconnecting, .wallboard-connection--unavailable { background: #4a3518; color: var(--unknown); }
+.wallboard-priority { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .5rem; margin-bottom: .5rem; }
+.wallboard-priority-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: .75rem; padding: .55rem .75rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface); }
+.wallboard-priority-item strong, .wallboard-priority-item small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.wallboard-priority-item small { color: var(--muted); }
+.wallboard-priority-item--ready { border-left: 3px solid var(--ready); }
+.wallboard-priority-item--attention { border-left: 3px solid var(--unknown); }
+.wallboard-status { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: .5rem; color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
+.wallboard-frame { min-width: 0; min-height: 0; overflow: hidden; transform: translate(0, 0); transition: transform 1s ease; }
+.wallboard-shell--shift-1 .wallboard-frame { transform: translate(1px, 0); }
+.wallboard-view { display: grid; height: 100%; min-height: 0; max-width: none; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
+.wallboard-view .dashboard-view-header { min-height: 0; margin-bottom: .5rem; }
+.wallboard-view .dashboard-view-header h2 { margin-bottom: .15rem; font-size: 1.15rem; }
+.wallboard-view .dashboard-view-header .intro { max-width: 60rem; margin: 0; overflow: hidden; font-size: .75rem; text-overflow: ellipsis; white-space: nowrap; }
+.wallboard-view .dashboard-view-header .eyebrow { color: var(--muted); }
+.wallboard-view .dashboard-view-meta { align-items: center; }
+.wallboard-read-only { color: var(--ready); font-size: .8rem; font-weight: 750; }
+.wallboard-view .dashboard-grid { height: 100%; min-height: 0; grid-template-columns: repeat(24, minmax(0, 1fr)); grid-template-rows: repeat(13, minmax(0, 1fr)); gap: .5rem; overflow: hidden; }
+.wallboard-view .dashboard-grid > * { grid-column: span min(var(--widget-span, 6), 24); }
+.wallboard-view .widget-card { min-height: 0; overflow: hidden; padding: .85rem; }
+.wallboard-view .widget-card-heading { gap: .5rem; }
+.wallboard-view .widget-card h3 { font-size: .9rem; }
+.wallboard-view .widget-description { margin-bottom: .45rem; overflow: hidden; font-size: .7rem; text-overflow: ellipsis; white-space: nowrap; }
+.wallboard-view .status-grid-widget, .wallboard-view .storage-map-grid, .wallboard-view .dashboard-event-list { overflow: hidden; }
+@media (max-width: 700px) {
+ .app-shell--wallboard .content { padding: 1rem; }
+ .wallboard-header { flex-direction: column; gap: 1rem; }
+ .wallboard-actions { justify-content: flex-start; }
+ .wallboard-actions .button { width: 100%; }
+ .wallboard-priority { grid-template-columns: 1fr 1fr; }
+ .wallboard-view .dashboard-grid { grid-template-columns: 1fr; }
+ .wallboard-view .dashboard-grid > * { grid-column: span 1; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .wallboard-frame, .wallboard-shell--shift-1 .wallboard-frame { transform: none; transition: none; }
+}
+
+/* Stitch Command Center: dense operational hierarchy without decorative chrome. */
+.app-shell:not(.app-shell--wallboard) { grid-template-columns: 15rem minmax(0, 1fr); }
+.app-workspace { min-width: 0; }
+.sidebar { position: sticky; top: 0; height: 100vh; gap: 1rem; overflow-y: auto; padding: 1rem .75rem; background: #0e1520; }
+.brand { min-height: 2.75rem; padding: 0 .5rem; }
+.brand-mark { width: 2rem; height: 2rem; border-radius: .5rem; }
+.desktop-navigation { display: grid; gap: 1rem; }
+.nav-group > summary { display: flex; min-height: 2rem; align-items: center; justify-content: space-between; padding: .35rem .65rem; cursor: pointer; color: var(--muted); font-size: .65rem; font-weight: 750; letter-spacing: .12em; list-style: none; text-transform: uppercase; }
+.nav-group > summary::-webkit-details-marker { display: none; }
+.nav-group > summary:focus-visible { border-radius: .4rem; outline: 2px solid var(--accent); outline-offset: 2px; }
+.nav-group > summary span:last-child { transition: transform .15s ease; }
+.nav-group[open] > summary span:last-child { transform: rotate(180deg); }
+.nav-group:not([open]) { border-bottom: 1px solid #1a2635; }
+.service-table th small, .service-link small { display: block; max-width: 13rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.service-table td { max-width: 18rem; overflow-wrap: anywhere; }
+.nav-list { gap: .15rem; }
+.nav-link { display: flex; min-height: 2.15rem; align-items: center; gap: .65rem; padding: .45rem .65rem; border-radius: .45rem; font-size: .82rem; }
+.nav-icon { display: inline-grid; width: 1.1rem; place-items: center; color: #64748b; font-size: .9rem; }
+.nav-link--active { background: #17283e; box-shadow: inset 2px 0 0 var(--accent); }
+.nav-link--active .nav-icon { color: var(--accent); }
+.sidebar-status { padding: .7rem; border-radius: .5rem; }
+.context-bar { position: sticky; top: 0; z-index: 10; display: flex; min-height: 3.5rem; align-items: center; justify-content: space-between; gap: 1rem; padding: .65rem 1.5rem; border-bottom: 1px solid var(--border); background: #0b1018f2; backdrop-filter: blur(8px); color: var(--muted); font-size: .78rem; }
+.context-bar > div, .context-actions { display: flex; align-items: center; gap: .6rem; }
+.context-bar strong { color: var(--text); }
+.context-product { color: var(--accent); font-weight: 750; }
+.context-live { color: var(--ready); font-weight: 700; }
+.context-live span { font-size: .55rem; }
+.context-actions .status-badge { padding: .25rem .5rem; font-size: .7rem; }
+.content { width: 100%; max-width: none; padding: 1.5rem; }
+.page-intro { margin-bottom: 1.5rem; }
+.page-intro h1, .overview-heading h1 { margin-bottom: .45rem; font-size: clamp(1.55rem, 2.5vw, 2rem); line-height: 1.15; }
+.page-intro .intro, .overview-heading .intro { margin-bottom: 0; font-size: .9rem; line-height: 1.55; }
+.eyebrow, .card-kicker { margin-bottom: .4rem; font-size: .67rem; }
+.card { padding: 1rem; border-radius: .6rem; box-shadow: none; }
+
+.command-overview { display: grid; gap: 1rem; }
+.overview-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; }
+.overview-heading-status { display: grid; justify-items: end; gap: .35rem; color: var(--muted); font-size: .72rem; }
+.source-health-strip { display: flex; flex-wrap: wrap; gap: .45rem; }
+.health-chip { display: grid; grid-template-columns: auto auto; align-items: center; gap: .05rem .4rem; padding: .4rem .6rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface); font-size: .72rem; }
+.health-chip > span { grid-row: span 2; display: grid; width: 1.1rem; height: 1.1rem; place-items: center; border: 1px solid currentColor; border-radius: 50%; }
+.health-chip strong { color: var(--text); }
+.health-chip small { color: var(--muted); }
+.health-chip--healthy { color: var(--ready); }
+.health-chip--degraded, .health-chip--stale { color: var(--attention); }
+.health-chip--critical { color: var(--critical); }
+.health-chip--unknown { color: var(--unknown); }
+.overview-kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; }
+.overview-kpi { min-width: 0; padding: .85rem 1rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface); }
+.overview-kpi p, .overview-kpi small { margin: 0; color: var(--muted); font-size: .72rem; }
+.overview-kpi strong { display: block; margin: .35rem 0 .2rem; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 1.55rem; font-variant-numeric: tabular-nums; }
+.overview-layout { display: grid; grid-template-columns: minmax(14rem, .7fr) repeat(2, minmax(0, 1fr)); gap: .75rem; align-items: stretch; }
+.action-queue { grid-row: span 2; }
+.action-queue ol, .overview-data-list { display: grid; gap: 0; margin: .75rem 0 0; padding: 0; list-style: none; }
+.action-queue li, .overview-data-list li { display: flex; align-items: center; gap: .65rem; min-width: 0; padding: .65rem 0; border-top: 1px solid var(--border); }
+.action-queue li span:nth-child(2), .overview-data-list li > span { min-width: 0; }
+.action-queue strong, .action-queue small, .overview-data-list strong, .overview-data-list small { display: block; }
+.action-queue strong, .action-queue small { overflow-wrap: anywhere; white-space: normal; }
+.overview-data-list strong, .overview-data-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.action-queue small, .overview-data-list small { margin-top: .2rem; color: var(--muted); font-size: .7rem; }
+.queue-count { display: grid; min-width: 1.7rem; height: 1.7rem; place-items: center; border-radius: 50%; background: #1b2a3d; color: var(--accent); font: 700 .75rem ui-monospace, monospace; }
+.queue-severity { display: grid; flex: 0 0 auto; width: 1.35rem; height: 1.35rem; place-items: center; border: 1px solid var(--attention); border-radius: 50%; color: var(--attention); font-size: .7rem; font-weight: 800; }
+.overview-table-card { min-width: 0; }
+.overview-data-list li { justify-content: space-between; }
+.mono-value { margin-left: auto; color: var(--text); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+.text-action { padding: .25rem; border: 0; background: transparent; color: var(--accent); font-size: .72rem; }
+.workload-summary { display: grid; grid-template-columns: auto 1fr auto 1fr; align-items: baseline; gap: .35rem; margin: 1rem 0; }
+.workload-summary strong { font: 750 1.6rem ui-monospace, monospace; }
+.workload-summary span { color: var(--muted); font-size: .72rem; }
+.incident-queue { grid-column: span 2; }
+.incident-dot { flex: 0 0 auto; width: .55rem; height: .55rem; border-radius: 50%; background: var(--unknown); }
+.incident-dot--critical { background: var(--critical); }
+.incident-dot--degraded, .incident-dot--warning { background: var(--attention); }
+
+@media (max-width: 1050px) {
+ .overview-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .overview-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .action-queue { grid-row: auto; grid-column: span 2; }
+ .incident-queue { grid-column: span 2; }
+}
+@media (min-width: 701px) and (max-width: 1100px) {
+ .app-shell:not(.app-shell--wallboard) { grid-template-columns: 13rem minmax(0, 1fr); }
+ .sidebar { padding: .85rem .6rem; }
+ .brand { gap: .55rem; padding: 0 .4rem; }
+ .brand small { font-size: .68rem; }
+ .nav-group > summary { padding-inline: .5rem; }
+ .nav-link { gap: .5rem; padding-inline: .5rem; font-size: .78rem; }
+ .content { padding: 1.25rem; }
+}
+@media (max-width: 700px) {
+ .app-shell:not(.app-shell--wallboard) { display: block; }
+ .sidebar { position: static; height: auto; overflow: visible; padding: .75rem; border-bottom: 1px solid var(--border); }
+ .brand { padding: 0; }
+ .desktop-navigation { display: none; }
+ .mobile-navigation { position: fixed; right: 0; bottom: 0; left: 0; z-index: 20; display: grid; grid-template-columns: minmax(0, 5fr) minmax(3.75rem, 1fr); gap: .3rem; padding: .35rem .5rem calc(.35rem + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); background: #0e1520fa; box-shadow: 0 -.5rem 1.5rem #05080d66; }
+ .mobile-primary-list { width: auto; gap: .15rem; }
+ .mobile-primary-list li { min-width: 0; }
+ .mobile-primary-list li + li { border-left: 1px solid var(--border); }
+ .mobile-primary-list .nav-link { min-width: 0; padding-inline: .15rem; font-size: .75rem; line-height: 1.15; }
+ .mobile-primary-list .nav-link span:last-child { min-width: 0; letter-spacing: -.04em; overflow-wrap: normal; white-space: nowrap; }
+ .mobile-more { position: relative; width: auto; border: 1px solid var(--border); border-radius: .45rem; }
+ .mobile-more > summary { height: 100%; min-height: 48px; flex-direction: column; gap: .1rem; font-size: .75rem; }
+ .mobile-more > summary::before { margin-right: 0; font-size: 1rem; }
+ .mobile-more .nav-list { position: absolute; right: -.5rem; bottom: calc(100% + .35rem); width: min(20rem, 100vw); max-height: 65vh; margin: 0; padding: .5rem; overflow-y: auto; border: 1px solid var(--border); border-radius: .65rem .65rem 0 0; background: #0e1520; box-shadow: 0 -1rem 2rem #05080d99; }
+ .mobile-primary-list .nav-link { min-height: 48px; }
+ .context-bar { position: static; min-height: 2.75rem; padding: .5rem 1rem; }
+ .context-actions .context-read-only, .context-actions .status-badge { display: none; }
+ .content { padding: 1rem 1rem calc(5.75rem + env(safe-area-inset-bottom)); }
+ .overview-heading { align-items: flex-start; flex-direction: column; }
+ .overview-heading-status { justify-items: start; }
+ .overview-kpi-grid, .overview-layout { grid-template-columns: 1fr; }
+ .action-queue { order: 1; }
+ .incident-queue { order: 2; }
+ .overview-table-card:not(.incident-queue) { order: 4; }
+ .incident-queue { grid-column: span 1; }
+ .action-queue { grid-column: span 1; }
+ .health-chip { flex: 1 1 10rem; }
+}
+
+/* M14 Signal Atelier foundations -------------------------------------------------
+ Deep-ink operational instrumentation. Components use semantic custom properties;
+ visual depth comes from tonal planes and hairlines, never decorative blur. */
+:root {
+ color: #dfe2ed;
+ background: #070b12;
+ font-family: Manrope, Aptos, "Segoe UI", system-ui, sans-serif;
+ --bg: #070b12;
+ --surface-lowest: #0a0e15;
+ --surface: #0f141b;
+ --surface-raised: #111b2a;
+ --surface-high: #172437;
+ --border: #24364c;
+ --border-quiet: #19283a;
+ --text: #dfe2ed;
+ --muted: #91a2b7;
+ --accent: #78a7ff;
+ --signal: #52d6d2;
+ --unknown: #91a2b7;
+ --ready: #55d6a5;
+ --attention: #f4b860;
+ --critical: #ff6b7a;
+ --stale: #d5945e;
+ --rail-width: 4.5rem;
+ --command-height: 3.5rem;
+ --radius-panel: .5rem;
+}
+
+body {
+ color: var(--text);
+ background:
+ radial-gradient(circle at 72% -20%, rgb(82 214 210 / .055), transparent 34rem),
+ radial-gradient(circle at 25% 0, rgb(120 167 255 / .06), transparent 32rem),
+ var(--bg);
+}
+h1, h2, h3, .brand strong { font-family: Sora, "Aptos Display", "Segoe UI", system-ui, sans-serif; }
+button, input, select, textarea { font-family: inherit; }
+::selection { background: rgb(120 167 255 / .28); color: var(--text); }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+
+.app-shell:not(.app-shell--wallboard) { grid-template-columns: var(--rail-width) minmax(0, 1fr); }
+.sidebar {
+ z-index: 30;
+ width: var(--rail-width);
+ gap: .5rem;
+ padding: .75rem .5rem;
+ overflow-x: visible;
+ border-color: var(--border-quiet);
+ background: linear-gradient(180deg, #0a101a 0%, #080d15 100%);
+}
+.brand { justify-content: center; min-height: 2.75rem; padding: 0; }
+.brand-copy { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.brand-mark {
+ position: relative;
+ width: 2.25rem;
+ height: 2.25rem;
+ border: 1px solid rgb(120 167 255 / .5);
+ border-radius: .55rem;
+ background: linear-gradient(145deg, #1c3151, #101d31);
+ color: #c8d9ff;
+ overflow: hidden;
+ box-shadow: inset 0 0 0 1px rgb(255 255 255 / .03);
+}
+.brand-mark::after { position: absolute; right: .1rem; bottom: .1rem; width: .38rem; height: .38rem; border: 1px solid #0a101a; border-radius: 50%; background: var(--signal); content: ""; }
+
+.desktop-navigation { gap: .18rem; }
+.nav-group { position: relative; }
+.nav-group > summary {
+ width: 3.5rem;
+ min-height: 2rem;
+ justify-content: center;
+ padding: 0;
+ border-radius: .4rem;
+ color: #667b94;
+}
+.nav-group > summary > span:first-child { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+.nav-group > summary > span:last-child { font-size: .7rem; }
+.nav-group[open] > summary { color: var(--accent); background: rgb(120 167 255 / .08); }
+.nav-group:not([open]) { border-bottom: 0; }
+.nav-group .nav-list { gap: .15rem; margin-top: .15rem; }
+.nav-link {
+ position: relative;
+ width: 3.5rem;
+ min-height: 2.6rem;
+ justify-content: center;
+ gap: 0;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: .45rem;
+}
+.nav-link > span:last-child { position: absolute; left: calc(100% + .7rem); z-index: 40; display: none; width: max-content; max-width: 15rem; padding: .45rem .65rem; border: 1px solid var(--border); border-radius: .4rem; background: #111b2af7; color: var(--text); box-shadow: 0 .5rem 1.5rem #02050a99; font-size: .75rem; font-weight: 700; }
+.nav-link:hover > span:last-child, .nav-link:focus-visible > span:last-child { display: block; }
+.nav-icon { width: 1.5rem; color: #71839a; font-size: 1rem; }
+.nav-link:hover, .nav-link:focus-visible { border-color: var(--border); background: var(--surface-raised); }
+.nav-link--active { border-color: rgb(120 167 255 / .25); background: rgb(120 167 255 / .11); box-shadow: inset 2px 0 0 var(--accent); }
+.nav-link--active .nav-icon { color: var(--accent); }
+.sidebar-status { display: grid; min-height: 2.75rem; place-items: center; margin-top: auto; padding: .35rem; border-color: var(--border-quiet); background: var(--surface-lowest); }
+.sidebar-status > span:last-child, .sidebar-status .status-badge { font-size: 0; }
+.sidebar-status .status-badge { margin: 0; padding: .25rem; }
+.sidebar-status .status-icon { margin: 0; font-size: .65rem; }
+
+.context-bar {
+ min-height: var(--command-height);
+ padding: .45rem 1.25rem;
+ border-color: var(--border-quiet);
+ background: rgb(7 11 18 / .94);
+ backdrop-filter: none;
+}
+.context-location { min-width: 0; }
+.context-server { display: flex; align-items: center; gap: .5rem; }
+.context-server > span:last-child { display: grid; line-height: 1.05; }
+.context-server small { color: var(--muted); font-size: .58rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
+.context-server strong { font-family: inherit; font-size: .78rem; }
+.context-server-mark { display: grid; width: 1.75rem; height: 1.75rem; place-items: center; border: 1px solid var(--border); border-radius: .4rem; background: var(--surface-raised); color: var(--signal); font: 700 .75rem ui-monospace, monospace; }
+.context-divider { align-self: stretch; width: 1px; margin-inline: .2rem; background: var(--border); }
+.context-product { color: var(--accent); letter-spacing: .02em; }
+.context-live { color: var(--ready); font-size: .72rem; letter-spacing: .02em; }
+.context-read-only { padding: .3rem .5rem; border: 1px solid var(--border-quiet); border-radius: 99rem; color: var(--muted); font-size: .68rem; }
+.content { padding: clamp(1rem, 2vw, 1.5rem); }
+
+.card, .widget-card, .topology-widget {
+ border-color: var(--border-quiet);
+ border-radius: var(--radius-panel);
+ background: linear-gradient(180deg, rgb(17 27 42 / .78), rgb(15 20 27 / .92));
+ box-shadow: none;
+}
+.card-heading { padding-bottom: .65rem; border-bottom: 1px solid var(--border-quiet); }
+.eyebrow, .card-kicker { color: var(--accent); letter-spacing: .13em; }
+.status-badge { border: 1px solid currentColor; background: rgb(145 162 183 / .08); }
+.status-badge--ready { background: rgb(85 214 165 / .08); }
+.status-badge--attention { background: rgb(244 184 96 / .09); }
+.status-badge--critical { background: rgb(255 107 122 / .1); }
+.button { min-height: 44px; border-radius: .45rem; }
+.button--secondary { background: rgb(17 27 42 / .62); }
+
+/* Shared composition primitives used throughout the M14 migration. */
+.instrument-band { display: grid; border: 1px solid var(--border-quiet); border-radius: var(--radius-panel); background: var(--surface); }
+.instrument-cell { min-width: 0; padding: .85rem 1rem; border-inline-start: 1px solid var(--border-quiet); }
+.instrument-cell:first-child { border-inline-start: 0; }
+.instrument-cell > small { display: block; color: var(--muted); font-size: .68rem; letter-spacing: .04em; text-transform: uppercase; }
+.instrument-cell > strong { display: block; margin-top: .35rem; font: 650 1.35rem ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+.data-plane { min-width: 0; border: 1px solid var(--border-quiet); border-radius: var(--radius-panel); background: linear-gradient(180deg, rgb(17 27 42 / .72), rgb(10 14 21 / .92)); }
+.focus-panel { border-inline-start: 2px solid var(--attention); }
+.context-inspector { border: 1px solid var(--border-quiet); border-radius: var(--radius-panel); background: var(--surface-lowest); }
+
+/* Command center: one asymmetric operational instrument, not a card gallery. */
+.command-overview { gap: .75rem; }
+.overview-heading { min-height: 4.5rem; padding: .15rem 0 .35rem; border-bottom: 1px solid var(--border-quiet); }
+.overview-heading h1 { max-width: 54rem; margin-bottom: .3rem; letter-spacing: -.035em; }
+.overview-heading-status { padding-inline-start: 1rem; border-inline-start: 1px solid var(--border-quiet); }
+.source-health-strip { gap: 0; overflow: hidden; border: 1px solid var(--border-quiet); border-radius: var(--radius-panel); background: var(--surface-lowest); }
+.health-chip { flex: 1 1 9rem; border: 0; border-inline-start: 1px solid var(--border-quiet); border-radius: 0; background: transparent; }
+.health-chip:first-child { border-inline-start: 0; }
+.overview-kpi-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; }
+.overview-kpi { padding: 1rem 1.1rem; border: 0; border-radius: 0; background: transparent; }
+.overview-kpi p { letter-spacing: .06em; text-transform: uppercase; }
+.overview-kpi strong { margin-block: .4rem .25rem; font-size: clamp(1.4rem, 2.2vw, 2rem); font-weight: 620; letter-spacing: -.05em; }
+.overview-layout {
+ display: grid;
+ grid-template-columns: minmax(15rem, .72fr) minmax(28rem, 1.55fr) minmax(15rem, .72fr);
+ grid-template-areas:
+ "focus signal now"
+ "focus capacity now"
+ "incidents incidents incidents";
+ gap: .75rem;
+ align-items: stretch;
+}
+.action-queue { grid-area: focus; }
+.signal-path-panel { grid-area: signal; min-height: 20rem; }
+.capacity-plane { grid-area: capacity; }
+.workload-inspector { grid-area: now; }
+.incident-queue { grid-area: incidents; }
+.action-queue, .capacity-plane, .workload-inspector, .incident-queue { padding: 1rem; }
+.action-queue { display: flex; flex-direction: column; background: linear-gradient(180deg, rgb(244 184 96 / .06), rgb(15 20 27 / .92)); }
+.action-queue ol { flex: 1; grid-auto-rows: max-content; align-content: start; }
+.queue-count { border: 1px solid rgb(244 184 96 / .35); border-radius: .4rem; background: rgb(244 184 96 / .08); color: var(--attention); }
+.signal-path-panel { padding: 1rem; overflow: hidden; background: linear-gradient(180deg, rgb(120 167 255 / .055), rgb(10 14 21 / .94)); }
+.signal-path-heading { align-items: center; }
+.signal-path-overall, .signal-state-text { display: inline-flex; align-items: center; gap: .35rem; color: var(--unknown); font-size: .72rem; font-weight: 750; }
+.signal-path-overall { min-height: 1.8rem; padding: .25rem .55rem; border: 1px solid currentColor; border-radius: 99rem; background: rgb(145 162 183 / .06); }
+.signal-path-intro { max-width: 54rem; margin: .75rem 0 .9rem; color: var(--muted); font-size: .78rem; line-height: 1.55; }
+.signal-path {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ margin: 0 -.25rem;
+ padding: .15rem .25rem .65rem;
+ overflow-x: auto;
+ list-style: none;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+.signal-path-stage { --stage-color: var(--unknown); position: relative; min-width: 0; padding-inline: .2rem; }
+.signal-path-stage--healthy { --stage-color: var(--ready); }
+.signal-path-stage--attention { --stage-color: var(--attention); }
+.signal-path-stage--critical { --stage-color: var(--critical); }
+.signal-path-stage--stale { --stage-color: var(--stale); }
+.signal-path-stage:not(:last-child)::after {
+ position: absolute;
+ top: 1.28rem;
+ left: calc(50% + 1.1rem);
+ z-index: 0;
+ width: calc(100% - 2.2rem);
+ height: 1px;
+ background: var(--stage-color);
+ opacity: .46;
+ content: "";
+}
+.signal-path-stage--unknown:not(:last-child)::after, .signal-path-stage--stale:not(:last-child)::after { background: repeating-linear-gradient(90deg, var(--stage-color) 0 4px, transparent 4px 8px); }
+.signal-path-stage--healthy:not(:last-child)::before {
+ position: absolute;
+ top: 1.12rem;
+ left: calc(50% + 1.05rem);
+ z-index: 1;
+ width: .34rem;
+ height: .34rem;
+ border-radius: 50%;
+ background: var(--signal);
+ box-shadow: 0 0 0 2px var(--surface-lowest);
+ content: "";
+ animation: signal-flow-pulse 2.8s linear infinite;
+}
+.signal-path-stage:nth-child(2)::before { animation-delay: -.45s; }
+.signal-path-stage:nth-child(3)::before { animation-delay: -.9s; }
+.signal-path-stage:nth-child(4)::before { animation-delay: -1.35s; }
+.signal-path-stage:nth-child(5)::before { animation-delay: -1.8s; }
+@keyframes signal-flow-pulse { from { left: calc(50% + 1.05rem); opacity: 0; } 18%, 82% { opacity: 1; } to { left: calc(150% - 1.4rem); opacity: 0; } }
+.signal-path-stage button {
+ position: relative;
+ z-index: 2;
+ display: grid;
+ width: 100%;
+ min-height: 8.6rem;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto auto 1fr;
+ gap: .35rem .45rem;
+ align-content: start;
+ padding: .5rem;
+ border: 1px solid transparent;
+ border-radius: .45rem;
+ background: transparent;
+ color: var(--text);
+ text-align: start;
+ cursor: pointer;
+}
+.signal-path-stage button:hover { border-color: rgb(120 167 255 / .3); background: rgb(120 167 255 / .055); }
+.signal-path-stage--selected button { border-color: color-mix(in srgb, var(--stage-color) 55%, var(--border)); background: color-mix(in srgb, var(--stage-color) 8%, transparent); box-shadow: inset 0 -2px 0 var(--stage-color); }
+.signal-stage-index { grid-column: 1 / -1; color: #62758d; font: 650 .58rem ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .08em; }
+.signal-stage-icon { display: grid; width: 1.55rem; height: 1.55rem; place-items: center; border: 1px solid var(--stage-color); border-radius: 50%; background: var(--surface-lowest); color: var(--stage-color); font-size: .72rem; }
+.signal-stage-copy { min-width: 0; grid-column: 1 / -1; }
+.signal-stage-copy strong, .signal-stage-copy small { display: block; }
+.signal-stage-copy strong { font-family: Sora, "Aptos Display", "Segoe UI", system-ui, sans-serif; font-size: .72rem; line-height: 1.2; }
+.signal-stage-copy small { margin-top: .12rem; color: var(--stage-color); font-size: .62rem; }
+.signal-stage-copy small span { margin-inline-end: .2rem; }
+.signal-stage-metric { align-self: end; grid-column: 1 / -1; padding-top: .4rem; border-top: 1px solid var(--border-quiet); }
+.signal-stage-metric small, .signal-stage-metric strong { display: block; }
+.signal-stage-metric small { color: var(--muted); font-size: .58rem; letter-spacing: .04em; text-transform: uppercase; }
+.signal-stage-metric strong { margin-top: .25rem; overflow-wrap: anywhere; font: 650 .82rem ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+.signal-path-inspector {
+ --inspector-color: var(--unknown);
+ display: grid;
+ grid-template-columns: minmax(8.5rem, .65fr) minmax(15rem, 1.4fr) auto;
+ gap: .85rem 1rem;
+ align-items: center;
+ min-height: 5.6rem;
+ padding: .8rem .9rem;
+ border: 1px solid var(--border-quiet);
+ border-inline-start: 2px solid var(--inspector-color);
+ border-radius: .45rem;
+ background: var(--surface-lowest);
+}
+.signal-path-inspector--healthy { --inspector-color: var(--ready); }
+.signal-path-inspector--attention { --inspector-color: var(--attention); }
+.signal-path-inspector--critical { --inspector-color: var(--critical); }
+.signal-path-inspector--stale { --inspector-color: var(--stale); }
+.signal-inspector-state { display: flex; min-width: 0; align-items: center; gap: .55rem; }
+.signal-inspector-state > span:last-child { min-width: 0; }
+.signal-inspector-state small, .signal-inspector-state strong { display: block; }
+.signal-inspector-state small { color: var(--muted); font-size: .58rem; letter-spacing: .06em; text-transform: uppercase; }
+.signal-inspector-state strong { margin-top: .2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: Sora, "Aptos Display", "Segoe UI", system-ui, sans-serif; font-size: .85rem; }
+.signal-inspector-icon { display: grid; flex: 0 0 auto; width: 2rem; height: 2rem; place-items: center; border: 1px solid var(--inspector-color); border-radius: .4rem; color: var(--inspector-color); }
+.signal-path-inspector dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; }
+.signal-path-inspector dl > div { min-width: 0; padding-inline: .65rem; border-inline-start: 1px solid var(--border-quiet); }
+.signal-path-inspector dt { color: var(--muted); font-size: .58rem; line-height: 1.25; letter-spacing: .04em; overflow-wrap: anywhere; text-transform: uppercase; }
+.signal-path-inspector dd { margin: .25rem 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 650 .72rem ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+.signal-path-inspector > p { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: .68rem; line-height: 1.45; }
+.signal-path-open { min-width: 7.5rem; grid-column: 3; grid-row: 1; white-space: nowrap; }
+.signal-tone--healthy { color: var(--ready); }
+.signal-tone--attention { color: var(--attention); }
+.signal-tone--critical { color: var(--critical); }
+.signal-tone--stale { color: var(--stale); }
+.signal-tone--unknown { color: var(--unknown); }
+.signal-path-disclaimer { margin: .7rem 0 0; color: #72849a; font-size: .62rem; line-height: 1.45; }
+.overview-data-list { margin-top: .4rem; }
+.overview-data-list li { min-height: 3rem; }
+.overview-now-list { display: grid; gap: 0; margin: 1rem 0 0; }
+.overview-now-list > div { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: .75rem 0; border-top: 1px solid var(--border-quiet); }
+.overview-now-list dt { color: var(--muted); font-size: .72rem; }
+.overview-now-list dd { margin: 0; font: 650 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+.workload-summary { grid-template-columns: auto 1fr; gap: .2rem .45rem; padding: .75rem 0; }
+.workload-summary strong { font-size: 1.8rem; }
+.incident-queue .overview-data-list { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; }
+.incident-queue .overview-data-list li { border: 1px solid var(--border-quiet); border-radius: .4rem; padding: .7rem; }
+
+/* Dashboard canvases inherit the same planes and connected instrumentation. */
+.dashboard-view-header { padding: .25rem 0 .85rem; border-bottom: 1px solid var(--border-quiet); }
+.dashboard-controls { border: 1px solid var(--border-quiet); border-radius: var(--radius-panel); background: var(--surface-lowest); }
+.dashboard-grid { gap: .75rem; }
+.dashboard-grid .widget-card { position: relative; overflow: hidden; border-color: var(--border-quiet); background: linear-gradient(180deg, rgb(17 27 42 / .72), rgb(10 14 21 / .92)); }
+.dashboard-grid .widget-card::before { position: absolute; inset: 0 auto 0 0; width: 2px; background: var(--accent); opacity: .38; content: ""; }
+.widget-card-heading { padding-bottom: .6rem; border-bottom: 1px solid var(--border-quiet); }
+.widget-description { color: var(--muted); }
+.dashboard-list-panel { max-width: none; }
+.dashboard-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
+.dashboard-list > li { border: 1px solid var(--border-quiet); border-radius: var(--radius-panel); background: var(--surface-lowest); }
+
+@media (max-width: 1180px) {
+ .overview-layout {
+ grid-template-columns: minmax(15rem, .72fr) minmax(0, 1.28fr);
+ grid-template-areas: "focus signal" "capacity now" "incidents incidents";
+ }
+ .signal-path-inspector { grid-template-columns: minmax(7.5rem, .55fr) minmax(13rem, 1.45fr) auto; }
+ .signal-path-inspector > p { grid-column: 1 / -1; }
+}
+
+@media (min-width: 701px) and (max-width: 1100px) {
+ .app-shell:not(.app-shell--wallboard) { grid-template-columns: var(--rail-width) minmax(0, 1fr); }
+ .sidebar { width: var(--rail-width); padding: .65rem .5rem; }
+}
+
+@media (max-width: 700px) {
+ .sidebar { width: 100%; padding: .6rem 1rem; background: #080d15; }
+ .brand { justify-content: flex-start; }
+ .brand-copy { position: static; width: auto; height: auto; overflow: visible; clip: auto; white-space: normal; }
+ .brand small { display: none; }
+ .mobile-navigation { background: rgb(8 13 21 / .98); box-shadow: 0 -.5rem 2rem #02050aaa; }
+ .mobile-navigation { grid-template-columns: minmax(0, 4fr) minmax(3.75rem, 1fr); }
+ .mobile-primary-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+ .mobile-primary-list .nav-link { width: 100%; min-height: 52px; }
+ .mobile-primary-list .nav-link > span:last-child { position: static; display: block; width: auto; max-width: none; padding: 0; border: 0; background: transparent; box-shadow: none; font-size: .75rem; font-weight: 650; }
+ .context-server, .context-divider, .context-product, .context-location > span[aria-hidden="true"] { display: none; }
+ .context-bar { position: sticky; min-height: 2.9rem; padding: .5rem 1rem; }
+ .context-location > strong { font-size: .78rem; }
+ .context-live { font-size: 0; }
+ .context-live span { font-size: .72rem; }
+ .content { padding: 1rem 1rem calc(6rem + env(safe-area-inset-bottom)); }
+ .instrument-band { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .instrument-cell { border-top: 1px solid var(--border-quiet); }
+ .instrument-cell:nth-child(-n + 2) { border-top: 0; }
+ .instrument-cell:nth-child(odd) { border-inline-start: 0; }
+ .overview-layout { grid-template-columns: 1fr; grid-template-areas: "focus" "incidents" "signal" "capacity" "now"; }
+ .overview-heading { min-height: 0; }
+ .overview-heading-status { padding-inline-start: 0; border-inline-start: 0; }
+ .source-health-strip { overflow-x: auto; flex-wrap: nowrap; }
+ .health-chip { min-width: 10rem; }
+ .overview-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .overview-kpi:nth-child(3) { border-inline-start: 0; }
+ .overview-kpi:nth-child(n + 3) { border-top: 1px solid var(--border-quiet); }
+ .signal-path-panel { min-height: 0; }
+ .signal-path { grid-template-columns: 1fr; gap: 0; overflow: visible; }
+ .signal-path-stage { padding: 0 0 .65rem; }
+ .signal-path-stage:not(:last-child)::after { top: 2.25rem; bottom: -.1rem; left: 1.16rem; width: 1px; height: auto; }
+ .signal-path-stage--healthy:not(:last-child)::before { top: 2.2rem; left: 1rem; animation: signal-flow-pulse-mobile 2.8s linear infinite; }
+ @keyframes signal-flow-pulse-mobile { from { transform: translateY(0); opacity: 0; } 18%, 82% { opacity: 1; } to { transform: translateY(5.2rem); opacity: 0; } }
+ .signal-path-stage button { min-height: 5.4rem; grid-template-columns: auto auto minmax(0, 1fr) minmax(5rem, auto); grid-template-rows: auto 1fr; align-items: center; gap: .2rem .5rem; padding: .55rem .6rem; }
+ .signal-stage-index { grid-column: 1 / -1; }
+ .signal-stage-copy { align-self: center; grid-column: 3; }
+ .signal-stage-metric { align-self: center; grid-column: 4; padding: 0 0 0 .6rem; border-top: 0; border-inline-start: 1px solid var(--border-quiet); text-align: end; }
+ .signal-path-inspector { grid-template-columns: 1fr; align-items: stretch; }
+ .signal-path-inspector dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .signal-path-inspector > p, .signal-path-open { grid-column: 1; grid-row: auto; }
+ .signal-path-open { width: 100%; justify-self: stretch; }
+ .incident-queue .overview-data-list { grid-template-columns: 1fr; }
+ .dashboard-list { grid-template-columns: 1fr; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
+}
+
+/* M14 operational screen families -------------------------------------------- */
+.page-intro {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ max-width: none;
+ min-height: 5.5rem;
+ margin-bottom: .9rem;
+ padding: .1rem 0 .9rem;
+ border-bottom: 1px solid var(--border-quiet);
+}
+.page-intro .eyebrow, .page-intro h1, .page-intro .intro { grid-column: 1; }
+.page-intro h1 { max-width: 58rem; margin-bottom: .35rem; letter-spacing: -.035em; }
+.page-intro .intro { max-width: 58rem; }
+
+/* Source, entity and policy summaries read as connected identity bands. */
+.container-summary, .host-summary, .service-source, .service-detail-summary,
+.system-status-summary, .settings-overview, .inventory-effective {
+ max-width: none;
+ border-inline-start: 2px solid var(--accent);
+ background: linear-gradient(90deg, rgb(120 167 255 / .045), rgb(15 20 27 / .9) 32%);
+}
+.container-summary .card-heading, .host-summary .card-heading,
+.service-source .card-heading, .service-detail-summary .card-heading,
+.system-status-summary .card-heading { align-items: center; }
+.container-provenance, .host-provenance, .service-provenance, .source-status-observed {
+ color: var(--muted);
+ font: 500 .72rem ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-variant-numeric: tabular-nums;
+}
+.container-detail-status, .detail-actions { gap: .5rem; }
+.inventory-detail-header { padding: .25rem 0 1rem; border-bottom: 1px solid var(--border-quiet); }
+.inventory-detail-header h1 { font-size: clamp(1.7rem, 3vw, 2.5rem); }
+
+/* Metric summaries connect related values instead of floating separately. */
+.host-metric-grid, .inventory-summary, .network-health-grid, .service-details-grid,
+.event-summary, .alert-operation-summary {
+ gap: 0;
+ overflow: hidden;
+ border: 1px solid var(--border-quiet);
+ border-radius: var(--radius-panel);
+ background: var(--surface);
+}
+.host-metric-grid .card, .inventory-summary .card,
+.network-health-grid > *, .event-summary > *, .alert-operation-summary > * {
+ border: 0;
+ border-inline-start: 1px solid var(--border-quiet);
+ border-radius: 0;
+ background: transparent;
+}
+.host-metric-grid .card:first-child, .inventory-summary .card:first-child,
+.network-health-grid > *:first-child, .event-summary > *:first-child,
+.alert-operation-summary > *:first-child { border-inline-start: 0; }
+.host-metric-grid h2, .inventory-summary h2, .alert-operation-summary strong {
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -.04em;
+}
+
+/* Filter and section navigation are compact command surfaces. */
+.list-filters, .inventory-filters, .topology-controls, .dashboard-controls,
+.alert-section-nav, .event-filters {
+ padding: .75rem;
+ border: 1px solid var(--border-quiet);
+ border-radius: var(--radius-panel);
+ background: var(--surface-lowest);
+}
+.list-filters input, .list-filters select, .inventory-filters input,
+.inventory-filters select, .topology-controls input, .topology-controls select,
+.form-grid input, .form-grid select, .form-grid textarea, .control-form input,
+.control-form select, .control-form textarea {
+ border-color: var(--border);
+ border-radius: .4rem;
+ background: #0a111c;
+}
+.alert-section-nav { top: calc(var(--command-height) + .5rem); z-index: 8; gap: .35rem; }
+.alert-section-nav a, .alert-section-nav button { border-radius: .4rem; }
+
+/* Tables and large lists are data planes with quiet row rhythm. */
+.host-detail-grid, .service-details-grid, .network-detail-grid,
+.inventory-detail-grid, .alert-layout, .settings-hub, .card-grid { gap: .75rem; }
+.host-table-wrap, .service-table-wrap, .network-table-wrap {
+ border: 1px solid var(--border-quiet);
+ border-radius: .4rem;
+ background: var(--surface-lowest);
+}
+.host-table thead, .service-table thead, .network-table thead { background: rgb(23 36 55 / .65); }
+.host-table th, .service-table th, .network-table th {
+ color: var(--text);
+ font-size: .68rem;
+ letter-spacing: .055em;
+ text-transform: uppercase;
+}
+.host-table td, .service-table td, .network-table td { color: var(--muted); }
+.host-table tbody tr, .service-table tbody tr, .network-table tbody tr { transition: background-color .12s ease; }
+.host-table tbody tr:hover, .service-table tbody tr:hover, .network-table tbody tr:hover { background: rgb(120 167 255 / .045); }
+.inventory-list, .event-list, .alert-operation-list-items { gap: 0; }
+.inventory-list li, .event-list > li, .alert-operation-list-items > li {
+ border-color: var(--border-quiet);
+ background: transparent;
+}
+.inventory-list li:hover, .event-list > li:hover, .alert-operation-list-items > li:hover { background: rgb(120 167 255 / .035); }
+
+/* Event and incident streams gain a true timeline axis. */
+.event-list, .dashboard-event-list { position: relative; }
+.event-list::before, .dashboard-event-list::before {
+ position: absolute;
+ top: .75rem;
+ bottom: .75rem;
+ left: 1.1rem;
+ width: 1px;
+ background: var(--border);
+ content: "";
+}
+.event-list > li, .dashboard-event-list > li { position: relative; padding-inline-start: 2.25rem; }
+.event-severity, .incident-dot { position: relative; z-index: 1; box-shadow: 0 0 0 4px var(--surface); }
+.event-row-heading strong, .alert-operation-button strong { color: var(--text); }
+.event-time, .event-row-meta, .alert-detail small { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+
+/* Storage is a capacity plane: contiguous sources, denser map, quiet heatmap. */
+.storage-source-grid { gap: 0; overflow: hidden; border: 1px solid var(--border-quiet); border-radius: .4rem; }
+.storage-source-grid > article { border: 0; border-inline-start: 1px solid var(--border-quiet); border-radius: 0; background: var(--surface-lowest); }
+.storage-source-grid > article:first-child { border-inline-start: 0; }
+.storage-visual { max-width: none; background: linear-gradient(180deg, rgb(17 27 42 / .7), rgb(10 14 21 / .94)); }
+.storage-map-grid { gap: .35rem; }
+.storage-map-node a { min-height: 5.5rem; border-color: var(--border-quiet); border-radius: .35rem; background: rgb(17 27 42 / .55); }
+.storage-map-node a:hover, .storage-map-node a:focus-visible { border-color: var(--accent); background: rgb(120 167 255 / .06); }
+.storage-heatmap-cell { border-color: transparent; border-radius: .15rem; }
+
+/* Configuration areas remain task-focused and clearly separated from operations. */
+.alert-operation-list, .alert-controls, .settings-hub-card, .forecast-card,
+.technical-details, .service-certificate, .container-panel, .process-panel,
+.service-panel, .network-panel, .inventory-panel {
+ border-color: var(--border-quiet);
+ background: linear-gradient(180deg, rgb(17 27 42 / .64), rgb(10 14 21 / .9));
+}
+.alert-operation-summary button { min-height: 6rem; }
+.alert-summary-critical { background: rgb(255 107 122 / .07) !important; }
+.control-form, .preview-panel, .alert-detail, .guided-options,
+.technical-details[open] { border-color: var(--border-quiet); background: var(--surface-lowest); }
+.technical-details > summary, .source-status-technical > summary,
+.storage-accessible-summary > summary { min-height: 44px; padding-block: .65rem; color: var(--accent); }
+.settings-hub { grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: none; }
+.settings-hub-card { border-top: 2px solid var(--accent); }
+.settings-hub-card a { min-height: 4.5rem; }
+
+/* Empty/error/loading states are intentional instrument states. */
+.state-page, .empty-state, .dashboard-message {
+ background-image: linear-gradient(var(--border-quiet) 1px, transparent 1px), linear-gradient(90deg, var(--border-quiet) 1px, transparent 1px);
+ background-size: 2rem 2rem;
+ background-position: center;
+}
+.state-page::before, .empty-state::before { width: 3rem; height: 2px; margin-bottom: .75rem; background: var(--unknown); content: ""; }
+
+@media (max-width: 900px) {
+ .settings-hub { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .host-metric-grid, .service-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .host-metric-grid .card:nth-child(odd), .service-details-grid > div:nth-child(odd) { border-inline-start: 0; }
+ .host-metric-grid .card:nth-child(n + 3), .service-details-grid > div:nth-child(n + 3) { border-top: 1px solid var(--border-quiet); }
+}
+
+@media (max-width: 700px) {
+ .page-intro { display: block; min-height: 0; padding-bottom: .75rem; }
+ .host-metric-grid, .inventory-summary, .network-health-grid, .event-summary,
+ .alert-operation-summary, .service-details-grid { grid-template-columns: 1fr; }
+ .host-metric-grid .card, .inventory-summary .card, .network-health-grid > *,
+ .event-summary > *, .alert-operation-summary > *, .service-details-grid > div {
+ border-inline-start: 0;
+ border-top: 1px solid var(--border-quiet);
+ }
+ .host-metric-grid .card:first-child, .inventory-summary .card:first-child,
+ .network-health-grid > *:first-child, .event-summary > *:first-child,
+ .alert-operation-summary > *:first-child, .service-details-grid > div:first-child { border-top: 0; }
+ .storage-source-grid { grid-template-columns: 1fr; }
+ .storage-source-grid > article { border-inline-start: 0; border-top: 1px solid var(--border-quiet); }
+ .storage-source-grid > article:first-child { border-top: 0; }
+ .settings-hub { grid-template-columns: 1fr; }
+ .event-list::before, .dashboard-event-list::before { left: .8rem; }
+ .event-list > li, .dashboard-event-list > li { padding-inline-start: 1.8rem; }
+ .mobile-data-list > li, .inventory-entity-list > li { border-color: var(--border-quiet); border-radius: .45rem; background: var(--surface-lowest); }
+}
+
+/* Mobile incident command mode ------------------------------------------------ */
+.incident-list-panel, .incident-timeline-panel, .incident-notes-panel,
+.incident-workflow-placeholder { max-width: none; }
+.incident-list { gap: 0; margin-top: .75rem; }
+.incident-list-row { position: relative; border-color: var(--border-quiet) !important; }
+.incident-list-row::before { position: absolute; inset: .7rem auto .7rem 0; width: 2px; background: var(--unknown); content: ""; }
+.incident-list-row--critical::before { background: var(--critical); }
+.incident-list-row--degraded::before { background: var(--attention); }
+.incident-list-item { min-height: 4.5rem; padding-inline: 1rem .4rem; }
+.incident-command-strip {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ overflow: hidden;
+ margin-bottom: .75rem;
+ border: 1px solid var(--border-quiet);
+ border-inline-start: 2px solid var(--unknown);
+ border-radius: var(--radius-panel);
+ background: var(--surface-lowest);
+}
+.incident-command-strip--critical { border-inline-start-color: var(--critical); background: linear-gradient(90deg, rgb(255 107 122 / .07), var(--surface-lowest) 28%); }
+.incident-command-strip--degraded { border-inline-start-color: var(--attention); }
+.incident-command-strip > span { min-width: 0; padding: .8rem 1rem; border-inline-start: 1px solid var(--border-quiet); }
+.incident-command-strip > span:first-child { border-inline-start: 0; }
+.incident-command-strip small { display: block; margin-bottom: .3rem; color: var(--muted); font-size: .65rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
+.incident-command-strip strong { display: block; overflow-wrap: anywhere; font: 650 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+.incident-rationale { border-inline-start: 2px solid var(--attention); }
+.incident-facts { gap: 0; overflow: hidden; border: 1px solid var(--border-quiet); border-radius: .4rem; }
+.incident-facts > div { padding: .7rem; border-inline-start: 1px solid var(--border-quiet); }
+.incident-facts > div:nth-child(odd) { border-inline-start: 0; }
+.incident-facts > div:nth-child(n + 3) { border-top: 1px solid var(--border-quiet); }
+.incident-timeline { border-color: var(--accent); }
+.incident-timeline li { border-color: var(--border-quiet); }
+.incident-timeline time { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+
+/* Wallboard 2.0: fixed-distance operational composition. */
+.app-shell--wallboard .content { padding: .75rem; background: #070b12; }
+.wallboard-shell {
+ grid-template-areas: "masthead" "priority" "frame" "status";
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ gap: .5rem;
+}
+.wallboard-header { grid-area: masthead; min-height: 4.25rem; margin: 0; padding: .25rem .5rem .65rem; border-bottom: 1px solid var(--border-quiet); }
+.wallboard-header h1 { margin: 0; font-size: clamp(1.65rem, 2.15vw, 2.35rem); letter-spacing: -.04em; }
+.wallboard-header .intro { max-width: 48rem; color: var(--muted); }
+.wallboard-actions { gap: .4rem; }
+.wallboard-actions .button, .wallboard-connection { min-height: 2.5rem; margin: 0; border: 1px solid currentColor; border-radius: .4rem; background: rgb(145 162 183 / .07); }
+.wallboard-connection--connected { background: rgb(85 214 165 / .07); }
+.wallboard-connection--reconnecting, .wallboard-connection--unavailable { background: rgb(244 184 96 / .08); }
+.wallboard-priority { grid-area: priority; gap: 0; margin: 0; overflow: hidden; border: 1px solid var(--border-quiet); border-radius: var(--radius-panel); background: var(--surface-lowest); }
+.wallboard-priority-item { min-height: 3.25rem; border: 0; border-inline-start: 1px solid var(--border-quiet); border-radius: 0; background: transparent; }
+.wallboard-priority-item:first-child { border-inline-start: 0; }
+.wallboard-priority-item--ready { box-shadow: inset 0 2px 0 rgb(85 214 165 / .55); }
+.wallboard-priority-item--attention { box-shadow: inset 0 2px 0 rgb(244 184 96 / .65); }
+.wallboard-priority-item strong { font-size: .75rem; letter-spacing: .06em; text-transform: uppercase; }
+.wallboard-priority-item small { color: var(--text); font: 650 .9rem ui-monospace, SFMono-Regular, Consolas, monospace; }
+.wallboard-frame { grid-area: frame; border: 1px solid var(--border-quiet); border-radius: var(--radius-panel); background: linear-gradient(180deg, rgb(17 27 42 / .45), rgb(7 11 18 / .9)); }
+.wallboard-status { grid-area: status; min-height: 2rem; align-items: center; justify-content: space-between; gap: .75rem; margin: 0; padding: .35rem .6rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); font-size: .7rem; }
+.wallboard-view { padding: .65rem; }
+.wallboard-view .dashboard-view-header { padding-bottom: .45rem; border-color: var(--border-quiet); }
+.wallboard-view .dashboard-grid { gap: .4rem; }
+.wallboard-view .widget-card { border-color: var(--border-quiet); border-radius: .35rem; background: rgb(10 14 21 / .82); }
+.wallboard-view .widget-card::before { opacity: .55; }
+
+@media (max-width: 700px) {
+ .incident-page-intro { padding-bottom: .6rem; }
+ .incident-command-strip { position: sticky; top: 2.9rem; z-index: 7; grid-template-columns: repeat(2, minmax(0, 1fr)); box-shadow: 0 .75rem 1.5rem #02050a99; }
+ .incident-command-strip > span { min-height: 3.8rem; padding: .65rem .75rem; border-top: 1px solid var(--border-quiet); }
+ .incident-command-strip > span:nth-child(-n + 2) { border-top: 0; }
+ .incident-command-strip > span:nth-child(odd) { border-inline-start: 0; }
+ .incident-detail-grid { gap: .75rem; }
+ .incident-rationale { order: 1; }
+ .incident-follow-up { order: 2; }
+ .incident-facts { grid-template-columns: 1fr 1fr; }
+ .incident-timeline-panel { background: var(--surface-lowest); }
+ .incident-timeline { padding-inline-start: .7rem; }
+ .incident-timeline li { grid-template-columns: 1fr; }
+}
diff --git a/apps/web/src/systemStatus.ts b/apps/web/src/systemStatus.ts
new file mode 100644
index 0000000..1cab6b5
--- /dev/null
+++ b/apps/web/src/systemStatus.ts
@@ -0,0 +1,220 @@
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { presentComponent, presentReason } from './presentation';
+
+export type ComponentStatus = { id: string; state: string; reason: string; lastSuccessAt?: string };
+export type SourceLag = { sourceId: string; state: string; reason: string; ageSeconds?: number };
+export type BackupStatus = { state: string; reason: string; ageSeconds?: number; verifiedAt?: string; lastSuccessAt?: string };
+export type SystemStatus = {
+ version: string;
+ release?: { version: string; commit: string; builtAt?: string; migrationVersion: string };
+ generatedAt: string;
+ overallState: string;
+ components: ComponentStatus[];
+ backup: BackupStatus;
+ sourceLag: SourceLag[];
+};
+
+export type SystemStatusState = 'loading' | 'ready' | 'error' | 'unauthorized' | 'forbidden';
+export type SystemStatusSnapshot = { state: SystemStatusState; status: SystemStatus | null; fetchedAt: number };
+
+/**
+ * ADR-0008: a snapshot older than this is treated as missing telemetry. It is
+ * generous relative to the refresh interval so that a single skipped refresh
+ * does not flip the badge, but it guarantees a wallboard that lost its backend
+ * degrades to Unknown instead of freezing on a green badge.
+ */
+export const STALE_AFTER_MS = 180_000;
+export const BACKUP_STALE_AFTER_SECONDS = 24 * 60 * 60;
+const REFRESH_MS = 30_000;
+
+const listeners = new Set<(snapshot: SystemStatusSnapshot) => void>();
+let snapshot: SystemStatusSnapshot = { state: 'loading', status: null, fetchedAt: 0 };
+let controller: AbortController | null = null;
+let timer: ReturnType | null = null;
+
+function publish(next: SystemStatusSnapshot): void {
+ snapshot = next;
+ [...listeners].forEach((listener) => listener(snapshot));
+}
+
+async function load(): Promise {
+ // A newer request always wins; the older one is aborted and its result
+ // discarded even if it happened to resolve first.
+ controller?.abort();
+ const active = new AbortController();
+ controller = active;
+ try {
+ const response = await fetch('/api/v1/system/status', { signal: active.signal });
+ if (controller !== active) return;
+ if (response.status === 401) {
+ publish({ state: 'unauthorized', status: null, fetchedAt: Date.now() });
+ return;
+ }
+ if (response.status === 403) {
+ publish({ state: 'forbidden', status: null, fetchedAt: Date.now() });
+ return;
+ }
+ if (!response.ok) throw new Error('status');
+ const value = await response.json() as SystemStatus;
+ if (controller !== active) return;
+ publish({ state: 'ready', status: value, fetchedAt: Date.now() });
+ } catch (error: unknown) {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ if (controller !== active) return;
+ // ADR-0008: a failed refresh must never leave a previously healthy snapshot
+ // behind as if it were current.
+ publish({ state: 'error', status: null, fetchedAt: Date.now() });
+ } finally {
+ if (controller === active) controller = null;
+ }
+}
+
+let requestedAt = 0;
+
+/** Starts a request only when nothing recent is in flight or cached. */
+function loadIfStale(): void {
+ const now = Date.now();
+ if (controller && now - requestedAt < REFRESH_MS) return;
+ if (snapshot.fetchedAt !== 0 && now - snapshot.fetchedAt < REFRESH_MS) return;
+ requestedAt = now;
+ void load();
+}
+
+/** Forces a refresh, e.g. from a retry button. */
+export function refreshSystemStatus(): void {
+ publish({ state: 'loading', status: null, fetchedAt: 0 });
+ requestedAt = Date.now();
+ void load();
+}
+
+/** Clears the singleton between isolated DOM tests; production code never calls this. */
+export function resetSystemStatusForTests(): void {
+ if (timer) clearInterval(timer);
+ timer = null;
+ controller?.abort();
+ controller = null;
+ listeners.clear();
+ snapshot = { state: 'loading', status: null, fetchedAt: 0 };
+ requestedAt = 0;
+}
+
+/**
+ * One shared `/api/v1/system/status` reader. Several surfaces (overview,
+ * sidebar, dashboard header, status page) need the same aggregate, and a
+ * rotating wallboard remounts them constantly; a single polled store keeps that
+ * to one bounded request per interval and cleans up when the last consumer goes.
+ */
+export function useSystemStatus(): SystemStatusSnapshot {
+ const [value, setValue] = useState(snapshot);
+ useEffect(() => {
+ listeners.add(setValue);
+ setValue(snapshot);
+ loadIfStale();
+ if (!timer) timer = setInterval(() => { requestedAt = Date.now(); void load(); }, REFRESH_MS);
+ return () => {
+ listeners.delete(setValue);
+ if (listeners.size > 0) return;
+ if (timer) clearInterval(timer);
+ timer = null;
+ controller?.abort();
+ controller = null;
+ };
+ }, []);
+ return value;
+}
+
+export function systemStateLabel(state: string): string {
+ if (state === 'healthy') return copy.systemStatus.healthy;
+ if (state === 'disabled') return copy.systemStatus.disabled;
+ if (state === 'degraded') return copy.systemStatus.degraded;
+ return copy.systemStatus.unknown;
+}
+
+export function componentStatus(status: SystemStatus | null, id: string): ComponentStatus | null {
+ return status?.components.find((component) => component.id === id) ?? null;
+}
+
+/** Fails closed when an allegedly healthy backup is old or has no usable age. */
+export function backupPresentation(backup: BackupStatus | undefined, now = Date.now()): BackupStatus {
+ if (!backup) return { state: 'unknown', reason: 'no_verified_backup' };
+ let ageSeconds = backup.ageSeconds;
+ if (ageSeconds == null || !Number.isFinite(ageSeconds)) {
+ const observed = Date.parse(backup.lastSuccessAt ?? backup.verifiedAt ?? '');
+ ageSeconds = Number.isNaN(observed) ? undefined : Math.max(0, (now - observed) / 1000);
+ }
+ if (backup.state !== 'healthy') return { ...backup, ageSeconds };
+ if (ageSeconds == null) return { ...backup, state: 'unknown', reason: 'no_verified_backup', ageSeconds };
+ if (ageSeconds > BACKUP_STALE_AFTER_SECONDS) return { ...backup, state: 'degraded', reason: 'backup_stale', ageSeconds };
+ return { ...backup, ageSeconds };
+}
+
+function isStale(snapshotValue: SystemStatusSnapshot, now: number): boolean {
+ const generated = Date.parse(snapshotValue.status?.generatedAt ?? '');
+ if (Number.isNaN(generated)) return true;
+ return now - generated > STALE_AFTER_MS || now - snapshotValue.fetchedAt > STALE_AFTER_MS;
+}
+
+export type AggregateStatus = { state: string; label: string; tone: 'ready' | 'unknown'; detail: string; stale: boolean };
+
+/**
+ * Maps a snapshot to what the UI may claim. ADR-0008: only a `ready` snapshot
+ * whose payload says `healthy` and whose observation is fresh may render as
+ * healthy. Loading, error, unauthorized, forbidden, unknown, missing and stale
+ * all render as Unknown, and `degraded`/`disabled` keep the Unknown tone because
+ * neither is a healthy system.
+ */
+export function aggregateStatus(snapshotValue: SystemStatusSnapshot, now = Date.now()): AggregateStatus {
+ if (snapshotValue.state === 'loading') {
+ return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.statusLoadingDetail, stale: false };
+ }
+ if (snapshotValue.state === 'unauthorized') {
+ return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.unauthorizedDetail, stale: false };
+ }
+ if (snapshotValue.state === 'forbidden') {
+ return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.forbiddenDetail, stale: false };
+ }
+ if (snapshotValue.state === 'error' || !snapshotValue.status) {
+ return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.unavailableDetail, stale: false };
+ }
+ const stale = isStale(snapshotValue, now);
+ if (stale) {
+ return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.staleDetail, stale: true };
+ }
+ const state = snapshotValue.status.overallState;
+ if (state === 'healthy') {
+ return { state, label: copy.systemStatus.healthy, tone: 'ready', detail: copy.overview.healthyDetail, stale: false };
+ }
+ if (state === 'degraded') {
+ return { state, label: copy.systemStatus.degraded, tone: 'unknown', detail: copy.overview.degradedDetail, stale: false };
+ }
+ if (state === 'disabled') {
+ return { state, label: copy.systemStatus.disabled, tone: 'unknown', detail: copy.overview.disabledDetail, stale: false };
+ }
+ const connectedSources = snapshotValue.status.sourceLag?.length ?? 0;
+ return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: connectedSources > 0 ? copy.overview.partialDetail : copy.overview.unknownDetail, stale: false };
+}
+
+/** The optimistic overview heading is reserved for a fully healthy, issue-free view. */
+export function overviewTitle(status: AggregateStatus, problemCount: number, operationalAttention = false): string {
+ if (status.state === 'healthy' && problemCount === 0 && !operationalAttention) return copy.overview.title;
+ if (status.state === 'degraded' || problemCount > 0 || operationalAttention) return copy.overview.attentionTitle;
+ return copy.overview.unknownTitle;
+}
+
+/** Bounded list of non-healthy signals, used for the degraded overview summary. */
+export function statusProblems(status: SystemStatus | null): Array<{ id: string; label: string; reason: string }> {
+ if (!status) return [];
+ const required = new Set(['database', 'worker', 'prometheus', 'query', 'unraid', 'storage']);
+ const actionableComponents = (status.components ?? []).filter((component) => component.state !== 'healthy' && (component.state !== 'disabled' || required.has(component.id)));
+ const componentIDs = new Set(actionableComponents.map((component) => component.id));
+ const components = actionableComponents
+ .map((component) => ({ id: 'component:' + component.id, label: presentComponent(component.id), reason: presentReason(component.reason) }));
+ const sources = (status.sourceLag ?? []).filter((source) => source.state !== 'healthy' && !componentIDs.has(source.sourceId))
+ .map((source) => ({ id: 'source:' + source.sourceId, label: presentComponent(source.sourceId), reason: presentReason(source.reason) }));
+ const backupStatus = backupPresentation(status.backup);
+ const backup = backupStatus.state !== 'healthy' && backupStatus.state !== 'disabled'
+ ? [{ id: 'backup', label: copy.systemStatus.backup, reason: presentReason(backupStatus.reason) }]
+ : [];
+ return [...components, ...sources, ...backup].slice(0, 10);
+}
diff --git a/apps/web/src/useLiveMetric.ts b/apps/web/src/useLiveMetric.ts
new file mode 100644
index 0000000..b37fdb4
--- /dev/null
+++ b/apps/web/src/useLiveMetric.ts
@@ -0,0 +1,89 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { MetricQueryRequest } from './metricClient';
+import { LiveClient, liveQueryKey, type LiveEvent, type LiveSubscription } from './liveClient';
+import { LiveChartAdapter, type LiveSample } from './liveBuffer';
+
+type LiveState = 'idle' | 'connecting' | 'live' | 'error';
+type LiveMetricResult = { state: LiveState; pointCount: number; series: ReturnType; error: string | null };
+
+const noSamples: LiveSample[] = [];
+
+/**
+ * Cheap identity for a sample batch. The previous implementation serialised the
+ * whole array into the effect dependency list, which ran on every render — and
+ * the hook re-renders every 16 ms while streaming, over arrays of up to
+ * 20 series x 4000 points. Sampling the ends is O(1) and recomputed only when
+ * the array reference actually changes.
+ */
+function samplesKey(samples: readonly LiveSample[]): string {
+ if (samples.length === 0) return '0';
+ const first = samples[0];
+ const last = samples[samples.length - 1];
+ return samples.length + '|' + first.series + '|' + first.timestamp + '|' + first.value + '|' + last.series + '|' + last.timestamp + '|' + last.value;
+}
+
+export function useLiveMetric(client: LiveClient, request: MetricQueryRequest | null, initialSamples: LiveSample[] = noSamples, capacity = 240): LiveMetricResult {
+ const adapter = useRef(null);
+ const [state, setState] = useState('idle');
+ const [error, setError] = useState(null);
+ const [, render] = useState(0);
+ const frame = useRef | null>(null);
+ if (!adapter.current) adapter.current = new LiveChartAdapter(capacity);
+ const requestKey = request ? liveQueryKey(request) : '';
+ const initialKey = useMemo(() => samplesKey(initialSamples), [initialSamples]);
+ // Always seed from the latest array, so an unchanged fingerprint can never
+ // reintroduce a stale batch.
+ const latestSamples = useRef(initialSamples);
+ latestSamples.current = initialSamples;
+
+ // Historical seed data can arrive after the live subscription has opened.
+ // Refreshing that seed must not tear down and recreate the WebSocket: on a
+ // rotating wallboard that produced a close/open race for every dashboard.
+ useEffect(() => {
+ const buffer = adapter.current;
+ if (!buffer) return undefined;
+ buffer.clear();
+ buffer.append(latestSamples.current);
+ return undefined;
+ }, [requestKey, initialKey, capacity]);
+
+ useEffect(() => {
+ const buffer = adapter.current;
+ if (!buffer) return undefined;
+ setError(null);
+ if (!request) {
+ setState('idle');
+ return undefined;
+ }
+ setState('connecting');
+ let subscription: LiveSubscription | null = null;
+ const handle = (event: LiveEvent) => {
+ if (event.type === 'samples') {
+ buffer.append(event.samples);
+ setState('live');
+ if (!frame.current) frame.current = setTimeout(() => { frame.current = null; render((value) => value + 1); }, 16);
+ } else if (event.type === 'status') {
+ if (event.state === 'resync-required') buffer.clear();
+ setState(event.state === 'subscribed' ? 'live' : event.state === 'unsubscribed' ? 'idle' : 'connecting');
+ if (event.state === 'resync-required') setError(null);
+ } else {
+ setState('error');
+ setError(event.message);
+ }
+ };
+ subscription = client.subscribe(request, handle);
+ // Series keys that stopped reporting must not accumulate for the lifetime of
+ // a wallboard session.
+ const sweep = setInterval(() => { buffer.evictStale(); }, 60000);
+ return () => {
+ subscription?.unsubscribe();
+ clearInterval(sweep);
+ if (frame.current) clearTimeout(frame.current);
+ frame.current = null;
+ buffer.clear();
+ client.releaseUnused();
+ };
+ }, [client, requestKey, capacity]);
+ const current = adapter.current;
+ return { state, pointCount: current?.pointCount ?? 0, series: current?.snapshot() ?? [], error };
+}
diff --git a/apps/web/src/useMetricQuery.ts b/apps/web/src/useMetricQuery.ts
new file mode 100644
index 0000000..8585112
--- /dev/null
+++ b/apps/web/src/useMetricQuery.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react';
+import { MetricApiError, MetricClient, type MetricQueryRequest, type MetricQueryResponse } from './metricClient';
+
+type MetricQueryState = { status: 'idle' | 'loading' | 'success' | 'error'; response: MetricQueryResponse | null; error: MetricApiError | null };
+const idle: MetricQueryState = { status: 'idle', response: null, error: null };
+
+export function useMetricQuery(client: MetricClient, request: MetricQueryRequest | null): MetricQueryState {
+ const [state, setState] = useState(idle);
+ const requestKey = request ? JSON.stringify(request) : '';
+ useEffect(() => {
+ if (!request) { setState(idle); return undefined; }
+ const controller = new AbortController();
+ setState({ status: 'loading', response: null, error: null });
+ client.queryRange(request, controller.signal).then((response) => {
+ if (!controller.signal.aborted) setState({ status: 'success', response, error: null });
+ }).catch((error: unknown) => {
+ if (controller.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) return;
+ setState({ status: 'error', response: null, error: error instanceof MetricApiError ? error : new MetricApiError(0) });
+ });
+ return () => controller.abort();
+ }, [client, requestKey]);
+ return state;
+}
\ No newline at end of file
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/web/src/wallboardLayout.ts b/apps/web/src/wallboardLayout.ts
new file mode 100644
index 0000000..cd6aa96
--- /dev/null
+++ b/apps/web/src/wallboardLayout.ts
@@ -0,0 +1,28 @@
+export const wallboardColumns = 24;
+export const wallboardRowsPerSlide = 13;
+
+export type WallboardPlacement = { columnStart: number; columnSpan: number; rowStart: number; rowSpan: number };
+
+function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number {
+ const number = Number(value);
+ if (!Number.isFinite(number)) return fallback;
+ return Math.min(maximum, Math.max(minimum, Math.floor(number)));
+}
+
+export function wallboardSlideIndex(y: unknown): number {
+ const row = boundedInteger(y, 0, 0, Number.MAX_SAFE_INTEGER);
+ return Math.floor(row / wallboardRowsPerSlide);
+}
+
+export function wallboardPlacement(layout: Record): WallboardPlacement {
+ const columnStart = boundedInteger(layout.x, 0, 0, wallboardColumns - 1) + 1;
+ const row = boundedInteger(layout.y, 0, 0, Number.MAX_SAFE_INTEGER) % wallboardRowsPerSlide;
+ const requestedWidth = boundedInteger(layout.w, 6, 1, wallboardColumns);
+ const requestedHeight = boundedInteger(layout.h, 4, 1, wallboardRowsPerSlide);
+ return {
+ columnStart,
+ columnSpan: Math.min(requestedWidth, wallboardColumns - columnStart + 1),
+ rowStart: row + 1,
+ rowSpan: Math.min(requestedHeight, wallboardRowsPerSlide - row),
+ };
+}
diff --git a/apps/web/tests/e2e/accessibility.spec.ts b/apps/web/tests/e2e/accessibility.spec.ts
new file mode 100644
index 0000000..05da32a
--- /dev/null
+++ b/apps/web/tests/e2e/accessibility.spec.ts
@@ -0,0 +1,129 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test, type Page } from '@playwright/test';
+import path from 'node:path';
+
+const generatedAt = new Date().toISOString();
+
+async function mockAPI(page: Page): Promise {
+ await page.route('**/api/v1/**', async (route) => {
+ const path = new URL(route.request().url()).pathname;
+ if (path === '/api/v1/system/status') {
+ await route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({
+ version: '1', generatedAt, overallState: 'degraded',
+ components: [{ id: 'database', state: 'healthy', reason: 'ok' }, { id: 'prometheus', state: 'unknown', reason: 'source_stale' }],
+ backup: { state: 'disabled', reason: 'not_configured' },
+ sourceLag: [{ sourceId: 'reverse-proxy', state: 'unknown', reason: 'source_unavailable', ageSeconds: 180 }],
+ }),
+ });
+ return;
+ }
+ if (path === '/api/v1/dashboards') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
+ return;
+ }
+ if (path === '/api/v1/host') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({
+ identity: { name: 'mobile-fixture' },
+ cpu: { totalPercent: 42, perCore: Array.from({ length: 16 }, (_, index) => index + 1) },
+ memory: { utilizationPercent: 61 }, source: { state: 'healthy', freshness: 'fresh' },
+ }) });
+ return;
+ }
+ if (path === '/api/v1/containers') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { state: 'healthy', freshness: 'fresh' }, containers: [{ id: 'proxy', state: 'running', health: 'healthy' }], total: 1 }) });
+ return;
+ }
+ if (path === '/api/v1/pools') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { state: 'healthy', freshness: 'fresh' }, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', utilizationPercent: 63 }], total: 1 }) });
+ return;
+ }
+ if (path === '/api/v1/services') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'proxy', name: 'Proxy', state: 'up' }], total: 1 }) });
+ return;
+ }
+ if (path === '/api/v1/incidents') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [{ id: 'incident-1', title: 'Bronvertraging', severity: 'warning', startedAt: generatedAt }] }) });
+ return;
+ }
+ await route.fulfill({ status: 404, contentType: 'application/problem+json', body: JSON.stringify({ code: 'NOT_FOUND' }) });
+ });
+}
+
+async function expectNoCriticalA11yViolations(page: Page): Promise {
+ const results = await new AxeBuilder({ page }).analyze();
+ const severe = results.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
+ expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
+}
+
+test.beforeEach(async ({ page }) => {
+ await mockAPI(page);
+});
+
+test('overview exposes degraded/unknown state and passes axe', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard has a dedicated route test.');
+ await page.goto('/');
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ await expect(page.getByText(/Onbekend|Verminderd/).first()).toBeVisible();
+ if (testInfo.project.name === 'mobile-chromium') {
+ await expect(page.locator('.desktop-navigation')).toBeHidden();
+ await expect(page.locator('.mobile-navigation')).toBeVisible();
+ await expect(page.locator('.mobile-navigation')).toHaveCSS('position', 'fixed');
+ await expect(page.locator('.mobile-primary-list .nav-link')).toHaveCount(4);
+ const mobileTargets = await page.locator('.mobile-primary-list .nav-link').evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
+ expect(mobileTargets.every((height) => height >= 44)).toBe(true);
+ await expect(page.locator('.signal-path-stage')).toHaveCount(6);
+ await expect(page.locator('.signal-path-inspector')).toBeVisible();
+ const incidentBox = await page.locator('.incident-queue').boundingBox();
+ const signalBox = await page.locator('.signal-path-panel').boundingBox();
+ expect(incidentBox?.y).toBeLessThan(signalBox?.y ?? 0);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+ } else {
+ await expect(page.locator('.desktop-navigation')).toBeVisible();
+ const rail = await page.locator('.sidebar').boundingBox();
+ const commandHeader = await page.locator('.context-bar').boundingBox();
+ expect(rail?.width).toBeLessThanOrEqual(72);
+ expect(commandHeader?.height).toBe(56);
+ await expect(page.locator('.overview-kpi')).toHaveCount(4);
+ await expect(page.locator('.source-health-strip')).toHaveAttribute('tabindex', '0');
+ await expect(page.locator('.nav-group')).toHaveCount(6);
+ await expect(page.locator('.nav-group').first()).toHaveAttribute('open', '');
+ await expect(page.locator('.nav-group').filter({ hasText: 'Infrastructuur' })).not.toHaveAttribute('open', '');
+ }
+ await page.keyboard.press('Tab');
+ await expect(page.locator(':focus')).toBeVisible();
+ await expectNoCriticalA11yViolations(page);
+ if (process.env.PULSE_E2E_REAL_BASE_URL || process.env.PULSE_CAPTURE_VISUALS) {
+ const evidenceDirectory = process.env.PULSE_CAPTURE_VISUALS ? 'M14-02' : 'M11-10';
+ await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
+ await page.screenshot({ path: path.resolve('../../artifacts/evidence', evidenceDirectory, `overview-${testInfo.project.name}.png`), fullPage: true });
+ }
+});
+
+test('grouped navigation reaches infrastructure in two actions', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard heeft geen productnavigatie.');
+ await page.goto('/');
+ if (testInfo.project.name === 'mobile-chromium') {
+ await page.getByText('Meer', { exact: true }).click();
+ await page.getByRole('link', { name: /Disks/ }).click();
+ } else {
+ await page.locator('.nav-group').filter({ hasText: 'Infrastructuur' }).locator('summary').click();
+ await page.getByRole('link', { name: /Disks/ }).click();
+ }
+ await expect(page).toHaveURL(/\/disks$/);
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+});
+
+test('wallboard remains read-only, bounded and accessible', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'wallboard-chromium', 'Wallboard is verified at 1920x1080.');
+ await page.goto('/wallboard');
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ await expect(page.getByText(/Geen dashboards|Wallboard/).first()).toBeVisible();
+ await expect(page.locator('.sidebar')).toHaveCount(0);
+ await expect(page.getByRole('button', { name: /Bewerken|Exporteren/ })).toHaveCount(0);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1)).toBe(true);
+ await expectNoCriticalA11yViolations(page);
+});
diff --git a/apps/web/tests/e2e/alert-workspace.spec.ts b/apps/web/tests/e2e/alert-workspace.spec.ts
new file mode 100644
index 0000000..6bd2ddb
--- /dev/null
+++ b/apps/web/tests/e2e/alert-workspace.spec.ts
@@ -0,0 +1,94 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+const alerts = Array.from({ length: 25 }, (_, index) => ({
+ id: `alert-${String(index + 1).padStart(2, '0')}`,
+ state: index === 1 ? 'acknowledged' : 'firing',
+ retainedState: 'firing',
+ ruleName: `Melding ${String(index + 1).padStart(2, '0')}`,
+ severity: index % 5 === 0 ? 'critical' : 'attention',
+ entityName: index % 2 === 0 ? 'Tower' : 'Database',
+ reason: 'threshold_exceeded',
+ revision: index + 1,
+ updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
+}));
+
+test('alertwerkruimte prioriteert operatie en opent configuratie doelgericht', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'De alertwerkruimte gebruikt de desktop-, tablet- en mobiele shell.');
+ let operationHeaders: Record | undefined;
+ await page.route('**/api/v1/**', async (route) => {
+ const request = route.request();
+ const pathname = new URL(request.url()).pathname;
+ if (pathname === '/api/v1/system/status') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'test', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) });
+ return;
+ }
+ if (pathname === '/api/v1/alert-rules') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
+ return;
+ }
+ if (pathname === '/api/v1/metrics/catalog') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ metrics: [{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }] }) });
+ return;
+ }
+ if (pathname === '/api/v1/alerts' && request.method() === 'GET') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: alerts }) });
+ return;
+ }
+ if (pathname.startsWith('/api/v1/alerts/') && request.method() === 'POST') {
+ operationHeaders = request.headers();
+ await route.fulfill({ contentType: 'application/json', body: '{}' });
+ return;
+ }
+ if (pathname === '/api/v1/alert-silences' || pathname === '/api/v1/maintenance-windows') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
+ return;
+ }
+ await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
+ });
+
+ await page.goto('/alerts');
+ await expect(page.getByRole('heading', { name: 'Meldingen en incidenten' })).toBeVisible();
+ await expect(page.getByRole('button', { name: /Actief 25/ })).toHaveAttribute('aria-pressed', 'true');
+ await expect(page.getByRole('button', { name: /Kritiek actief 5/ })).toBeVisible();
+ const rows = page.locator('.alert-operation-list-items > li');
+ await expect(rows).toHaveCount(20);
+ await expect(rows.first()).toContainText('Kritiek');
+ await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
+
+ const acknowledge = page.getByRole('button', { name: 'Erkennen' }).first();
+ page.once('dialog', (dialog) => dialog.dismiss());
+ await acknowledge.click();
+ expect(operationHeaders).toBeUndefined();
+ page.once('dialog', (dialog) => dialog.accept());
+ await acknowledge.click();
+ await expect.poll(() => operationHeaders?.['if-match']).toBe('1');
+ expect(operationHeaders?.['idempotency-key']).toBeTruthy();
+
+ await page.getByRole('button', { name: /Alertregels Detectie en drempels/ }).click();
+ await expect(page).toHaveURL(/section=rules/);
+ await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Actieve en recente meldingen' })).toHaveCount(0);
+ await page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ }).click();
+ await expect(page).toHaveURL(/section=controls/);
+ await expect(page.getByRole('heading', { name: 'Tijdelijke onderdrukking en onderhoud' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
+ await page.reload();
+ await expect(page.getByRole('heading', { name: 'Tijdelijke onderdrukking en onderhoud' })).toBeVisible();
+ await expect(page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ })).toHaveAttribute('aria-current', 'page');
+ await page.getByRole('button', { name: /Actieve meldingen Prioriteiten en erkenning/ }).click();
+ await expect(page).not.toHaveURL(/section=/);
+
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ expect(await page.evaluate(() => document.documentElement.scrollHeight / window.innerHeight)).toBeLessThan(10);
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
+ if (testInfo.project.name === 'mobile-chromium') {
+ const heights = await page.locator('.alert-section-nav button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
+ expect(heights.every((height) => height >= 44)).toBe(true);
+ }
+ if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
+ await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `alerts-${testInfo.project.name}.png`), fullPage: true });
+ }
+});
diff --git a/apps/web/tests/e2e/capacity-real.spec.ts b/apps/web/tests/e2e/capacity-real.spec.ts
new file mode 100644
index 0000000..e2803bd
--- /dev/null
+++ b/apps/web/tests/e2e/capacity-real.spec.ts
@@ -0,0 +1,26 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+
+const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
+
+test('persisted capacity history produces one qualified explainable forecast', async ({ page }) => {
+ test.skip(!enabled, 'Requires an isolated real Pulse stack.');
+ expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
+ const response = await page.request.get('/api/v1/forecasts');
+ expect(response.ok()).toBeTruthy();
+ const snapshot = await response.json() as { qualifiedCount: number; items: Array<{ entityId: string; dataPoints: number; confidence: string; projectedAt?: string }> };
+ expect(snapshot.qualifiedCount).toBe(1);
+ expect(snapshot.items[0]).toMatchObject({ dataPoints: 3, confidence: 'medium' });
+ expect(snapshot.items[0].projectedAt).toBeTruthy();
+
+ await page.goto('/capacity');
+ await expect(page.getByText(/1 gekwalificeerde prognoses/)).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Media forecast' })).toBeVisible();
+ await expect(page.getByText('Mediane dagelijkse groei', { exact: true })).toBeVisible();
+ await expect(page.getByText('Gemiddelde betrouwbaarheid')).toBeVisible();
+ await expect(page.getByText('42 dagen', { exact: false })).toBeVisible();
+ await expect(page.getByText('0 B / 0 B')).toHaveCount(0);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
+});
diff --git a/apps/web/tests/e2e/core-visual-audit.spec.ts b/apps/web/tests/e2e/core-visual-audit.spec.ts
new file mode 100644
index 0000000..007495b
--- /dev/null
+++ b/apps/web/tests/e2e/core-visual-audit.spec.ts
@@ -0,0 +1,29 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
+const coreRoutes = ['/', '/host', '/containers', '/storage', '/services', '/alerts', '/incidents', '/inventory'];
+
+test('core routes remain accessible, bounded and visually stable', async ({ page }, testInfo) => {
+ test.skip(!enabled, 'Requires the isolated server-built Pulse stack.');
+ const errors: string[] = [];
+ page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
+ page.on('pageerror', (error) => errors.push(error.message));
+ expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
+
+ const routes = testInfo.project.name === 'wallboard-chromium' ? ['/wallboard'] : coreRoutes;
+ for (const route of routes) {
+ await page.goto(route);
+ await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), `${route} has document overflow`).toBe(true);
+ if (route === '/wallboard') expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1), `${route} has vertical overflow`).toBe(true);
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious'), `${route} axe findings`).toEqual([]);
+ if (process.env.PULSE_CAPTURE_VISUALS && (route === '/' || route === '/services' || route === '/wallboard')) {
+ const name = route === '/' ? 'overview' : route.slice(1);
+ await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-10', `${name}-${testInfo.project.name}.png`), fullPage: true });
+ }
+ }
+ expect(errors, errors.join('\n')).toEqual([]);
+});
diff --git a/apps/web/tests/e2e/dashboard-editor-polish.spec.ts b/apps/web/tests/e2e/dashboard-editor-polish.spec.ts
new file mode 100644
index 0000000..3d4749a
--- /dev/null
+++ b/apps/web/tests/e2e/dashboard-editor-polish.spec.ts
@@ -0,0 +1,91 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test, type Page } from '@playwright/test';
+import path from 'node:path';
+
+const dashboard = { id: 'polish-dashboard', slug: 'operations', name: 'Netwerkoperaties', description: 'Actuele netwerkbelasting en operationele wijzigingen.', scope: 'system', revision: 4, currentVersion: 7 };
+const widgets = [
+ {
+ id: 'network', title: 'Netwerkbelasting', type: 'timeseries',
+ data: { sourceType: 'semantic-metric', metric: 'host.network.receive', aggregation: 'avg' },
+ visualization: { unit: 'bytesPerSecond', decimals: 0, legend: true },
+ behavior: { locked: false, hidden: false, liveIntervalSeconds: 30 },
+ layouts: { desktop: { x: 0, y: 0, w: 9, h: 5, visible: true } },
+ },
+ {
+ id: 'events', title: 'Recente wijzigingen', type: 'event-timeline',
+ data: { sourceType: 'events', limit: 12 }, behavior: { locked: false, hidden: false, liveIntervalSeconds: 30 },
+ layouts: { desktop: { x: 9, y: 0, w: 9, h: 5, visible: true } },
+ },
+];
+
+async function mockDashboard(page: Page): Promise {
+ await page.route('**/api/v1/**', async (route) => {
+ const requestPath = new URL(route.request().url()).pathname;
+ const body = requestPath === '/api/v1/dashboards/polish-dashboard'
+ ? { dashboard, version: { document: { schemaVersion: 2, widgets, variables: [], settings: { defaultTimeRange: '1h' } } } }
+ : requestPath === '/api/v1/metrics/query-range'
+ ? { status: 'success', data: { result: [{ metric: { __name__: 'host_network_receive', host: 'tower', interface: 'eth0' }, values: [[1786420800, '1200'], [1786420815, '1500']] }] }, provenance: { source: 'prometheus', metric: 'host.network.receive', catalogVersion: '1', cacheKey: 'test' }, sourceObservedAt: '2026-08-11T04:01:00Z', receivedAt: '2026-08-11T04:01:01Z', freshness: 'fresh', cacheHit: false }
+ : requestPath === '/api/v1/events'
+ ? { items: [{ id: 'event-1', type: 'container.restart', severity: 'warning', summary: 'container.restart', occurredAt: '2026-08-11T04:00:00Z' }] }
+ : requestPath === '/api/v1/system/status'
+ ? { version: '1', generatedAt: '2026-08-11T04:01:00Z', overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'database_ready' }], backup: { state: 'healthy', reason: 'backup_verified' }, sourceLag: [] }
+ : {};
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
+ });
+}
+
+async function expectNoSeriousAxeViolations(page: Page): Promise {
+ const results = await new AxeBuilder({ page }).analyze();
+ const violations = results.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious');
+ expect(violations, violations.map((item) => `${item.id}: ${item.help}`).join('\n')).toEqual([]);
+}
+
+test('dashboard and editor use human labels, safe modes and accessible keyboard controls', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop-chromium', 'The editor acceptance proof uses the desktop canvas.');
+ await mockDashboard(page);
+ await page.goto('/dashboards/polish-dashboard');
+
+ await expect(page.getByRole('heading', { level: 1, name: 'Netwerkoperaties' })).toBeVisible();
+ await expect(page.getByRole('heading', { level: 2, name: 'Dashboardwidgets' })).toBeAttached();
+ await expect(page.getByRole('heading', { level: 3, name: 'Netwerkbelasting' })).toBeVisible();
+ await expect(page.getByRole('list', { name: 'Legenda' })).toContainText('tower · eth0');
+ await expect(page.locator('.metric-chart-line')).toHaveAttribute('d', 'M 28.000 192.000 L 628.000 12.000');
+ await expect(page.getByText('Container herstart', { exact: false })).toBeVisible();
+ await expect(page.getByText('container.restart', { exact: true })).toHaveCount(0);
+ await expect(page.locator('body')).not.toContainText('{"__name__"');
+ await expectNoSeriousAxeViolations(page);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/dashboard-human-labels.png'), fullPage: true });
+
+ await page.getByRole('button', { name: 'Bewerken' }).click();
+ await expect(page.getByRole('heading', { level: 1, name: 'Dashboard aanpassen' })).toBeVisible();
+ await expect(page.getByRole('heading', { level: 2, name: 'Dashboardindeling' })).toBeAttached();
+ const advanced = page.getByText('Dashboardvariabelen, sjablonen en gegevensoverdracht', { exact: true });
+ await expect(advanced).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Import, export en templates' })).toBeHidden();
+ await expect(page.locator('.editor-widget-actions').first()).toContainText('Omhoog');
+ await expect(page.locator('.editor-widget-actions').first()).toContainText('Omlaag');
+ await expect(page.locator('.editor-widget-actions').first()).toContainText('Breedte');
+ await expect(page.getByText('Weergavemodus')).toHaveCount(0);
+
+ const editorWidgets = page.locator('.editor-widget');
+ await expect(editorWidgets.locator('h3')).toHaveText(['Netwerkbelasting', 'Recente wijzigingen']);
+ const firstWidget = await editorWidgets.first().boundingBox();
+ expect(firstWidget).not.toBeNull();
+ await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 30);
+ await page.mouse.down();
+ await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 75, { steps: 4 });
+ await page.mouse.up();
+ await expect(editorWidgets.locator('h3')).toHaveText(['Recente wijzigingen', 'Netwerkbelasting']);
+
+ const resize = page.getByRole('slider', { name: 'Breedte aanpassen: Netwerkbelasting' });
+ await expect(resize).toHaveAttribute('aria-valuenow', '9');
+ await resize.focus();
+ await page.keyboard.press('ArrowRight');
+ await expect(resize).toHaveAttribute('aria-valuenow', '10');
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-keyboard-and-disclosure.png'), fullPage: true });
+ await advanced.click();
+ await expect(page.getByRole('heading', { level: 2, name: 'Import, export en templates' })).toBeVisible();
+ await expectNoSeriousAxeViolations(page);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-advanced-open.png'), fullPage: true });
+});
diff --git a/apps/web/tests/e2e/dashboard-real-sources.spec.ts b/apps/web/tests/e2e/dashboard-real-sources.spec.ts
new file mode 100644
index 0000000..25b7ed4
--- /dev/null
+++ b/apps/web/tests/e2e/dashboard-real-sources.spec.ts
@@ -0,0 +1,40 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+
+const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
+
+test('default dashboard and wallboard expose usable real sources', async ({ page }) => {
+ test.skip(!enabled, 'Run against an isolated server smoke stack.');
+ test.setTimeout(90_000);
+ expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
+ const failures: string[] = [];
+ page.on('pageerror', (error) => failures.push(error.message));
+ page.on('response', (response) => {
+ const path = new URL(response.url()).pathname;
+ if (path.startsWith('/api/') && response.status() >= 400) failures.push(`${response.status()} ${path}`);
+ });
+
+ await page.goto('/dashboards/11111111-1111-4111-8111-111111111111');
+ await expect(page.getByRole('heading', { level: 1, name: 'Overzicht' })).toBeVisible();
+ await expect(page.locator('.widget-card--runtime')).toHaveCount(5);
+ await expect(page.locator('.widget-placeholder')).toHaveCount(0);
+ await expect.poll(() => failures, { timeout: 5_000 }).toEqual([]);
+ await expect(page.locator('.metric-chart')).toBeVisible();
+ await expect(page.getByText('Disk 1').first()).toBeVisible();
+ await expect(page.getByText('pulse', { exact: true }).first()).toBeVisible();
+ await expect(page.getByText('Inventaris succesvol bijgewerkt')).toBeVisible();
+ await expect(page.locator('.widget-card--runtime[data-runtime-state="usable"]')).toHaveCount(5);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
+ expect((await new AxeBuilder({ page }).analyze()).violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
+
+ await page.goto('/wallboard?refresh=300&interval=300');
+ await expect(page.getByRole('heading', { name: 'Operationeel wallboard' })).toBeVisible();
+ await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden');
+ await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
+ expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1)).toBe(true);
+ await expect(page.locator('.metric-chart')).toBeVisible();
+ await page.setViewportSize({ width: 390, height: 844 });
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
+ expect((await new AxeBuilder({ page }).analyze()).violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
+ expect(failures).toEqual([]);
+});
diff --git a/apps/web/tests/e2e/event-timeline.spec.ts b/apps/web/tests/e2e/event-timeline.spec.ts
new file mode 100644
index 0000000..bbba6c7
--- /dev/null
+++ b/apps/web/tests/e2e/event-timeline.spec.ts
@@ -0,0 +1,74 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+const items = Array.from({ length: 100 }, (_, index) => ({
+ id: `event-${String(index + 1).padStart(3, '0')}`,
+ type: index % 2 === 0 ? 'service.down' : 'container.restart',
+ severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
+ entityId: `entity-${String(index % 5).padStart(2, '0')}`,
+ sourceId: 'source-unraid',
+ occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
+ receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
+ summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
+}));
+
+test('100 events blijven compact, filterbaar en toetsenbordnavigeerbaar', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'De eventwerkruimte gebruikt de desktop-, tablet- en mobiele shell.');
+ let eventRequests = 0;
+ await page.route('**/api/v1/**', async (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ if (pathname === '/api/v1/events') {
+ eventRequests += 1;
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items }) });
+ return;
+ }
+ if (pathname === '/api/v1/system/status') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) });
+ return;
+ }
+ await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
+ });
+
+ await page.goto('/events');
+ await expect(page.getByRole('heading', { name: 'Gebeurtenissen' })).toBeVisible();
+ const rows = page.locator('.event-list > li');
+ await expect(rows).toHaveCount(20);
+ const baselineRequests = eventRequests;
+ expect(baselineRequests).toBeLessThanOrEqual(2);
+ const criticalSummary = page.getByRole('button', { name: /Kritieke gebeurtenissen/i });
+ await expect(criticalSummary).toBeVisible();
+ await expect(criticalSummary).toContainText('10');
+ expect(await page.evaluate(() => document.documentElement.scrollHeight / window.innerHeight)).toBeLessThan(10);
+
+ await page.getByLabel('Ernst').selectOption('critical');
+ await expect(rows).toHaveCount(10);
+ await page.getByLabel('Soort').selectOption('service.down');
+ await expect(rows).toHaveCount(10);
+ await page.getByLabel('Onderdeel').selectOption('entity-00');
+ await expect(rows).toHaveCount(10);
+ await page.getByLabel('Zoeken').fill('Gebeurtenis 091');
+ await expect(rows).toHaveCount(1);
+ expect(eventRequests).toBe(baselineRequests);
+
+ await page.getByRole('button', { name: 'Filters wissen' }).click();
+ const next = page.getByRole('button', { name: 'Volgende pagina' });
+ await next.focus();
+ await page.keyboard.press('Enter');
+ await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
+ await expect(page).toHaveURL(/page=2/);
+ await expect(rows).toHaveCount(20);
+ await expect(rows.first()).toContainText('event-021');
+ expect(eventRequests).toBe(baselineRequests);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
+ if (testInfo.project.name === 'mobile-chromium') {
+ const pagerHeights = await page.locator('.list-pager .button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
+ expect(pagerHeights.every((height) => height >= 44)).toBe(true);
+ }
+ if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
+ await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `events-${testInfo.project.name}.png`), fullPage: true });
+ }
+});
diff --git a/apps/web/tests/e2e/inventory-real.spec.ts b/apps/web/tests/e2e/inventory-real.spec.ts
new file mode 100644
index 0000000..af941bd
--- /dev/null
+++ b/apps/web/tests/e2e/inventory-real.spec.ts
@@ -0,0 +1,30 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
+
+test('real inventory exposes effective overrides, provenance and relations', async ({ page }, testInfo) => {
+ test.skip(!enabled, 'Requires the isolated real PostgreSQL stack and inventory fixture.');
+ const login = await page.request.get('/auth/test-login');
+ expect(login.ok()).toBeTruthy();
+
+ await page.goto('/inventory');
+ await page.getByRole('searchbox', { name: 'Zoeken' }).fill('pulse-api');
+ const entity = page.getByRole('link', { name: /Pulse API · handmatig/ });
+ await expect(entity).toBeVisible();
+ await expect(entity).toContainText('1 bronnen · 2 feiten · 1 relaties · 2 correcties');
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
+
+ await entity.click();
+ await expect(page.getByRole('heading', { level: 1, name: 'Pulse API · handmatig' })).toBeVisible();
+ await expect(page.getByText('itworx/pulse:pinned', { exact: true })).toBeVisible();
+ await expect(page.getByText('Handmatige correctie').first()).toBeVisible();
+ await expect(page.getByText('Verouderd', { exact: true })).toBeVisible();
+ await expect(page.getByRole('link', { name: /PostgreSQL.*depends_on.*bevestigd/ })).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
+
+ const violations = await new AxeBuilder({ page }).analyze();
+ expect(violations.violations.filter((item) => ['serious', 'critical'].includes(item.impact ?? ''))).toEqual([]);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-05', `inventory-${testInfo.project.name}.png`), fullPage: true });
+});
diff --git a/apps/web/tests/e2e/large-lists.spec.ts b/apps/web/tests/e2e/large-lists.spec.ts
new file mode 100644
index 0000000..38749d3
--- /dev/null
+++ b/apps/web/tests/e2e/large-lists.spec.ts
@@ -0,0 +1,104 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+const containers = Array.from({ length: 150 }, (_, index) => ({
+ id: `container-${String(index + 1).padStart(3, '0')}`,
+ name: `container-${String(index + 1).padStart(3, '0')}`,
+ image: 'example/pulse:read-only', state: index % 17 === 0 ? 'exited' : 'running', health: index % 13 === 0 ? 'unhealthy' : 'healthy',
+ intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 3600, restartCount: 0, exitCode: 0,
+ cpuPercent: index / 10, memoryBytes: 1024 * (index + 1), memoryLimitBytes: 1024 * 1024,
+ networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0,
+}));
+const processes = Array.from({ length: 60 }, (_, index) => ({ pid: index + 1, name: `worker-${String(index + 1).padStart(2, '0')}`, state: 'running', runtimeSeconds: 300, cpuPercent: index, memoryBytes: 2048 + index, containerName: index % 2 ? 'pulse' : 'database' }));
+const entities = Array.from({ length: 60 }, (_, index) => ({ id: `entity-${index + 1}`, entityType: 'container', canonicalName: `container.${index + 1}`, displayName: `Entity ${String(index + 1).padStart(2, '0')}`, status: 'operational', factCount: 2, overrideCount: 0, relationCount: 1, sourceCount: 1, staleFactCount: 0 }));
+
+test.beforeEach(async ({ page }) => {
+ await page.route('**/api/v1/containers?**', async (route) => {
+ const url = new URL(route.request().url());
+ const query = (url.searchParams.get('q') ?? '').toLowerCase();
+ const state = url.searchParams.get('state') ?? '';
+ const health = url.searchParams.get('health') ?? '';
+ const after = Number(url.searchParams.get('after') ?? '0');
+ const limit = Number(url.searchParams.get('limit') ?? '25');
+ const filtered = containers.filter((item) => (!query || item.name.includes(query)) && (!state || item.state === state) && (!health || item.health === health));
+ const items = filtered.slice(after, after + limit);
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { id: 'target-scale', state: 'healthy' }, total: filtered.length, containers: items, nextCursor: after + limit < filtered.length ? String(after + limit) : '' }) });
+ });
+ await page.route('**/api/v1/processes?**', async (route) => {
+ const url = new URL(route.request().url());
+ const query = (url.searchParams.get('q') ?? '').toLowerCase();
+ const container = (url.searchParams.get('container') ?? '').toLowerCase();
+ const after = Number(url.searchParams.get('after') ?? '0');
+ const filtered = processes.filter((item) => (!query || item.name.includes(query)) && (!container || item.containerName.includes(container)));
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { id: 'target-scale', state: 'healthy' }, total: filtered.length, processes: filtered.slice(after, after + 25), nextCursor: after + 25 < filtered.length ? String(after + 25) : '' }) });
+ });
+ await page.route('**/api/v1/entities?**', async (route) => {
+ const url = new URL(route.request().url());
+ const query = (url.searchParams.get('q') ?? '').toLowerCase();
+ const after = Number(url.searchParams.get('after') ?? '0');
+ const filtered = entities.filter((item) => !query || (item.displayName + item.canonicalName).toLowerCase().includes(query));
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: filtered.slice(after, after + 25), hasMore: after + 25 < filtered.length, nextCursor: after + 25 < filtered.length ? String(after + 25) : '' }) });
+ });
+});
+
+test('process and inventory filters survive navigation with mobile-first cards', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
+ await page.goto('/processes?q=worker&container=pulse&sort=memory');
+ await expect(page.getByRole('heading', { name: 'Topprocessen' })).toBeVisible();
+ await expect(page.getByLabel('Zoeken')).toHaveValue('worker');
+ await expect(page.getByLabel('Container')).toHaveValue('pulse');
+ await expect(page).toHaveURL(/sort=memory/);
+ if (testInfo.project.name === 'mobile-chromium') await expect(page.locator('.mobile-data-list > li:visible')).toHaveCount(25);
+ else await expect(page.locator('.desktop-data-view tbody tr:visible')).toHaveCount(25);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+
+ await page.goto('/inventory?q=Entity&type=container&status=operational&order=desc');
+ await expect(page.getByRole('heading', { name: 'Wat Pulse kan zien' })).toBeVisible();
+ await expect(page.getByLabel('Zoeken')).toHaveValue('Entity');
+ await expect(page.getByLabel('Type')).toHaveValue('container');
+ await expect(page.getByRole('textbox', { name: 'Status' })).toHaveValue('operational');
+ await expect(page.locator('.inventory-entity-list > li')).toHaveCount(25);
+ await page.getByRole('button', { name: 'Volgende pagina' }).click();
+ await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
+ await expect(page).toHaveURL(/after=25/);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+});
+
+test('all 150 containers remain reachable with shareable filters and bounded mobile cards', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
+ const started = Date.now();
+ await page.goto('/containers');
+ await expect(page.getByRole('heading', { name: 'Containers' })).toBeVisible();
+ const visibleRows = testInfo.project.name === 'mobile-chromium' ? page.locator('.mobile-data-list > li:visible') : page.locator('.desktop-data-view tbody tr:visible');
+ await expect(visibleRows).toHaveCount(25);
+ expect(Date.now() - started).toBeLessThan(3000);
+ const seen = new Set();
+ for (let pageNumber = 1; pageNumber <= 6; pageNumber += 1) {
+ await expect(visibleRows).toHaveCount(25);
+ for (const value of await visibleRows.locator('a').allTextContents()) seen.add(value.trim());
+ if (pageNumber < 6) {
+ const next = page.getByRole('button', { name: 'Volgende pagina' });
+ await next.click();
+ await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
+ }
+ }
+ expect(seen.size).toBe(150);
+ expect(seen.has('container-150')).toBe(true);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ if (testInfo.project.name === 'mobile-chromium') {
+ const controls = await page.locator('.list-pager button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
+ expect(controls.every((height) => height >= 44)).toBe(true);
+ }
+
+ await page.getByLabel('Zoeken').fill('container-150');
+ await expect(visibleRows).toHaveCount(1);
+ await expect(page).toHaveURL(/q=container-150/);
+ await page.reload();
+ await expect(visibleRows).toHaveCount(1);
+ await expect(visibleRows.getByText('container-150')).toBeVisible();
+
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-08', `large-containers-${testInfo.project.name}.png`), fullPage: true });
+});
diff --git a/apps/web/tests/e2e/localized-alert-editor.spec.ts b/apps/web/tests/e2e/localized-alert-editor.spec.ts
new file mode 100644
index 0000000..bc9c954
--- /dev/null
+++ b/apps/web/tests/e2e/localized-alert-editor.spec.ts
@@ -0,0 +1,55 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+const rule = {
+ id: '20000000-0000-4000-8000-000000000001', schemaVersion: 1, name: 'Hoge hostbelasting', enabled: true, severity: 'critical', scope: {},
+ condition: { inputType: 'metric', metric: 'host.cpu.utilization', operator: '>', threshold: 90, recoveryThreshold: 80, aggregation: 'avg', windowSeconds: 60 },
+ evaluationIntervalSeconds: 30, pendingSeconds: 60, resolveSeconds: 120, cooldownSeconds: 300,
+ unknownBehavior: 'retain-firing-as-unknown', groupBy: [], suppressWhen: ['host.unreachable'],
+ message: { titleKey: 'alerts.rule.title', bodyKey: 'alerts.rule.body' }, revision: 1, currentVersion: 1,
+};
+
+test.beforeEach(async ({ page }) => {
+ await page.route('**/api/v1/system/status', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'test', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) }));
+ await page.route('**/api/v1/alert-rules?**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [rule] }) }));
+ await page.route('**/api/v1/metrics/catalog', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ metrics: [{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }] }) }));
+ await page.route('**/api/v1/alert-silences**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
+ await page.route('**/api/v1/maintenance-windows**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
+ await page.route('**/api/v1/alerts?**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
+});
+
+test('alert editor uses Dutch guided choices and rejects an invalid draft', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'De alert-editor is geen wallboardroute.');
+ await page.goto('/alerts');
+ await expect(page.getByRole('heading', { name: 'Meldingen en incidenten' })).toBeVisible();
+ await page.getByRole('button', { name: /Alertregels Detectie en drempels/ }).click();
+ await expect(page.getByText('Kritiek · v1')).toBeVisible();
+ await expect(page.getByRole('combobox', { name: /Meting/ })).toHaveValue('host.cpu.utilization');
+ await expect(page.getByRole('option', { name: 'CPU-gebruik van de host (%)' })).toBeAttached();
+ await expect(page.getByText('host.cpu.utilization')).toHaveCount(0);
+ await expect(page.getByText('host.unreachable')).not.toBeVisible();
+
+ await page.getByRole('button', { name: 'Nieuwe regel' }).click();
+ const save = page.getByRole('button', { name: 'Regel opslaan' });
+ await expect(save).toBeDisabled();
+ await page.locator('#alert-rule-name').fill('CPU-waarschuwing');
+ await page.getByRole('combobox', { name: /Meting/ }).selectOption('host.cpu.utilization');
+ await expect(save).toBeEnabled();
+ await page.locator('#alert-rule-recovery-threshold').fill('90');
+ await expect(save).toBeDisabled();
+ await page.getByRole('combobox', { name: /Signaalbron/ }).selectOption('event');
+ await expect(page.getByRole('combobox', { name: /Meting/ })).toHaveCount(0);
+ await expect(page.locator('#alert-rule-threshold')).toHaveValue('3');
+ await expect(save).toBeEnabled();
+
+ await page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ }).click();
+ await expect(page.locator('#silence-matcher')).toHaveValue('critical');
+ await expect(page.locator('#silence-matcher')).toContainText('Kritiek');
+ await expect(page.locator('#maintenance-selector')).toContainText('Host');
+
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-09', `alert-editor-${testInfo.project.name}.png`), fullPage: true });
+});
diff --git a/apps/web/tests/e2e/management-workspace.spec.ts b/apps/web/tests/e2e/management-workspace.spec.ts
new file mode 100644
index 0000000..0c9dcdc
--- /dev/null
+++ b/apps/web/tests/e2e/management-workspace.spec.ts
@@ -0,0 +1,85 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+const systemStatus = {
+ version: '1.3.0',
+ release: { version: '1.3.0', commit: 'abc1234', builtAt: '2026-08-21T12:00:00Z', migrationVersion: '0024' },
+ generatedAt: new Date().toISOString(),
+ overallState: 'healthy',
+ components: [],
+ backup: { state: 'healthy', reason: 'backup_verified', ageSeconds: 30 * 60 * 60, verifiedAt: '2026-08-20T06:00:00Z' },
+ sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh', ageSeconds: 5 }],
+};
+
+const onboarding = {
+ state: { completed: true, step: 'completed', dashboardChoice: 'default', rulesChoice: 'default', dashboardId: 'overview', rulesReady: true },
+ capabilities: [
+ { id: 'auth', state: 'ready', detail: 'Aanmelding geconfigureerd.' },
+ { id: 'database', state: 'ready', detail: 'Database beschikbaar.' },
+ { id: 'prometheus', state: 'ready', detail: 'Meetgegevens beschikbaar.' },
+ { id: 'unraid', state: 'ready', detail: 'Unraid-bron beschikbaar.' },
+ ],
+ resume: false,
+};
+
+test('beheerhub, afgeronde onboarding en backupouderdom blijven taakgericht en waarheidsgetrouw', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'Beheerflows gebruiken de desktop-, tablet- en mobiele shell.');
+ await page.route('**/api/v1/**', async (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ if (pathname === '/api/v1/system/status') {
+ if (route.request().method() === 'POST') {
+ await route.fulfill({ status: 403, contentType: 'application/problem+json', body: '{}' });
+ return;
+ }
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ...systemStatus, generatedAt: new Date().toISOString() }) });
+ return;
+ }
+ if (pathname === '/api/v1/onboarding') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify(onboarding) });
+ return;
+ }
+ await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
+ });
+
+ await page.goto('/settings');
+ await expect(page.getByRole('heading', { name: 'Pulse configureren' })).toBeVisible();
+ const hub = page.getByRole('region', { name: 'Beheerfuncties' });
+ await expect(hub.getByRole('link')).toHaveCount(6);
+ await expect(hub.getByRole('link', { name: /Systeemstatus en backup/ })).toHaveAttribute('href', '/status');
+ await expect(hub.getByRole('link', { name: /Eerste configuratie/ })).toHaveAttribute('href', '/onboarding');
+ await expect(hub.getByRole('link', { name: /Alertregels/ })).toHaveAttribute('href', '/alerts?section=rules');
+ await expect(hub.getByRole('link', { name: /Stiltes en onderhoud/ })).toHaveAttribute('href', '/alerts?section=controls');
+ await expect(hub.getByText('Backupactie: beheerder')).toBeVisible();
+ await expect(hub.getByText('Wijzigen: operator')).toBeVisible();
+ if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
+ await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `settings-${testInfo.project.name}.png`), fullPage: true });
+ }
+
+ await hub.getByRole('link', { name: /Systeemstatus en backup/ }).click();
+ const backup = page.getByRole('article').filter({ has: page.getByText('Backupstatus', { exact: true }) });
+ await expect(page.getByRole('heading', { name: 'Pulse-systeemstatus' })).toBeVisible();
+ await expect(backup.locator('.status-badge')).toContainText('Aandacht');
+ await expect(backup.getByText(/backup is verlopen/i)).toBeVisible();
+ await expect(backup.getByText(/1 dag geleden/)).toBeVisible();
+ await expect(backup.getByText(/ouder dan 24 uur/)).toBeVisible();
+ await backup.getByRole('button', { name: 'Maak geverifieerde backup' }).click();
+ await expect(backup.getByRole('alert')).toContainText('Alleen beheerders');
+
+ await page.goto('/settings');
+ await page.getByRole('region', { name: 'Beheerfuncties' }).locator('a[href="/onboarding"]').click();
+ await expect(page.getByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
+ await expect(page.getByRole('radio')).toHaveCount(0);
+ await page.getByRole('button', { name: 'Herconfiguratie openen' }).click();
+ await expect(page.getByRole('radio')).toHaveCount(4);
+ await page.getByRole('button', { name: 'Annuleren' }).click();
+ await expect(page.getByRole('radio')).toHaveCount(0);
+
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
+ if (testInfo.project.name === 'mobile-chromium') {
+ const linkHeights = await page.locator('.settings-hub-card a').evaluateAll((links) => links.map((link) => link.getBoundingClientRect().height));
+ expect(linkHeights.every((height) => height >= 44)).toBe(true);
+ }
+});
diff --git a/apps/web/tests/e2e/mobile-incident.spec.ts b/apps/web/tests/e2e/mobile-incident.spec.ts
new file mode 100644
index 0000000..6ce185a
--- /dev/null
+++ b/apps/web/tests/e2e/mobile-incident.spec.ts
@@ -0,0 +1,39 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+
+const now = new Date().toISOString();
+
+test('mobile incident command mode is prioritized, bounded and accessible', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile incident mode uses the supported 390x844 viewport.');
+ await page.route('**/api/v1/**', async (route) => {
+ const path = new URL(route.request().url()).pathname;
+ const body = path === '/api/v1/system/status' ? {
+ version: '1', generatedAt: now, overallState: 'degraded', components: [],
+ backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
+ } : path === '/api/v1/incidents/incident-1' ? {
+ incident: {
+ id: 'incident-1', correlationKey: 'cachepool', title: 'Cachepool bijna vol',
+ summary: 'Cachepool is 92% gebruikt en groeit sneller dan verwacht.', severity: 'critical',
+ status: 'open', startedAt: now, correlationMethod: 'temporal-window', confidence: .84,
+ revision: 2, updatedAt: now, ownerUserId: '', alerts: [
+ { alertId: 'alert-1', rationale: 'Capaciteitsdrempel van 90% overschreden', confidence: .84, correlationMethod: 'temporal-window', manual: false, createdAt: now },
+ ], notes: [{ id: 'note-1', incidentId: 'incident-1', author: 'Pulse', body: 'Mover is niet actief.', createdAt: now }],
+ },
+ } : {};
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
+ });
+
+ await page.goto('/incidents/incident-1');
+ await expect(page.getByRole('heading', { level: 1, name: 'Cachepool bijna vol' })).toBeVisible();
+ await expect(page.locator('.incident-command-strip')).toContainText('Kritiek');
+ await expect(page.locator('.incident-command-strip')).toContainText('84%');
+ await expect(page.locator('.incident-timeline')).toContainText('Capaciteitsdrempel van 90% overschreden');
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
+ const targets = await page.locator('button, .mobile-navigation a, .mobile-more summary').evaluateAll((items) => items.filter((item) => {
+ const style = getComputedStyle(item); return style.display !== 'none' && style.visibility !== 'hidden';
+ }).map((item) => item.getBoundingClientRect().height));
+ expect(targets.every((height) => height >= 44)).toBe(true);
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: '../../artifacts/evidence/M14-04/mobile-incident-command.png', fullPage: true });
+});
diff --git a/apps/web/tests/e2e/real-stack.spec.ts b/apps/web/tests/e2e/real-stack.spec.ts
new file mode 100644
index 0000000..88a435f
--- /dev/null
+++ b/apps/web/tests/e2e/real-stack.spec.ts
@@ -0,0 +1,198 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+
+const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
+
+test('real stack serves collector data through database, API and UI', async ({ page }) => {
+ test.skip(!enabled, 'Run through scripts/integration-smoke.ps1 with an isolated real stack.');
+ test.setTimeout(210_000);
+ const login = await page.request.get('/auth/test-login');
+ expect(login.ok()).toBeTruthy();
+ const issuedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'pulse_session');
+ expect(issuedSession, 'mock login issues the same HttpOnly session contract as OIDC').toBeDefined();
+ expect(issuedSession?.httpOnly).toBe(true);
+
+ for (const endpoint of ['/healthz', '/readyz']) {
+ const response = await page.request.get(endpoint);
+ expect(response.status(), endpoint).toBe(200);
+ expect(response.headers()['content-type'], endpoint).toContain('text/plain');
+ expect((await response.text()).toLowerCase(), endpoint).not.toContain('');
+ }
+ for (const endpoint of ['/metrics', '/debug/pprof/']) {
+ const response = await page.request.get(endpoint);
+ expect(response.status(), endpoint).toBe(404);
+ expect((await response.text()).toLowerCase(), endpoint).not.toContain('');
+ }
+
+ const endpointChecks = [
+ '/api/v1/system/status', '/api/v1/host', '/api/v1/processes?limit=10',
+ '/api/v1/containers?limit=10', '/api/v1/array', '/api/v1/disks?limit=10',
+ '/api/v1/pools?limit=10', '/api/v1/shares?limit=10', '/api/v1/services?limit=10',
+ '/api/v1/network', '/api/v1/topology?limit=10', '/api/v1/applications?limit=10', '/api/v1/events?limit=10',
+ '/api/v1/entities?limit=10', '/api/v1/dashboards?limit=10', '/api/v1/alert-rules?limit=10',
+ '/api/v1/alerts?limit=10', '/api/v1/incidents?limit=10', '/api/v1/onboarding',
+ ];
+ for (const endpoint of endpointChecks) {
+ const response = await page.request.get(endpoint);
+ expect(response.status(), endpoint).toBe(200);
+ }
+ const hostResponse = await page.request.get('/api/v1/host');
+ const host = await hostResponse.json() as Record;
+ expect(JSON.stringify(host)).toContain('smoke-host');
+ expect(JSON.stringify(host)).toContain('fresh');
+
+ const containers = await (await page.request.get('/api/v1/containers?limit=10')).json() as { containers?: Array<{ name: string; state: string; health: string; metricsAvailable?: boolean; lifecycleAvailable?: boolean }> };
+ const smokeContainer = containers.containers?.find((item) => item.name === 'smoke-api');
+ expect(smokeContainer).toMatchObject({ state: 'running', health: 'healthy', metricsAvailable: false, lifecycleAvailable: false });
+ const applications = await (await page.request.get('/api/v1/applications?limit=10')).json() as { applications?: Array<{ name: string; status: string }> };
+ expect(applications.applications?.find((item) => item.name === 'smoke')).toMatchObject({ status: 'healthy' });
+ await page.goto('/containers');
+ await expect(page.getByText(/metingen niet beschikbaar/).first()).toBeVisible();
+
+ const systemStatus = await (await page.request.get('/api/v1/system/status')).json() as {
+ components?: Array<{ id: string; state: string; reason: string }>;
+ sourceLag?: Array<{ sourceId: string; state: string }>;
+ };
+ const unraidComponent = systemStatus.components?.find((item) => item.id === 'unraid');
+ expect(unraidComponent, 'unraid runtime component').toBeDefined();
+ expect(unraidComponent?.state, 'unraid is derived from every fresh bounded agent capability').toBe('healthy');
+ expect(unraidComponent?.reason, 'unraid no longer uses API-local configuration').toBe('source_sampled');
+ const storageComponent = systemStatus.components?.find((item) => item.id === 'storage');
+ expect(storageComponent, 'storage runtime component').toMatchObject({ state: 'healthy', reason: 'source_sampled' });
+ expect(systemStatus.sourceLag?.find((source) => source.sourceId === 'unraid')?.state).toBe('healthy');
+
+ const onboarding = await (await page.request.get('/api/v1/onboarding')).json() as { capabilities?: Array<{ id: string; state: string }> };
+ expect(onboarding.capabilities?.find((capability) => capability.id === 'unraid')?.state).toBe('ready');
+
+ const dashboardId = '61000000-0000-4000-8000-000000000001';
+ const dashboard = {
+ schemaVersion: 2, id: dashboardId, slug: 'real-stack-metric', name: 'Real-stack CPU', description: 'Metric planner browser gate.', scope: 'system',
+ variables: [{ name: 'server', type: 'server', label: 'Server', default: 'smoke-host' }],
+ widgets: [{
+ id: '62000000-0000-4000-8000-000000000001', type: 'timeseries', title: 'CPU live', description: 'Catalog-bounded metric.',
+ data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: '$server' }, aggregation: 'avg', transformations: [] },
+ visualization: { unit: 'percent', decimals: 1, legend: true, showSparkline: false, min: 0, max: 100, thresholds: [] },
+ behavior: { locked: true, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, liveIntervalSeconds: 2, independentTimeRange: null },
+ layouts: { desktop: { x: 0, y: 0, w: 18, h: 8, visible: true }, tablet: { x: 0, y: 0, w: 8, h: 8, visible: true }, mobile: { x: 0, y: 0, w: 1, h: 8, visible: true }, wallboard: { x: 0, y: 0, w: 24, h: 12, visible: true } },
+ }],
+ settings: { defaultTimeRange: 'live', live: true, refreshSeconds: 10, rotationSeconds: 30 },
+ };
+ const dashboardResponse = await page.request.post('/api/v1/dashboards', { data: dashboard });
+ expect([201, 409], await dashboardResponse.text()).toContain(dashboardResponse.status());
+ const peerDashboard = {
+ ...dashboard,
+ id: '61000000-0000-4000-8000-000000000002',
+ slug: 'real-stack-memory',
+ name: 'Real-stack geheugen',
+ widgets: dashboard.widgets.map((widget) => ({
+ ...widget,
+ id: '62000000-0000-4000-8000-000000000002',
+ title: 'Geheugen live',
+ data: { ...widget.data, metric: 'host.memory.utilization' },
+ })),
+ };
+ const peerDashboardResponse = await page.request.post('/api/v1/dashboards', { data: peerDashboard });
+ expect([201, 409], await peerDashboardResponse.text()).toContain(peerDashboardResponse.status());
+
+ await page.goto('/');
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ const websocketOpened = await page.evaluate(() => new Promise((resolve) => {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const socket = new WebSocket(`${protocol}//${window.location.host}/api/v1/live`);
+ const timeout = window.setTimeout(() => { socket.close(); resolve(false); }, 5_000);
+ socket.addEventListener('open', () => { window.clearTimeout(timeout); socket.close(); resolve(true); }, { once: true });
+ socket.addEventListener('error', () => { window.clearTimeout(timeout); resolve(false); }, { once: true });
+ }));
+ expect(websocketOpened, 'same-origin WebSocket upgrade through nginx').toBe(true);
+
+ const runtimeFailures: string[] = [];
+ page.on('pageerror', (error) => runtimeFailures.push(error.message));
+ page.on('response', (response) => {
+ if (new URL(response.url()).pathname.startsWith('/api/') && response.status() >= 400) {
+ runtimeFailures.push(`${response.status()} ${response.url()}`);
+ }
+ });
+ const routes = ['/', '/host', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/topology', '/network', '/applications', '/inventory', '/dashboards', '/alerts', '/incidents', '/settings', '/status', '/onboarding'];
+ for (const route of routes) {
+ await page.goto(route);
+ await expect(page.getByRole('heading', { level: 1 }).first(), route).toBeVisible();
+ await expect(page.locator('.state-page[role="alert"]'), route).toHaveCount(0);
+ }
+ expect(runtimeFailures).toEqual([]);
+ await page.addInitScript((id) => window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'), dashboardId);
+ const metricResponse = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/v1/metrics/query-range');
+ await page.goto('/dashboards/' + dashboardId);
+ await expect(page.getByRole('heading', { name: 'Real-stack CPU' })).toBeVisible();
+ expect((await metricResponse).status(), 'catalog-bounded query through deployed UI').toBe(200);
+ await expect(page.getByRole('heading', { name: 'CPU live' })).toBeVisible();
+ expect(runtimeFailures).toEqual([]);
+
+ // A rotating wallboard fetches the next dashboard document before its live
+ // subscription is ready. Exercise a response well beyond the 250 ms
+ // subscription-release grace and prove the bounded idle transport is reused
+ // across multiple rotations instead of closing and reopening.
+ const dashboardList = await (await page.request.get('/api/v1/dashboards?limit=100')).json() as { items?: Record[] };
+ const wallboardDashboardIds = (dashboardList.items ?? []).map((item) => String(item.id ?? item.ID ?? '')).filter(Boolean);
+ expect(wallboardDashboardIds.length).toBeGreaterThanOrEqual(2);
+ await page.addInitScript(() => {
+ const NativeWebSocket = window.WebSocket;
+ const counters = { opened: 0, closed: 0 };
+ Object.defineProperty(window, '__pulseSocketLifecycle', { value: counters, configurable: true });
+ class TrackedWebSocket extends NativeWebSocket {
+ constructor(url: string | URL, protocols?: string | string[]) {
+ super(url, protocols);
+ counters.opened += 1;
+ this.addEventListener('close', () => { counters.closed += 1; }, { once: true });
+ }
+ }
+ window.WebSocket = TrackedWebSocket;
+ });
+ await page.addInitScript((ids) => {
+ ids.forEach((id) => window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'));
+ }, wallboardDashboardIds);
+ let delayedDashboardLoads = 0;
+ await page.route('**/api/v1/dashboards/**', async (route) => {
+ delayedDashboardLoads += 1;
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
+ try {
+ await route.continue();
+ } catch (error) {
+ // Going offline can settle an intentionally delayed request before the
+ // handler resumes. That is the failure mode under test, not a harness
+ // failure; every other routing error must still fail the flow.
+ if (!(error instanceof Error) || !error.message.includes('Route is already handled')) throw error;
+ }
+ });
+ await page.goto('/wallboard?interval=10&refresh=300');
+ await expect(page.getByRole('heading', { name: 'Operationeel wallboard' })).toBeVisible();
+ const visibleDashboard = page.locator('#dashboard-view-title');
+ await expect(visibleDashboard).toBeVisible();
+ await page.context().setOffline(true);
+ // Cross a rotation while both HTTP and WebSocket transport are unavailable.
+ // The wallboard must retain its last verified document instead of replacing
+ // operational context with a blank loading/error page.
+ await page.waitForTimeout(12_000);
+ await expect(visibleDashboard).toBeVisible();
+ await expect(visibleDashboard).not.toHaveText('');
+ await page.context().setOffline(false);
+ // The integration API has a 30-second idle session TTL. Staying on this page
+ // for 65 seconds after recovery crosses it more than twice. Dashboard refreshes must renew
+ // the HttpOnly cookie server-side without reopening the live transport.
+ await page.waitForTimeout(65_000);
+ expect(delayedDashboardLoads, 'initial document plus at least five rotations').toBeGreaterThanOrEqual(6);
+ const renewedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'pulse_session');
+ expect(renewedSession, 'active wallboard retains its server session').toBeDefined();
+ expect(renewedSession?.value).toBe(issuedSession?.value);
+ expect(renewedSession?.expires ?? 0, 'idle deadline was renewed beyond its initial expiry').toBeGreaterThan(issuedSession?.expires ?? 0);
+ const statusAfterMultipleTTLs = await page.request.get('/api/v1/system/status');
+ expect(statusAfterMultipleTTLs.status(), 'authenticated API after multiple idle TTLs').toBe(200);
+ const socketLifecycle = await page.evaluate(() => (window as unknown as { __pulseSocketLifecycle: { opened: number; closed: number } }).__pulseSocketLifecycle);
+ expect(socketLifecycle).toEqual({ opened: 1, closed: 0 });
+ expect(runtimeFailures).toEqual([]);
+ await page.unroute('**/api/v1/dashboards/**');
+
+ await page.goto('/host');
+ await expect(page.getByText('smoke-host').first()).toBeVisible();
+ const axe = await new AxeBuilder({ page }).analyze();
+ expect(axe.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious')).toEqual([]);
+});
diff --git a/apps/web/tests/e2e/release-backup-real.spec.ts b/apps/web/tests/e2e/release-backup-real.spec.ts
new file mode 100644
index 0000000..efe44aa
--- /dev/null
+++ b/apps/web/tests/e2e/release-backup-real.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+test('release metadata and verified backup are production truthful', async ({ page }, testInfo) => {
+ test.skip(!process.env.PULSE_E2E_REAL_BASE_URL, 'Requires the isolated server-built Pulse stack.');
+ expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
+ const created = await page.request.post('/api/v1/system/backups');
+ expect(created.ok()).toBeTruthy();
+
+ const response = await page.request.get('/api/v1/system/status');
+ expect(response.ok()).toBeTruthy();
+ const status = await response.json() as {
+ version: string;
+ release: { version: string; commit: string; builtAt?: string; migrationVersion: string };
+ backup: { state: string; reason: string; verifiedAt?: string; ageSeconds?: number };
+ };
+ expect(status.version).not.toBe('development');
+ expect(status.release).toMatchObject({ version: 'm11.11-test', commit: 'm1111testcommit' });
+ expect(status.release.builtAt).toBe('2026-08-12T03:00:00Z');
+ expect(status.release.migrationVersion).toMatch(/^\d{4}_.+/);
+ expect(status.backup).toMatchObject({ state: 'healthy', reason: 'backup_verified' });
+ expect(status.backup.verifiedAt).toBeTruthy();
+ expect(status.backup.ageSeconds).toBeGreaterThanOrEqual(0);
+
+ await page.goto('/status');
+ await expect(page.getByText(/m11\.11-test/)).toBeVisible();
+ await expect(page.getByText(/De backup is geverifieerd/)).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({
+ path: path.resolve('../../artifacts/evidence/M11-11', `release-backup-${testInfo.project.name}.png`),
+ fullPage: true,
+ });
+});
diff --git a/apps/web/tests/e2e/responsive-polish.spec.ts b/apps/web/tests/e2e/responsive-polish.spec.ts
new file mode 100644
index 0000000..8456166
--- /dev/null
+++ b/apps/web/tests/e2e/responsive-polish.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test, type Page } from '@playwright/test';
+import path from 'node:path';
+
+const observedAt = new Date().toISOString();
+
+async function mockResponsiveData(page: Page): Promise {
+ await page.route('**/api/v1/**', async (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ const body = pathname === '/api/v1/system/status' ? {
+ version: '1', generatedAt: observedAt, overallState: 'degraded',
+ components: [
+ { id: 'database', state: 'healthy', reason: 'ok' },
+ { id: 'prometheus', state: 'unknown', reason: 'source_stale' },
+ ],
+ backup: { state: 'disabled', reason: 'not_configured' },
+ sourceLag: [{ sourceId: 'prometheus', state: 'unknown', reason: 'source_stale', ageSeconds: 900 }],
+ } : pathname === '/api/v1/host' ? {
+ identity: { name: 'responsive-fixture' }, cpu: { totalPercent: 42, perCore: [42, 38] },
+ memory: { utilizationPercent: 61 }, source: { state: 'healthy', freshness: 'fresh' },
+ } : pathname === '/api/v1/containers' ? { source: { state: 'healthy', freshness: 'fresh' }, containers: [], total: 0 }
+ : pathname === '/api/v1/pools' ? { source: { state: 'healthy', freshness: 'fresh' }, pools: [], total: 0 }
+ : pathname === '/api/v1/services' ? { capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 }
+ : pathname === '/api/v1/incidents' ? { items: [] }
+ : {};
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
+ });
+}
+
+test.beforeEach(async ({ page }) => mockResponsiveData(page));
+
+test('mobile primary navigation and operational reasons remain readable at 390px', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile evidence uses the supported 390px viewport.');
+ await page.goto('/');
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ const labels = page.locator('.mobile-primary-list .nav-link span:last-child');
+ await expect(labels).toHaveCount(4);
+ const metrics = await labels.evaluateAll((nodes) => nodes.map((node) => {
+ const box = node.getBoundingClientRect();
+ return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, font: Number.parseFloat(getComputedStyle(node).fontSize) };
+ }));
+ for (let index = 1; index < metrics.length; index += 1) expect(metrics[index - 1].right).toBeLessThanOrEqual(metrics[index].left);
+ expect(metrics.every((metric) => metric.font >= 12 && metric.bottom - metric.top <= 16)).toBe(true);
+ const reason = page.locator('.action-queue small').first();
+ await expect(reason).toBeVisible();
+ await expect(reason).toHaveCSS('white-space', 'normal');
+ const overflow = await page.evaluate(() => [...document.querySelectorAll('body *')]
+ .map((element) => ({ tag: element.tagName, className: element.className, right: Math.round(element.getBoundingClientRect().right), scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }))
+ .filter((item) => item.right > innerWidth + 1 || item.scrollWidth > item.clientWidth + 1)
+ .slice(0, 12));
+ expect(overflow).toEqual([]);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-06/responsive-mobile-390.png'), fullPage: true });
+});
+
+test('tablet sidebar stays compact and content uses the remaining viewport', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'tablet-chromium', 'Tablet evidence uses the supported 1024px viewport.');
+ await page.goto('/');
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ const sidebar = await page.locator('.sidebar').boundingBox();
+ const workspace = await page.locator('.app-workspace').boundingBox();
+ const queue = await page.locator('.action-queue').boundingBox();
+ const dataPlane = await page.locator('.signal-path-panel').boundingBox();
+ expect(sidebar?.width).toBeLessThanOrEqual(208);
+ expect(workspace?.width).toBeGreaterThanOrEqual(816);
+ expect(queue?.width).toBeGreaterThan(300);
+ expect(dataPlane?.width).toBeGreaterThan(500);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1024);
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-06/responsive-tablet-1024.png'), fullPage: true });
+});
diff --git a/apps/web/tests/e2e/service-states-real.spec.ts b/apps/web/tests/e2e/service-states-real.spec.ts
new file mode 100644
index 0000000..042b32e
--- /dev/null
+++ b/apps/web/tests/e2e/service-states-real.spec.ts
@@ -0,0 +1,30 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+
+const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
+
+test.beforeEach(async ({ page }) => {
+ test.skip(!enabled, 'Requires an isolated real Pulse stack.');
+ const login = await page.request.get('/auth/test-login');
+ expect(login.ok()).toBeTruthy();
+});
+
+test('real services and inventory topology retain explicit production states', async ({ page }) => {
+ await page.goto('/services');
+ await expect(page.getByRole('heading', { name: 'Bereikbaarheid en historie' })).toBeVisible();
+ await expect(page.getByText('Voor deze service is nog geen probe geconfigureerd.').first()).toBeVisible();
+
+ await page.goto('/topology');
+ await expect(page.getByRole('heading', { name: 'Relaties en services' })).toBeVisible();
+ await expect(page.getByRole('region', { name: 'Relaties', exact: true }).getByText('Ondersteunt')).toBeVisible();
+ await expect(page.getByText(/dependency-test-a-/).first()).toBeVisible();
+
+ await page.goto('/network');
+ const dns = page.getByRole('heading', { name: 'DNS' }).locator('../..');
+ await expect(dns.getByText('Niet geconfigureerd')).toBeVisible();
+ await expect(dns.getByText('Voor dit signaal is nog geen veilige probe geconfigureerd.')).toBeVisible();
+
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ const accessibility = await new AxeBuilder({ page }).analyze();
+ expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
+});
diff --git a/apps/web/tests/e2e/session-boundary.spec.ts b/apps/web/tests/e2e/session-boundary.spec.ts
new file mode 100644
index 0000000..34966ad
--- /dev/null
+++ b/apps/web/tests/e2e/session-boundary.spec.ts
@@ -0,0 +1,23 @@
+import { expect, test } from '@playwright/test';
+
+test('expired wallboard session is revoked once without periodic 401 churn', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'wallboard-chromium', 'The long-running wallboard owns this session boundary.');
+ let apiRequests = 0;
+ await page.route('**/api/v1/**', async (route) => {
+ apiRequests += 1;
+ await route.fulfill({ status: 401, contentType: 'application/problem+json', body: JSON.stringify({ code: 'UNAUTHENTICATED' }) });
+ });
+
+ await page.goto('/wallboard?interval=10&refresh=10');
+ await expect(page.getByRole('heading', { level: 1, name: 'Geen toegang' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Aanmelden' })).toBeVisible();
+ await expect.poll(() => apiRequests).toBeGreaterThan(0);
+ const boundaryRequests = apiRequests;
+ // Development StrictMode mounts the four bootstrap readers twice. The
+ // production bundle issues one batch; neither mode may start a second one.
+ expect(boundaryRequests).toBeLessThanOrEqual(8);
+
+ await page.waitForTimeout(12_000);
+ expect(apiRequests).toBe(boundaryRequests);
+ await expect(page.getByRole('heading', { level: 1, name: 'Geen toegang' })).toBeVisible();
+});
diff --git a/apps/web/tests/e2e/sol-ultra.spec.ts b/apps/web/tests/e2e/sol-ultra.spec.ts
new file mode 100644
index 0000000..e2f602e
--- /dev/null
+++ b/apps/web/tests/e2e/sol-ultra.spec.ts
@@ -0,0 +1,130 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test, type Page } from '@playwright/test';
+import path from 'node:path';
+
+const observedAt = new Date().toISOString();
+
+async function mockSignalFlow(page: Page): Promise {
+ await page.route('**/api/v1/**', async (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ const body = pathname === '/api/v1/system/status' ? {
+ version: '1', generatedAt: observedAt, overallState: 'degraded',
+ components: [
+ { id: 'database', state: 'healthy', reason: 'ok' },
+ { id: 'worker', state: 'healthy', reason: 'ok' },
+ { id: 'prometheus', state: 'degraded', reason: 'source_stale' },
+ { id: 'unraid', state: 'healthy', reason: 'ok' },
+ { id: 'storage', state: 'healthy', reason: 'ok' },
+ ],
+ backup: { state: 'healthy', reason: 'ok', ageSeconds: 3600 },
+ sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
+ } : pathname === '/api/v1/host' ? {
+ identity: { name: 'tower' }, cpu: { totalPercent: 68.4, perCore: [72, 64, 66, 71] },
+ memory: { utilizationPercent: 71.2 }, source: { state: 'healthy', freshness: 'fresh' },
+ } : pathname === '/api/v1/containers' ? {
+ source: { state: 'healthy', freshness: 'fresh' }, total: 6,
+ containers: [
+ { id: 'one', state: 'running', health: 'healthy' }, { id: 'two', state: 'running', health: 'healthy' },
+ { id: 'three', state: 'running', health: 'healthy' }, { id: 'four', state: 'running', health: 'healthy' },
+ { id: 'five', state: 'running', health: 'healthy' }, { id: 'six', state: 'restarting', health: 'unknown' },
+ ],
+ } : pathname === '/api/v1/pools' ? {
+ source: { state: 'healthy', freshness: 'fresh' }, total: 2,
+ pools: [
+ { id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 63.8 },
+ { id: 'array', name: 'Array', state: 'healthy', capacitySeverity: 'attention', utilizationPercent: 86.1 },
+ ],
+ } : pathname === '/api/v1/services' ? {
+ capabilityState: 'available', configurationState: 'configured', total: 3,
+ services: [
+ { id: 'proxy', name: 'Reverse proxy', state: 'up' },
+ { id: 'auth', name: 'Authentik', state: 'up' },
+ { id: 'media', name: 'Media', state: 'degraded' },
+ ],
+ } : pathname === '/api/v1/incidents' ? {
+ items: [{ id: 'incident-1', title: 'Prometheus-bron loopt achter', severity: 'warning', startedAt: observedAt }],
+ } : pathname === '/api/v1/dashboards' ? { items: [] } : {};
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
+ });
+}
+
+test.beforeEach(async ({ page }) => mockSignalFlow(page));
+
+test('operational signal path is diagnostic, keyboard reachable and responsive', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'The wallboard keeps its dedicated bounded composition.');
+ const runtimeErrors: string[] = [];
+ page.on('pageerror', (error) => runtimeErrors.push(error.message));
+ page.on('console', (message) => { if (message.type() === 'error') runtimeErrors.push(message.text()); });
+ await page.goto('/');
+ const panel = page.locator('.signal-path-panel');
+ await expect(panel).toBeVisible();
+ await expect(panel.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
+ const stages = panel.locator('.signal-path-stage button');
+ await expect(stages).toHaveCount(6);
+ await expect(stages.first()).toHaveAttribute('aria-pressed', 'true');
+ await stages.nth(1).focus();
+ await page.keyboard.press('Enter');
+ await expect(stages.nth(1)).toHaveAttribute('aria-pressed', 'true');
+ await expect(panel.locator('.signal-path-inspector')).toContainText('68,4%');
+ await expect(panel.locator('.signal-path-inspector')).toContainText('71,2%');
+ await expect(panel.getByRole('button', { name: 'Open host' })).toBeVisible();
+
+ const stageHeights = await stages.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
+ expect(stageHeights.every((height) => height >= 44)).toBe(true);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ if (testInfo.project.name === 'desktop-chromium') {
+ const severe = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
+ expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
+ }
+
+ if (process.env.PULSE_SOL_ULTRA_CAPTURE) {
+ await page.evaluate(() => { window.scrollTo(0, 0); (document.activeElement as HTMLElement | null)?.blur(); });
+ const mobile = testInfo.project.name === 'mobile-chromium';
+ await page.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual', `overview-${testInfo.project.name}.png`), fullPage: !mobile });
+ if (mobile) {
+ await page.addStyleTag({ content: '.context-bar, .mobile-navigation { display: none !important; }' });
+ await panel.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual/overview-mobile-chromium-signal.png') });
+ }
+ }
+ expect(runtimeErrors).toEqual([]);
+ if (testInfo.project.name === 'desktop-chromium') {
+ await panel.getByRole('button', { name: 'Open host' }).click();
+ await expect(page).toHaveURL(/\/host$/);
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ }
+});
+
+test('failed overview resources remain unknown and recover through the shared retry', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop-chromium', 'The data-state contract is viewport independent.');
+ let poolCalls = 0;
+ let allowPoolRecovery = false;
+ await page.route('**/api/v1/pools?**', async (route) => {
+ poolCalls += 1;
+ if (!allowPoolRecovery) {
+ await route.fulfill({ status: 503, contentType: 'application/problem+json', body: JSON.stringify({ code: 'SOURCE_UNAVAILABLE' }) });
+ return;
+ }
+ await route.fallback();
+ });
+ await page.goto('/');
+ const storage = page.locator('.signal-path-stage').filter({ hasText: 'Opslag' });
+ await expect(storage).toHaveAttribute('data-tone', 'unknown');
+ await expect(storage).toContainText('Niet beschikbaar');
+ await expect(page.locator('.overview-kpi').filter({ hasText: 'Hoogste poolgebruik' }).locator('strong')).toHaveText('—');
+ await expect(page.locator('.capacity-plane')).toContainText('Deze overzichtsbron kon niet worden geladen');
+ await expect(page.getByRole('heading', { level: 1, name: 'Aandacht vereist' })).toBeVisible();
+
+ allowPoolRecovery = true;
+ await page.getByRole('button', { name: 'Opnieuw laden' }).click();
+ await expect(storage).toContainText('Aandacht');
+ await expect(storage).not.toContainText('Niet beschikbaar');
+ expect(poolCalls).toBeGreaterThanOrEqual(2);
+});
+
+test('signal animation yields to reduced-motion preference', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop-chromium', 'Reduced-motion CSS is viewport independent.');
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.goto('/');
+ const animation = await page.locator('.signal-path-stage--healthy').first().evaluate((element) => getComputedStyle(element, '::before').animationDuration);
+ expect(Number.parseFloat(animation)).toBeLessThanOrEqual(0.001);
+});
diff --git a/apps/web/tests/e2e/source-status-presentation.spec.ts b/apps/web/tests/e2e/source-status-presentation.spec.ts
new file mode 100644
index 0000000..9721995
--- /dev/null
+++ b/apps/web/tests/e2e/source-status-presentation.spec.ts
@@ -0,0 +1,55 @@
+import { expect, test, type Page } from '@playwright/test';
+
+const zeroTimestamp = '0001-01-01T00:00:00Z';
+
+async function mockSourceStatusData(page: Page): Promise {
+ await page.route('**/api/v1/**', async (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ const source = { id: 'unraid', state: 'healthy', freshness: 'stale', observedAt: zeroTimestamp, reason: 'source_stale' };
+ const body = pathname === '/api/v1/host' ? {
+ source,
+ identity: { name: 'Tower', version: '7.2.0' },
+ uptimeSeconds: 3600,
+ cpu: { totalPercent: 12, perCore: [12], iowaitPercent: 0 },
+ load: { one: 0.1, five: 0.2, fifteen: 0.3 },
+ memory: { totalBytes: 1024, availableBytes: 512, usedBytes: 512, utilizationPercent: 50, swapTotalBytes: 0, swapUsedBytes: 0, swapUtilizationPercent: 0 },
+ filesystems: [], network: [], time: { synchronized: true, offsetSeconds: 0, state: 'healthy' },
+ status: { state: 'unknown', reasons: [{ code: 'filesystem_root_not_configured', message: 'technical' }] },
+ observedAt: zeroTimestamp, receivedAt: zeroTimestamp,
+ warnings: ['filesystem_root_not_configured'],
+ } : pathname === '/api/v1/array' ? {
+ source, state: 'unknown', parity: { present: false, state: 'unknown', errors: 0 }, members: [],
+ } : pathname === '/api/v1/disks' ? {
+ source: { ...source, id: 'unraid-disks', reason: 'filesystem_root_not_configured' }, total: 0, disks: [],
+ } : pathname === '/api/v1/pools' ? {
+ source: { ...source, id: 'unraid-pools' }, total: 0, pools: [],
+ } : pathname === '/api/v1/applications' ? {
+ source, total: 0, applications: [],
+ } : pathname === '/api/v1/system/status' ? {
+ version: '1', generatedAt: zeroTimestamp, overallState: 'unknown', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
+ } : {};
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
+ });
+}
+
+test.beforeEach(async ({ page }) => mockSourceStatusData(page));
+
+test('source status is human-readable and technically progressive on core routes', async ({ page }) => {
+ for (const route of ['/host', '/storage', '/applications']) {
+ await page.goto(route);
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ await expect(page.getByText('Nooit ontvangen').first()).toBeVisible();
+ await expect(page.getByText(/laatste meting is verouderd/i).first()).toBeVisible();
+ const visibleText = await page.locator('body').innerText();
+ expect(visibleText).not.toContain('source_stale');
+ expect(visibleText).not.toContain('filesystem_root_not_configured');
+ expect(visibleText).not.toMatch(/1 jan(?:uari)? 1/i);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
+ }
+
+ await page.goto('/host');
+ const technical = page.locator('.source-status-technical').first();
+ await expect(technical).not.toHaveAttribute('open');
+ await technical.getByText('Technische broninformatie').click();
+ await expect(technical.getByText('source_stale')).toBeVisible();
+});
diff --git a/apps/web/tests/e2e/storage-real-stack.spec.ts b/apps/web/tests/e2e/storage-real-stack.spec.ts
new file mode 100644
index 0000000..8015712
--- /dev/null
+++ b/apps/web/tests/e2e/storage-real-stack.spec.ts
@@ -0,0 +1,32 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+
+const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
+
+test('storage map keeps physical identity and signal severities truthful', async ({ page }) => {
+ test.skip(!enabled, 'Run against an isolated real stack with the M11-03 storage fixture.');
+ const login = await page.request.get('/auth/test-login');
+ expect(login.ok()).toBeTruthy();
+
+ const disks = await (await page.request.get('/api/v1/disks?limit=100')).json() as { disks: Array<{ id: string; state: string; capacitySeverity: string; thermalSeverity: string }> };
+ expect(disks.disks.find((disk) => disk.id === 'disk-10')).toMatchObject({ state: 'online', capacitySeverity: 'critical', thermalSeverity: 'normal' });
+ expect(disks.disks.find((disk) => disk.id === 'cache')).toMatchObject({ state: 'online', capacitySeverity: 'attention', thermalSeverity: 'critical' });
+
+ const pools = await (await page.request.get('/api/v1/pools?limit=100')).json() as { pools: Array<{ id: string; state: string; capacitySeverity: string }> };
+ expect(pools.pools.find((pool) => pool.id === 'cache')).toMatchObject({ state: 'healthy', capacitySeverity: 'attention' });
+
+ await page.goto('/storage');
+ await expect(page.getByRole('heading', { level: 1, name: 'Opslagoverzicht' })).toBeVisible();
+ const visualNodes = page.locator('.storage-map-node');
+ await expect(visualNodes).toHaveCount(3);
+ await expect(visualNodes.filter({ hasText: 'disk10' })).toHaveCount(1);
+ await expect(visualNodes.filter({ hasText: 'disk10' })).toContainText('capaciteit kritiek');
+ await expect(visualNodes.filter({ hasText: 'cache' })).toHaveCount(2);
+ await expect(page.locator('.storage-heatmap-cell')).toHaveCount(2);
+ await expect(page.locator('.storage-heatmap-cell--critical')).toHaveCount(1);
+
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ expect(overflow).toBeLessThanOrEqual(1);
+ const accessibility = await new AxeBuilder({ page }).analyze();
+ expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
+});
diff --git a/apps/web/tests/e2e/wallboard-viewport.spec.ts b/apps/web/tests/e2e/wallboard-viewport.spec.ts
new file mode 100644
index 0000000..2f8e956
--- /dev/null
+++ b/apps/web/tests/e2e/wallboard-viewport.spec.ts
@@ -0,0 +1,62 @@
+import { expect, test } from '@playwright/test';
+
+const dashboard = { id: 'wallboard-dashboard', slug: 'operations', name: 'Operaties', description: 'Kritieke infrastructuur en recente gebeurtenissen.', scope: 'system', revision: 1, currentVersion: 1 };
+const widgets = [
+ ['system', 'Serverstatus', 0, 0, 6, 4, 'inventory', { entityType: 'server' }],
+ ['cpu', 'CPU en belasting', 6, 0, 10, 6, 'text', {}],
+ ['storage', 'Array en pools', 0, 6, 12, 7, 'text', {}],
+ ['apps', 'Applicaties', 12, 6, 12, 7, 'text', {}],
+ ['events', 'Recente gebeurtenissen', 0, 13, 24, 6, 'events', {}],
+].map(([id, title, x, y, w, h, sourceType, scope]) => ({
+ id, title, type: id === 'events' ? 'event-timeline' : 'stat',
+ data: { sourceType, scope, limit: 12 }, behavior: { liveIntervalSeconds: 30 },
+ layouts: { wallboard: { x, y, w, h, visible: true }, desktop: { x: 0, y: 0, w: 6, h: 4, visible: true } },
+}));
+
+test('wallboard rotates bounded 1080p slides with truthful transport and data status', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'wallboard-chromium', 'Requires the 1920x1080 wallboard viewport.');
+ let dashboardReads = 0;
+ await page.route('**/api/v1/**', async (route) => {
+ const path = new URL(route.request().url()).pathname;
+ if (path === '/api/v1/dashboards') {
+ dashboardReads += 1;
+ // React development mode performs an initial StrictMode re-read. Fail the
+ // first scheduled refresh, not either of the initial bootstrap reads.
+ if (dashboardReads === 3) {
+ await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary_unavailable' }) });
+ return;
+ }
+ }
+ const body = path === '/api/v1/dashboards' ? { items: [dashboard] }
+ : path === '/api/v1/dashboards/wallboard-dashboard' ? { dashboard, version: { document: { widgets, variables: [] } } }
+ : path === '/api/v1/system/status' ? { version: '1', generatedAt: new Date().toISOString(), overallState: 'degraded', components: [{ id: 'storage', state: 'degraded', reason: 'capacity_critical' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh' }] }
+ : path === '/api/v1/services' ? { services: [{ id: 'up', state: 'up' }, { id: 'down', state: 'down' }] }
+ : path === '/api/v1/incidents' ? { items: [{ id: 'incident-1' }] }
+ : path === '/api/v1/events' ? { items: [{ id: 'event-1', type: 'service.down', severity: 'critical', summary: 'Service niet beschikbaar', occurredAt: new Date().toISOString() }] }
+ : {};
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
+ });
+
+ const startedAt = Date.now();
+ await page.goto('/wallboard?interval=15&refresh=10');
+ await expect(page.getByRole('heading', { level: 1, name: 'Operationeel wallboard' })).toBeVisible();
+ expect(Date.now() - startedAt).toBeLessThan(3_000);
+ await expect(page.getByText('Slide 1 / 2')).toBeVisible();
+ await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden');
+ await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
+ await expect(page.locator('.wallboard-priority')).toContainText('OpslagVerstoord');
+ await expect(page.locator('.wallboard-priority')).toContainText('Services1 problemen');
+ await expect(page.locator('.wallboard-priority')).toContainText('Incidenten1 open');
+ await expect(page.getByRole('button', { name: /Bewerken|Exporteren/ })).toHaveCount(0);
+ expect(await page.evaluate(() => ({ horizontal: document.documentElement.scrollWidth - innerWidth, vertical: document.documentElement.scrollHeight - innerHeight }))).toEqual({ horizontal: 0, vertical: 0 });
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: `../../artifacts/evidence/M14-04/wallboard-slide-1-${testInfo.project.name}.png` });
+
+ await expect(page.getByText('Slide 2 / 2')).toBeVisible({ timeout: 18_000 });
+ await expect(page.getByRole('heading', { level: 3, name: 'Recente gebeurtenissen' })).toBeVisible();
+ await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Bron niet beschikbaar', { timeout: 12_000 });
+ await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
+ await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden', { timeout: 12_000 });
+ await expect(page.getByText('Slide 2 / 2')).toBeVisible();
+ expect(await page.evaluate(() => ({ horizontal: document.documentElement.scrollWidth - innerWidth, vertical: document.documentElement.scrollHeight - innerHeight }))).toEqual({ horizontal: 0, vertical: 0 });
+ if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: `../../artifacts/evidence/M14-04/wallboard-slide-2-${testInfo.project.name}.png` });
+});
diff --git a/apps/web/tests/setup.ts b/apps/web/tests/setup.ts
new file mode 100644
index 0000000..b6f636b
--- /dev/null
+++ b/apps/web/tests/setup.ts
@@ -0,0 +1,15 @@
+import '@testing-library/jest-dom/vitest';
+
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => undefined,
+ removeListener: () => undefined,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ dispatchEvent: () => false,
+ }),
+});
diff --git a/apps/web/tests/unit/AlertRulesPage.test.tsx b/apps/web/tests/unit/AlertRulesPage.test.tsx
new file mode 100644
index 0000000..5e33822
--- /dev/null
+++ b/apps/web/tests/unit/AlertRulesPage.test.tsx
@@ -0,0 +1,126 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { AlertRulesPage } from '../../src/AlertRulesPage';
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState({}, '', '/alerts'); });
+
+describe('begeleide alertregelbewerking', () => {
+ it('houdt de werkruimte gesloten wanneer het regelscontract toegang weigert', async () => {
+ vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
+ if (String(input).includes('/alert-rules?')) return Promise.resolve(new Response('{}', { status: 403 }));
+ return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ }));
+
+ render( );
+
+ expect(await screen.findByRole('heading', { name: 'Geen toegang tot alertregels' })).toBeVisible();
+ expect(screen.queryByRole('navigation', { name: 'Werkruimte voor meldingen' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Actieve en recente meldingen' })).not.toBeInTheDocument();
+ });
+
+ it('laadt de metriccatalogus en blokkeert een ongeldige regel', async () => {
+ const fetchMock = vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [
+ { semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' },
+ ] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ if (url.includes('/alert-silences') || url.includes('/maintenance-windows') || url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.click((await screen.findByText('Alertregels')).closest('button')!);
+ const save = await screen.findByRole('button', { name: 'Regel opslaan' });
+ expect(save).toBeDisabled();
+ const metric = screen.getByRole('combobox', { name: /Meting/ }) as HTMLSelectElement;
+ expect(metric).toHaveTextContent('CPU-gebruik van de host (%)');
+ expect(screen.queryByText('host.cpu.utilization')).not.toBeInTheDocument();
+
+ await user.type(document.querySelector('#alert-rule-name')!, 'Hoge hostbelasting');
+ await user.selectOptions(metric, 'host.cpu.utilization');
+ expect(save).toBeEnabled();
+
+ const technical = screen.getByText('Technische regelgegevens').closest('details');
+ expect(technical).not.toHaveAttribute('open');
+ expect(screen.getByLabelText('Host niet bereikbaar')).not.toBeChecked();
+ await user.click(screen.getByLabelText('Host niet bereikbaar'));
+ expect(screen.getByLabelText('Host niet bereikbaar')).toBeChecked();
+ await user.click(screen.getByText('Stiltes en onderhoud').closest('button')!);
+ expect(document.querySelector('#silence-matcher')).toHaveTextContent('Kritiek');
+ expect(document.querySelector('#maintenance-selector')).toHaveTextContent('Host');
+ expect(window.location.search).toBe('?section=controls');
+ });
+
+ it('laat bestaande niet-metrische regels met typeafhankelijke validatie bewerken', async () => {
+ const eventRule = {
+ id: '81111111-1111-4111-8111-111111111111', schemaVersion: 1, name: 'Container bevindt zich in een herstartlus', enabled: true, severity: 'degraded', scope: {},
+ condition: { inputType: 'event', operator: '>=', threshold: 3, aggregation: 'count', windowSeconds: 900 }, evaluationIntervalSeconds: 30, pendingSeconds: 0, resolveSeconds: 900, cooldownSeconds: 0,
+ unknownBehavior: 'become-unknown', groupBy: [], suppressWhen: ['host.unreachable'], message: { titleKey: 'alerts.restart.title', bodyKey: 'alerts.restart.body' }, revision: 1, currentVersion: 1,
+ };
+ vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [eventRule] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ }));
+
+ render( );
+
+ await userEvent.setup().click((await screen.findByText('Alertregels')).closest('button')!);
+ expect(await screen.findByDisplayValue('Container bevindt zich in een herstartlus')).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: /Signaalbron/ })).toHaveValue('event');
+ expect(screen.queryByRole('combobox', { name: /Meting/ })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Regel opslaan' })).toBeEnabled();
+ });
+
+ it('zet kritieke actieve meldingen vooraan en bewaart confirmatie, revisie en idempotentie', async () => {
+ const alerts = Array.from({ length: 25 }, (_, index) => ({
+ id: `alert-${String(index + 1).padStart(2, '0')}`,
+ state: index === 1 ? 'acknowledged' : 'firing',
+ retainedState: 'firing',
+ ruleName: `Melding ${String(index + 1).padStart(2, '0')}`,
+ severity: index % 5 === 0 ? 'critical' : 'attention',
+ entityName: 'Tower',
+ reason: 'threshold_exceeded',
+ revision: index + 1,
+ updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
+ }));
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ if (url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: alerts }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ return Promise.resolve(new Response(JSON.stringify({ alert: alerts[0] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const confirm = vi.fn(() => false);
+ vi.stubGlobal('confirm', confirm);
+ const user = userEvent.setup();
+
+ render( );
+
+ const critical = (await screen.findByText('Kritiek actief')).closest('button')!;
+ expect(screen.getByText('Actief').closest('button')).toHaveAttribute('aria-pressed', 'true');
+ await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(20));
+ expect(document.querySelector('.alert-operation-list-items li')).toHaveTextContent('Kritiek');
+ await user.click(critical);
+ await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(5));
+
+ const acknowledge = screen.getAllByRole('button', { name: 'Erkennen' })[0];
+ await user.click(acknowledge);
+ expect(confirm).toHaveBeenCalledWith('Deze melding erkennen? De evaluatie en geschiedenis blijven behouden.');
+ expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false);
+
+ confirm.mockReturnValue(true);
+ await user.click(acknowledge);
+ const operation = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
+ expect(operation?.[1]?.headers).toMatchObject({ 'If-Match': '1' });
+ expect((operation?.[1]?.headers as Record)['Idempotency-Key']).toBeTruthy();
+ });
+});
diff --git a/apps/web/tests/unit/AppOverview.test.tsx b/apps/web/tests/unit/AppOverview.test.tsx
new file mode 100644
index 0000000..5483da1
--- /dev/null
+++ b/apps/web/tests/unit/AppOverview.test.tsx
@@ -0,0 +1,269 @@
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import App from '../../src/App';
+import { resetSystemStatusForTests } from '../../src/systemStatus';
+
+afterEach(() => {
+ cleanup();
+ resetSystemStatusForTests();
+ vi.unstubAllGlobals();
+ window.history.replaceState({}, '', '/');
+});
+
+function json(value: unknown, status = 200): Response {
+ return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
+}
+
+const healthySource = { state: 'healthy', freshness: 'fresh' };
+
+describe('Stitch command overview', () => {
+ it('shows real source values while failed sources remain explicitly unavailable', async () => {
+ window.history.replaceState({}, '', '/');
+ const now = new Date().toISOString();
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === '/api/v1/system/status') return json({
+ version: '1', generatedAt: now, overallState: 'healthy', components: [
+ { id: 'database', state: 'healthy', reason: 'ok' },
+ { id: 'prometheus', state: 'healthy', reason: 'ok' },
+ ], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
+ });
+ if (url === '/api/v1/host') return json({
+ identity: { name: 'tower-lab' },
+ cpu: { totalPercent: 37.5, perCore: [25, 50] },
+ memory: { utilizationPercent: 62.25 },
+ source: healthySource,
+ });
+ if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [{ id: 'running', state: 'RUNNING', health: 'healthy' }, { id: 'stopped', state: 'stopped', health: 'unknown' }], total: 2 });
+ if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'svc-1', name: 'API', state: 'up' }], total: 1 });
+ if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
+ if (url.startsWith('/api/v1/pools')) return json({ error: 'source unavailable' }, 503);
+ return json({ error: 'unexpected request' }, 404);
+ }));
+
+ render( );
+
+ expect((await screen.findAllByText('37,5%'))[0]).toBeVisible();
+ expect(document.querySelector('.instrument-band')).toBeInTheDocument();
+ expect(document.querySelector('.data-plane')).toBeInTheDocument();
+ expect(document.querySelector('.focus-panel')).toBeInTheDocument();
+ expect(document.querySelector('.context-inspector')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
+ const unavailableStorage = screen.getByRole('button', { name: /Opslag: Niet beschikbaar/ });
+ expect(unavailableStorage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
+ const staleSource = screen.getByRole('button', { name: /Bronnen: Aandacht.*0\/1/ });
+ expect(staleSource.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'attention');
+ expect(screen.getAllByText('62,3%')[0]).toBeVisible();
+ expect(screen.getByText('tower-lab')).toBeVisible();
+ expect(screen.getByRole('button', { name: /Workloads: Kritiek.*1\/2/ })).toBeVisible();
+ expect(screen.queryByText('0 pools')).not.toBeInTheDocument();
+ const storageCard = screen.getByRole('heading', { name: 'Capaciteit en toestand' }).closest('article');
+ expect(storageCard).not.toBeNull();
+ expect(within(storageCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
+ expect(screen.getByText('Opslag: Niet beschikbaar')).toBeVisible();
+ expect(screen.getByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
+ });
+
+ it('suppresses current workload claims when retained container data is stale', async () => {
+ const now = new Date().toISOString();
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
+ if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
+ if (url.startsWith('/api/v1/containers')) return json({ source: { state: 'unknown', freshness: 'stale' }, containers: [{ id: 'retained', state: 'running', health: 'healthy' }], total: 1 });
+ if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
+ if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
+ if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
+ return json({}, 404);
+ }));
+
+ render( );
+
+ expect(await screen.findByRole('button', { name: /Workloads: Verouderd.*actieve containers: —/ })).toBeVisible();
+ const workloadCard = screen.getByRole('heading', { name: 'Containers' }).closest('article');
+ expect(workloadCard).not.toBeNull();
+ expect(within(workloadCard!).getByText(/laatst bekende waarden worden niet als actueel getoond/i)).toBeVisible();
+ expect(within(workloadCard!).queryByText(/1 van 1 containers zijn actief/)).not.toBeInTheDocument();
+ });
+
+ it('settles independent resources while a single endpoint is still pending', async () => {
+ const now = new Date().toISOString();
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
+ if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
+ if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
+ if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
+ if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'api', name: 'API', state: 'up' }], total: 1 });
+ if (url.startsWith('/api/v1/incidents')) return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
+ });
+ return json({}, 404);
+ }));
+
+ render( );
+
+ expect(await screen.findByRole('button', { name: /Host: Gezond.*30%.*40%/ })).toBeVisible();
+ expect(screen.getByRole('button', { name: /Incidenten: Wordt geladen/ })).toBeVisible();
+ expect(screen.getByRole('heading', { level: 1, name: 'Status nog niet bevestigd' })).toBeVisible();
+ const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
+ expect(incidentCard).not.toBeNull();
+ expect(within(incidentCard!).getByText(/wordt geladen; er wordt nog geen toestand verondersteld/)).toBeVisible();
+ });
+
+ it('does not present an unavailable incident feed as an empty healthy feed', async () => {
+ const now = new Date().toISOString();
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
+ if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
+ if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
+ if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
+ if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
+ if (url.startsWith('/api/v1/incidents')) return json({ code: 'UNAVAILABLE' }, 503);
+ return json({}, 404);
+ }));
+
+ render( );
+
+ expect(await screen.findByRole('button', { name: /Incidenten: Niet beschikbaar/ })).toBeVisible();
+ const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
+ expect(incidentCard).not.toBeNull();
+ expect(within(incidentCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
+ expect(within(incidentCard!).queryByText('Er zijn geen open incidenten geregistreerd.')).not.toBeInTheDocument();
+ });
+
+ it('distinguishes forbidden resources from an expired session', async () => {
+ const now = new Date().toISOString();
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === '/api/v1/system/status') return json({ code: 'FORBIDDEN' }, 403);
+ if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
+ if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
+ if (url.startsWith('/api/v1/pools')) return json({ code: 'FORBIDDEN' }, 403);
+ if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
+ if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
+ return json({}, 404);
+ }));
+
+ render( );
+
+ expect(await screen.findByRole('button', { name: /Bronnen: Geen toegang/ })).toBeVisible();
+ expect(await screen.findByRole('button', { name: /Opslag: Geen toegang/ })).toBeVisible();
+ expect(screen.getByText('Opslag: Geen toegang')).toBeVisible();
+ expect(screen.getAllByText(/account heeft geen toegang tot deze overzichtsbron/).length).toBeGreaterThan(0);
+ expect(screen.queryByRole('link', { name: /Aanmelden/ })).not.toBeInTheDocument();
+ });
+
+ it('prioriteert kritieke poolcapaciteit boven gezonde device-health', async () => {
+ const now = new Date().toISOString();
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
+ if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'ssd', name: 'ssd', state: 'healthy', capacitySeverity: 'critical', utilizationPercent: 98.8 }], total: 1 });
+ if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
+ if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
+ if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
+ if (url === '/api/v1/host') return json({ source: healthySource });
+ return json({}, 404);
+ }));
+
+ render( );
+
+ expect(await screen.findByText('ssd: Kritiek')).toBeVisible();
+ expect(screen.getByText(/kritieke capaciteitsgrens is overschreden/)).toBeVisible();
+ expect(screen.getByText('Kritiek · device-health gezond')).toBeVisible();
+ expect(await screen.findByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
+ await waitFor(() => expect(screen.getByRole('button', { name: /Opslag: Kritiek/ })).toHaveAttribute('aria-pressed', 'true'));
+ });
+
+ it('retries every overview resource from the shared retry action', async () => {
+ const user = userEvent.setup();
+ const now = new Date().toISOString();
+ let poolCalls = 0;
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
+ if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
+ if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
+ if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
+ if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
+ if (url.startsWith('/api/v1/pools')) {
+ poolCalls += 1;
+ if (poolCalls === 1) return json({ code: 'POOLS_UNAVAILABLE' }, 503);
+ return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
+ }
+ return json({}, 404);
+ }));
+
+ render( );
+ expect(await screen.findByText('Opslag: Niet beschikbaar')).toBeVisible();
+ await user.click(screen.getByRole('button', { name: 'Opnieuw laden' }));
+
+ expect(await screen.findByRole('button', { name: /Opslag: Gezond/ })).toBeVisible();
+ expect(poolCalls).toBe(2);
+ expect(screen.queryByText('Opslag: Niet beschikbaar')).not.toBeInTheDocument();
+ });
+
+ it('reads the bounded container target scale and marks truncated services partial', async () => {
+ const now = new Date().toISOString();
+ const containers = Array.from({ length: 150 }, (_, index) => ({ id: `container-${String(index).padStart(3, '0')}`, state: 'running', health: 'healthy' }));
+ const services = Array.from({ length: 100 }, (_, index) => ({ id: `service-${String(index).padStart(3, '0')}`, name: `Service ${index}`, state: 'up' }));
+ let containerCalls = 0;
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
+ const raw = String(input);
+ const url = new URL(raw, 'http://pulse.test');
+ if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
+ if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
+ if (url.pathname === '/api/v1/containers') {
+ containerCalls += 1;
+ const offset = Number(url.searchParams.get('after') ?? 0);
+ const page = containers.slice(offset, offset + 100);
+ return json({ source: healthySource, containers: page, total: containers.length, nextCursor: offset + page.length < containers.length ? String(offset + page.length) : undefined });
+ }
+ if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
+ if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'configured', services, total: 150 });
+ if (url.pathname === '/api/v1/incidents') return json({ items: [] });
+ return json({}, 404);
+ }));
+
+ render( );
+
+ expect(await screen.findByRole('button', { name: /Workloads: Gezond.*150\/150/ })).toBeVisible();
+ const serviceStage = screen.getByRole('button', { name: /Services: Gedeeltelijke data.*≥100\/150/ });
+ expect(serviceStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
+ expect(screen.getByText('Services: Gedeeltelijke data')).toBeVisible();
+ expect(containerCalls).toBe(2);
+ });
+
+ it('bounds container paging and does not inflate totals with overlapping rows', async () => {
+ const now = new Date().toISOString();
+ let containerCalls = 0;
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
+ const raw = String(input);
+ const url = new URL(raw, 'http://pulse.test');
+ if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
+ if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
+ if (url.pathname === '/api/v1/containers') {
+ containerCalls += 1;
+ const start = url.searchParams.get('after') === '100' ? 99 : url.searchParams.get('after') === '200' ? 199 : 0;
+ const items = Array.from({ length: 100 }, (_, index) => ({ id: `container-${start + index}`, state: 'running', health: 'healthy' }));
+ const nextCursor = containerCalls === 1 ? '100' : containerCalls === 2 ? '200' : '300';
+ return json({ source: healthySource, containers: items, total: 400, nextCursor });
+ }
+ if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [], total: 0 });
+ if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
+ if (url.pathname === '/api/v1/incidents') return json({ items: [] });
+ return json({}, 404);
+ }));
+
+ render( );
+
+ const workloadStage = await screen.findByRole('button', { name: /Workloads: Gedeeltelijke data.*≥299\/400/ });
+ expect(workloadStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
+ expect(screen.getByText('Workloads: Gedeeltelijke data')).toBeVisible();
+ expect(containerCalls).toBe(3);
+ });
+});
diff --git a/apps/web/tests/unit/ApplicationPage.test.tsx b/apps/web/tests/unit/ApplicationPage.test.tsx
new file mode 100644
index 0000000..a543a80
--- /dev/null
+++ b/apps/web/tests/unit/ApplicationPage.test.tsx
@@ -0,0 +1,43 @@
+import { cleanup, render, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { ApplicationPage } from '../../src/ApplicationPage';
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+describe('application status projection', () => {
+ it('uses attention for failures and unknown for incomplete evidence', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ source: { id: 'agent+services', state: 'healthy', freshness: 'fresh' },
+ total: 2,
+ applications: [
+ { id: 'a', name: 'attention-app', status: 'DOWN', overridden: false, components: [] },
+ { id: 'b', name: 'unknown-app', status: 'unknown', overridden: false, components: [] },
+ ],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ const attention = (await screen.findByText('attention-app')).closest('li');
+ const unknown = screen.getByText('unknown-app').closest('li');
+ expect(attention).not.toBeNull();
+ expect(unknown).not.toBeNull();
+ expect(within(attention!).getByText('Aandacht').closest('.status-badge')).toHaveClass('status-badge--attention');
+ expect(within(unknown!).getByText('Onbekend').closest('.status-badge')).toHaveClass('status-badge--unknown');
+ });
+
+ it('vertaalt staleness en toont nooit een jaar-1-waarneming als echte tijd', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ source: { id: 'agent+services', state: 'healthy', freshness: 'stale', observedAt: '0001-01-01T00:00:00Z', reason: 'source_stale' },
+ total: 0,
+ applications: [],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ expect(await screen.findByText(/laatste meting is verouderd/i)).toBeVisible();
+ expect(screen.getByText('Nooit ontvangen')).toBeVisible();
+ expect(screen.queryByText(/1 jan 1/i)).not.toBeInTheDocument();
+ expect(screen.getByText('source_stale')).not.toBeVisible();
+ });
+});
diff --git a/apps/web/tests/unit/ArrayPage.test.tsx b/apps/web/tests/unit/ArrayPage.test.tsx
new file mode 100644
index 0000000..1682d5a
--- /dev/null
+++ b/apps/web/tests/unit/ArrayPage.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { ArrayPage } from '../../src/ArrayPage';
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('ArrayPage', () => {
+ it('renders a bounded empty state when an older snapshot contains null collections', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+ contractVersion: '1',
+ source: { id: 'array', state: 'unknown', freshness: 'unavailable' },
+ state: 'unknown',
+ parity: { present: false, state: 'unknown', errors: 0 },
+ members: null,
+ history: null,
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ expect(await screen.findByRole('heading', { level: 1, name: 'Array en parity' })).toBeVisible();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/CapacityPage.test.tsx b/apps/web/tests/unit/CapacityPage.test.tsx
new file mode 100644
index 0000000..bc8abb1
--- /dev/null
+++ b/apps/web/tests/unit/CapacityPage.test.tsx
@@ -0,0 +1,40 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { CapacityPage } from '../../src/CapacityPage';
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('CapacityPage forecast qualification', () => {
+ it('does not count an insufficient assessment as a forecast', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z',
+ policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
+ qualifiedCount: 0,
+ items: [{ entityId: 'media', name: 'Media', kind: 'share', enabled: true, method: 'insufficient_data', windowSeconds: 2592000, dataPoints: 1, confidence: 'none', currentUsedBytes: 100, capacityBytes: 0, rateBytesPerDay: 0, reason: 'insufficient_points' }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ expect(await screen.findByText(/0 gekwalificeerde prognoses/)).toBeVisible();
+ expect(screen.getByRole('heading', { name: 'Media' })).toBeVisible();
+ expect(screen.getByText('Er zijn minder historische metingen dan het ingestelde minimum.')).toBeVisible();
+ expect(screen.queryByText('0 B / 0 B')).not.toBeInTheDocument();
+ });
+
+ it('renders a concrete empty state without a synthetic entity', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z', reason: 'source_unavailable',
+ policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
+ qualifiedCount: 0, items: [],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ expect(await screen.findByRole('heading', { name: 'Nog geen bruikbare capaciteitsprognose' })).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Bekijk shares en groeihistorie' })).toHaveAttribute('href', '/shares');
+ });
+});
diff --git a/apps/web/tests/unit/ContainerPage.test.tsx b/apps/web/tests/unit/ContainerPage.test.tsx
new file mode 100644
index 0000000..8706511
--- /dev/null
+++ b/apps/web/tests/unit/ContainerPage.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { ContainerPage } from '../../src/ContainerPage';
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('container status projection', () => {
+ it('normalizes presentation and never fabricates missing health or metrics', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ source: { id: 'unraid', state: 'healthy', freshness: 'fresh' },
+ total: 2,
+ containers: [
+ { id: 'a', name: 'alpha', state: 'RUNNING', health: 'unknown', intentionalStop: false, metricsAvailable: false, lifecycleAvailable: false, uptimeSeconds: 0, restartCount: 0, exitCode: 0, cpuPercent: 0, memoryBytes: 0, memoryLimitBytes: 0, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
+ { id: 'b', name: 'beta', state: 'restarting', health: 'unhealthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 4, exitCode: 137, cpuPercent: 12.5, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
+ ],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ const alpha = (await screen.findAllByRole('link', { name: 'alpha' })).map((item) => item.closest('tr')).find(Boolean);
+ const beta = screen.getAllByText('beta').map((item) => item.closest('tr')).find(Boolean);
+ expect(alpha).not.toBeNull();
+ expect(beta).not.toBeNull();
+ expect(within(alpha!).getByText('Actief').closest('.status-badge')).toHaveClass('status-badge--ready');
+ expect(within(alpha!).getAllByText('Onbekend').length).toBeGreaterThanOrEqual(2);
+ expect(within(alpha!).getByText(/metingen niet beschikbaar/)).toBeVisible();
+ expect(within(beta!).getByText('Wordt herstart').closest('.status-badge')).toHaveClass('status-badge--attention');
+ expect(within(beta!).getByText('Ongezond').closest('.status-badge')).toHaveClass('status-badge--attention');
+ expect(within(beta!).getByText('12,5%')).toBeVisible();
+ });
+
+ it('does not render stale item states as ready', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ source: { id: 'unraid', state: 'unknown', freshness: 'stale', reason: 'stale_source' },
+ total: 1,
+ containers: [{ id: 'a', name: 'stale-alpha', state: 'running', health: 'healthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 0, exitCode: 0, cpuPercent: 10, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ const row = (await screen.findAllByRole('link', { name: 'stale-alpha' })).map((item) => item.closest('tr')).find(Boolean);
+ expect(row).not.toBeNull();
+ expect(row!.querySelectorAll('.status-badge--unknown')).toHaveLength(2);
+ expect(within(row!).queryByText('running')).not.toBeInTheDocument();
+ expect(within(row!).queryByText('healthy')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/DashboardCache.test.tsx b/apps/web/tests/unit/DashboardCache.test.tsx
new file mode 100644
index 0000000..7b3c923
--- /dev/null
+++ b/apps/web/tests/unit/DashboardCache.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import App from '../../src/App';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ window.history.replaceState({}, '', '/');
+});
+
+describe('dashboard request caching', () => {
+ it('does not reuse dashboard responses across authentication or onboarding changes', async () => {
+ window.history.replaceState({}, '', '/dashboards');
+ const requests: Array<{ url: string; init?: RequestInit }> = [];
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ requests.push({ url, init });
+ if (url.startsWith('/api/v1/dashboards')) {
+ return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ return new Response(JSON.stringify({ error: 'not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
+ }));
+
+ render( );
+
+ expect(await screen.findByRole('heading', { level: 1, name: 'Jouw dashboards' })).toBeVisible();
+ const dashboardRequest = requests.find((request) => request.url.startsWith('/api/v1/dashboards'));
+ expect(dashboardRequest?.init?.cache).toBe('no-store');
+ });
+});
diff --git a/apps/web/tests/unit/EventsPage.test.tsx b/apps/web/tests/unit/EventsPage.test.tsx
new file mode 100644
index 0000000..bca3176
--- /dev/null
+++ b/apps/web/tests/unit/EventsPage.test.tsx
@@ -0,0 +1,81 @@
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { EventsPage } from '../../src/EventsPage';
+
+const items = Array.from({ length: 100 }, (_, index) => ({
+ id: `event-${String(index + 1).padStart(3, '0')}`,
+ type: index % 2 === 0 ? 'service.down' : 'container.restart',
+ severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
+ entityId: `entity-${String(index % 5).padStart(2, '0')}`,
+ sourceId: 'source-unraid',
+ occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
+ receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
+ summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ window.history.replaceState({}, '', '/events');
+});
+
+function mockEvents(payload = items) {
+ const fetch = vi.fn(async () => new Response(JSON.stringify({ items: payload }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ vi.stubGlobal('fetch', fetch);
+ return fetch;
+}
+
+describe('compacte eventtijdlijn', () => {
+ it('houdt honderd events begrensd en pagineert deterministisch met focus', async () => {
+ const fetch = mockEvents();
+ const user = userEvent.setup();
+ render( );
+
+ const list = await screen.findByRole('list', { name: 'Resultaten' });
+ expect(within(list).getAllByRole('listitem')).toHaveLength(20);
+ expect(within(list).getByText('event-001')).toBeInTheDocument();
+ expect(within(list).queryByText('event-021')).not.toBeInTheDocument();
+
+ const next = screen.getByRole('button', { name: 'Volgende pagina' });
+ await user.click(next);
+ await waitFor(() => expect(screen.getByText(/Pagina 2 van 5/)).toHaveFocus());
+ expect(within(list).getByText('event-021')).toBeInTheDocument();
+ expect(window.location.search).toContain('page=2');
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('filtert ernst, soort, onderdeel en vrije tekst zonder nieuwe request', async () => {
+ const fetch = mockEvents();
+ const user = userEvent.setup();
+ render( );
+ const list = await screen.findByRole('list', { name: 'Resultaten' });
+
+ await user.selectOptions(screen.getByLabelText('Ernst'), 'critical');
+ expect(within(list).getAllByRole('listitem')).toHaveLength(10);
+ await user.selectOptions(screen.getByLabelText('Soort'), 'service.down');
+ expect(within(list).getAllByRole('listitem')).toHaveLength(10);
+ await user.selectOptions(screen.getByLabelText('Onderdeel'), 'entity-00');
+ expect(within(list).getAllByRole('listitem')).toHaveLength(10);
+ await user.clear(screen.getByLabelText('Zoeken'));
+ await user.type(screen.getByLabelText('Zoeken'), 'Gebeurtenis 091');
+ expect(within(list).getAllByRole('listitem')).toHaveLength(1);
+ expect(within(list).getByText('event-091')).toBeInTheDocument();
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('houdt het kritieke totaal zichtbaar en biedt een herstelbare lege state', async () => {
+ mockEvents();
+ const user = userEvent.setup();
+ render( );
+
+ const critical = await screen.findByRole('button', { name: /Kritieke gebeurtenissen/i });
+ expect(critical).toHaveTextContent('10');
+ await user.type(screen.getByLabelText('Zoeken'), 'bestaat-niet');
+ expect(screen.getByText('Geen gebeurtenissen binnen de huidige filters.')).toBeVisible();
+ expect(critical).toBeVisible();
+ await user.click(screen.getByRole('button', { name: 'Alle filters wissen' }));
+ expect(await screen.findByRole('list', { name: 'Resultaten' })).toBeVisible();
+ });
+});
diff --git a/apps/web/tests/unit/InventoryPage.test.tsx b/apps/web/tests/unit/InventoryPage.test.tsx
new file mode 100644
index 0000000..4d6cdb0
--- /dev/null
+++ b/apps/web/tests/unit/InventoryPage.test.tsx
@@ -0,0 +1,52 @@
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { InventoryPage } from '../../src/InventoryPage';
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+const json = (value: unknown) => new Response(JSON.stringify(value), { status: 200, headers: { 'Content-Type': 'application/json' } });
+
+describe('InventoryPage', () => {
+ it('renders effective status and sends bounded filters and pagination', async () => {
+ const fetch = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes('after=')) return json({ items: [{ id: 'b', entityType: 'probe', canonicalName: 'probe.b', displayName: 'Probe B', status: 'unknown', factCount: 0, overrideCount: 0, relationCount: 0, sourceCount: 0, staleFactCount: 0 }], hasMore: false, nextCursor: '' });
+ return json({ items: [{ id: 'a', entityType: 'container', canonicalName: 'container.a', displayName: 'Handmatige API', status: 'operational', factCount: 2, overrideCount: 1, relationCount: 1, sourceCount: 2, staleFactCount: 0 }], hasMore: true, nextCursor: 'next' });
+ });
+ vi.stubGlobal('fetch', fetch);
+
+ render( );
+ expect(await screen.findByText('Handmatige API')).toBeVisible();
+ expect(screen.getByText('2 bronnen · 2 feiten · 1 relaties · 1 correcties')).toBeVisible();
+ fireEvent.change(screen.getByLabelText('Zoeken'), { target: { value: 'api' } });
+ expect(await screen.findByText('Handmatige API')).toBeVisible();
+ expect(fetch.mock.calls.some(([url]) => String(url).includes('q=api'))).toBe(true);
+ expect(window.location.search).toContain('q=api');
+ fireEvent.click(screen.getByRole('button', { name: 'Volgende pagina' }));
+ expect(await screen.findByText('Probe B')).toBeVisible();
+ expect(window.location.search).toContain('after=next');
+ });
+
+ it('shows override priority while preserving discovered facts and stale relations', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => json({
+ entity: { id: 'a', entityType: 'container', canonicalName: 'container.a', displayName: 'Handmatige API', status: 'operational', firstSeenAt: '2026-08-12T00:00:00Z', factCount: 2, overrideCount: 1, relationCount: 1, sourceCount: 2, staleFactCount: 1 },
+ aliases: [{ sourceName: 'Unraid', externalType: 'container', externalId: 'api' }],
+ facts: [{ fieldName: 'image', sourceId: 'unraid', sourceName: 'Unraid', value: 'discovered:latest', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }, { fieldName: 'runtimeState', sourceId: 'unraid', sourceName: 'Unraid', value: 'running', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }],
+ overrides: [{ fieldName: 'image', value: 'manual:pinned', updatedAt: '2026-08-12T01:00:00Z' }],
+ effectiveValues: [{ fieldName: 'image', value: 'manual:pinned', origin: 'override', stale: false, overriddenAt: '2026-08-12T01:00:00Z' }, { fieldName: 'runtimeState', value: 'running', origin: 'discovered', sourceName: 'Unraid', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }],
+ relations: [{ id: 'r', direction: 'outgoing', relationType: 'depends_on', peerId: 'b', peerType: 'service', peerName: 'Database', peerStatus: 'Ontbrekende entiteit', peerTombstonedAt: '2026-08-11T00:00:00Z', sourceName: 'Agent', confidence: .8, confirmed: false }],
+ })));
+
+ render( );
+ const effective = await screen.findByRole('heading', { name: 'Wat Pulse momenteel gebruikt' });
+ expect(within(effective.closest('section')!).getByText('manual:pinned')).toBeVisible();
+ expect(screen.getByText('Handmatige correctie')).toBeVisible();
+ expect(screen.getByText('Runtime-status')).toBeVisible();
+ expect(screen.getByText('Actief')).toBeVisible();
+ fireEvent.click(screen.getByText('Alle bronfeiten en correcties'));
+ expect(screen.getByText(/discovered:latest/)).toBeVisible();
+ expect(screen.getByText(/afgeleid/)).toBeVisible();
+ expect(screen.getByText('Ontbrekende entiteit')).toBeVisible();
+ });
+});
diff --git a/apps/web/tests/unit/MetricWidgets.test.tsx b/apps/web/tests/unit/MetricWidgets.test.tsx
new file mode 100644
index 0000000..933dc86
--- /dev/null
+++ b/apps/web/tests/unit/MetricWidgets.test.tsx
@@ -0,0 +1,81 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { MetricWidget, RankedListWidget, StatusGridWidget, metricStatus, type MetricWidgetProps } from '../../src/MetricWidgets';
+
+const now = Date.parse('2026-08-10T04:00:00Z');
+const freshPoint = { timestamp: now, value: 24, freshness: 'fresh' as const, labels: { host: 'tower' } };
+
+function props(overrides: Partial = {}): MetricWidgetProps {
+ return {
+ kind: 'stat',
+ series: [{ key: 'tower', points: [freshPoint] }],
+ freshness: 'fresh',
+ expectedStepSeconds: 15,
+ availability: 'success',
+ metricName: 'host.cpu.utilization',
+ visualization: { unit: 'percent', decimals: 0 },
+ ...overrides,
+ };
+}
+
+describe('metricStatus ADR-0008 invariant', () => {
+ it('marks only fresh, available data as ready', () => {
+ expect(metricStatus(props())).toMatchObject({ tone: 'ready' });
+ });
+
+ it.each([
+ props({ availability: 'idle' }),
+ props({ availability: 'loading' }),
+ props({ availability: 'connecting' }),
+ props({ availability: 'error' }),
+ props({ series: [] }),
+ props({ freshness: 'stale' }),
+ props({ freshness: 'unavailable' }),
+ props({ series: [{ key: 'tower', points: [{ ...freshPoint, freshness: 'delayed' }] }] }),
+ ])('never presents missing, stale or unavailable telemetry as ready', (value) => {
+ expect(metricStatus(value).tone).toBe('unknown');
+ });
+
+ it('renders an explicit unavailable notice without a numeric value', () => {
+ render( );
+ expect(screen.getByText('Bron tijdelijk onbereikbaar.')).toBeVisible();
+ expect(screen.getByText(/De waarde wordt niet als gezond geïnterpreteerd/)).toBeVisible();
+ expect(screen.queryByText('24 %')).not.toBeInTheDocument();
+ });
+});
+
+describe('interactive widgets', () => {
+ it('presents metric series labels as human-readable values without raw JSON', () => {
+ const rawKey = '{"interface":"eth0","host":"tower"}';
+ render( );
+
+ expect(screen.getByRole('list', { name: 'Legenda' })).toHaveTextContent('tower · eth0');
+ expect(screen.getByRole('img', { name: /Tijdreeks voor Goedgekeurde meting/ })).toBeVisible();
+ expect(document.querySelector('.metric-chart-line')).toHaveAttribute('d', 'M 28.000 192.000 L 628.000 192.000');
+ expect(document.body).not.toHaveTextContent(rawKey);
+ });
+
+ it('supports keyboard activation of ranked rows', async () => {
+ const onSelect = vi.fn();
+ render( );
+ const row = screen.getByRole('button', { name: /Disk 1/ });
+ row.focus();
+ await userEvent.keyboard('{Enter}');
+ expect(onSelect).toHaveBeenCalledWith('disk-1');
+ });
+
+ it('localizes status codes in status-grid fallbacks', () => {
+ render( );
+ expect(screen.getByText('Verstoord')).toBeVisible();
+ expect(screen.queryByText('degraded')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/NetworkPage.test.tsx b/apps/web/tests/unit/NetworkPage.test.tsx
new file mode 100644
index 0000000..ba99b45
--- /dev/null
+++ b/apps/web/tests/unit/NetworkPage.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { NetworkPage } from '../../src/NetworkPage';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('NetworkPage API boundary', () => {
+ it('normalizes nullable PostgreSQL collections before rendering', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ contractVersion: 'v1',
+ observedAt: '2026-08-10T04:41:55Z',
+ source: { id: 'agent', freshness: 'fresh', observedAt: '2026-08-10T04:41:55Z', state: 'unknown' },
+ health: null,
+ interfaces: null,
+ certificates: null,
+ events: null,
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ expect(await screen.findByRole('heading', { level: 1, name: 'Netwerk' })).toBeVisible();
+ expect(screen.getByText('Geen betrouwbare interfacegegevens beschikbaar.')).toBeVisible();
+ });
+
+ it('distinguishes an unconfigured DNS signal from generic unknown', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ contractVersion: 'v1', observedAt: '2026-08-10T04:41:55Z',
+ source: { id: 'network-aggregate', freshness: 'unknown', observedAt: '2026-08-10T04:41:55Z', state: 'unknown' },
+ health: [{ scope: 'dns', state: 'unknown', capabilityState: 'available', configurationState: 'not_configured', reason: 'not_configured', freshness: 'unavailable', observedAt: '2026-08-10T04:41:55Z' }],
+ interfaces: [], certificates: [], events: [],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ expect(await screen.findByText('Niet geconfigureerd')).toBeVisible();
+ expect(screen.getByText('Voor dit signaal is nog geen veilige probe geconfigureerd.')).toBeVisible();
+ });
+});
diff --git a/apps/web/tests/unit/OnboardingPage.test.tsx b/apps/web/tests/unit/OnboardingPage.test.tsx
new file mode 100644
index 0000000..110fd3d
--- /dev/null
+++ b/apps/web/tests/unit/OnboardingPage.test.tsx
@@ -0,0 +1,51 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { OnboardingPage } from '../../src/OnboardingPage';
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+const completed = {
+ state: { completed: true, step: 'completed', dashboardChoice: 'default', rulesChoice: 'default', dashboardId: 'overview', rulesReady: true },
+ capabilities: [
+ { id: 'auth', state: 'ready', detail: 'Aanmelding geconfigureerd.' },
+ { id: 'database', state: 'ready', detail: 'Database beschikbaar.' },
+ ],
+ resume: false,
+};
+
+describe('afgeronde onboarding', () => {
+ it('toont eerst alleen de samenvatting en maakt herconfiguratie expliciet en rolbewust', async () => {
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes('/system/status')) return Promise.resolve(new Response(JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ if (init?.method === 'POST') return Promise.resolve(new Response('{}', { status: 403 }));
+ return Promise.resolve(new Response(JSON.stringify(completed), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const confirm = vi.fn(() => false);
+ vi.stubGlobal('confirm', confirm);
+ const user = userEvent.setup();
+
+ render( );
+
+ expect(await screen.findByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
+ expect(screen.getByText('Overzicht actief')).toBeVisible();
+ expect(screen.getByText('Standaardmeldingen actief')).toBeVisible();
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Configuratie afronden' })).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Herconfiguratie openen' }));
+ expect(screen.getAllByRole('radio')).toHaveLength(4);
+ const save = screen.getByRole('button', { name: 'Herconfiguratie opslaan' });
+ await user.click(save);
+ expect(confirm).toHaveBeenCalled();
+ expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false);
+
+ confirm.mockReturnValue(true);
+ await user.click(save);
+ expect(await screen.findByText('Alleen een beheerder kan onboardingkeuzes wijzigen.')).toBeVisible();
+ expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true);
+ });
+});
diff --git a/apps/web/tests/unit/OperationalSignalPath.test.tsx b/apps/web/tests/unit/OperationalSignalPath.test.tsx
new file mode 100644
index 0000000..cfe4697
--- /dev/null
+++ b/apps/web/tests/unit/OperationalSignalPath.test.tsx
@@ -0,0 +1,56 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { OperationalSignalPath, type OperationalSignalStage } from '../../src/OperationalSignalPath';
+
+afterEach(cleanup);
+
+const stages: OperationalSignalStage[] = [
+ {
+ id: 'host', label: 'Host', icon: '▣', tone: 'healthy', statusLabel: 'Gezond',
+ primaryLabel: 'CPU-belasting', primaryValue: '42%', secondaryLabel: 'Geheugen', secondaryValue: '61%',
+ detail: 'Actuele hostmeting.', route: '/host',
+ },
+ {
+ id: 'storage', label: 'Opslag', icon: '▤', tone: 'critical', statusLabel: 'Kritiek',
+ primaryLabel: 'Poolgebruik', primaryValue: '98%', secondaryLabel: 'Pools', secondaryValue: '2',
+ detail: 'De capaciteitsgrens is overschreden.', route: '/storage',
+ },
+];
+
+describe('OperationalSignalPath', () => {
+ it('selects the most urgent stage and supports explicit drill-down', async () => {
+ const onNavigate = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ const storage = screen.getByRole('button', { name: /Opslag: Kritiek/ });
+ expect(storage).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByRole('region', { name: 'Opslag' })).toHaveTextContent('98%');
+
+ const host = screen.getByRole('button', { name: /Host: Gezond/ });
+ await user.click(host);
+ expect(host).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByRole('region', { name: 'Host' })).toHaveTextContent('Actuele hostmeting.');
+
+ await user.click(screen.getByRole('button', { name: 'Open host' }));
+ expect(onNavigate).toHaveBeenCalledWith('/host');
+ });
+
+ it('preserves an explicit keyboard selection across urgency updates', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render( );
+ const host = screen.getByRole('button', { name: /Host: Gezond/ });
+ host.focus();
+ await user.keyboard('{Enter}');
+ expect(host).toHaveAttribute('aria-pressed', 'true');
+
+ const updated = stages.map((stage) => stage.id === 'storage' ? { ...stage, tone: 'attention' as const, statusLabel: 'Aandacht' } : stage);
+ rerender( );
+ expect(screen.getByRole('button', { name: /Host: Gezond/ })).toHaveAttribute('aria-pressed', 'true');
+
+ rerender( stage.id !== 'host')} onNavigate={vi.fn()} />);
+ expect(screen.getByRole('button', { name: /Opslag: Aandacht/ })).toHaveAttribute('aria-pressed', 'true');
+ });
+});
diff --git a/apps/web/tests/unit/PoolPage.test.tsx b/apps/web/tests/unit/PoolPage.test.tsx
new file mode 100644
index 0000000..25ce1fd
--- /dev/null
+++ b/apps/web/tests/unit/PoolPage.test.tsx
@@ -0,0 +1,25 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { PoolPage } from '../../src/PoolPage';
+
+const criticalPool = {
+ id: 'ssd', name: 'ssd', filesystem: 'zfs', state: 'healthy', usableBytes: 1000,
+ usedBytes: 988, freeBytes: 12, utilizationPercent: 98.8, capacitySeverity: 'critical',
+ capabilities: { members: 'available', capacity: 'available', redundancy: 'available', scrub: 'available', filesystemErrors: 'available', performance: 'available', ssdWear: 'available', moverSignals: 'available' },
+};
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+describe('operationele poolstatus', () => {
+ it.each([
+ { id: undefined, payload: { source: { id: 'unraid', state: 'healthy', freshness: 'fresh' }, pools: [criticalPool], total: 1 } },
+ { id: 'ssd', payload: { source: { id: 'unraid', state: 'healthy', freshness: 'fresh' }, pools: [criticalPool], total: 1, pool: criticalPool } },
+ ])('toont kritieke capaciteit niet primair als gezond voor $id', async ({ id, payload }) => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+ render( );
+
+ expect(await screen.findByText('Kritiek')).toBeVisible();
+ expect(screen.queryByText('Gezond', { selector: '.status-badge' })).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/ServicePage.test.tsx b/apps/web/tests/unit/ServicePage.test.tsx
new file mode 100644
index 0000000..ebf86cc
--- /dev/null
+++ b/apps/web/tests/unit/ServicePage.test.tsx
@@ -0,0 +1,60 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { ServicePage } from '../../src/ServicePage';
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('ServicePage API boundary', () => {
+ it('renders a stable empty state when PostgreSQL serializes an empty service list as null', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ contractVersion: 'v1',
+ observedAt: '2026-08-10T04:38:27Z',
+ services: null,
+ total: 0,
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ expect(await screen.findByRole('heading', { level: 1, name: 'Services' })).toBeVisible();
+ expect(screen.getByRole('heading', { level: 2, name: 'Nog geen services geconfigureerd' })).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Open eerste configuratie' })).toHaveAttribute('href', '/onboarding');
+ });
+
+ it('does not present an unavailable source as an empty configuration', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ contractVersion: 'v1', observedAt: '2026-08-10T04:38:27Z', capabilityState: 'unavailable',
+ configurationState: 'unknown', reason: 'source_unavailable', services: [], total: 0,
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+
+ render( );
+
+ expect(await screen.findByRole('heading', { level: 2, name: 'Servicebron niet beschikbaar' })).toBeVisible();
+ expect(screen.queryByRole('link', { name: 'Open eerste configuratie' })).not.toBeInTheDocument();
+ });
+
+ it('renders the service-level TLS certificate when bounded probe history no longer contains the TLS sample', async () => {
+ vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith('/dependencies?limit=100')) {
+ return Promise.resolve(new Response(JSON.stringify({ serviceId: 'service-1', dependencies: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ }
+ return Promise.resolve(new Response(JSON.stringify({
+ service: {
+ id: 'service-1', name: 'Pulse', state: 'up', sampleCount: 5, successfulSampleCount: 5,
+ history: [{ probeId: 'http-probe', observedAt: '2026-08-12T10:20:00Z', state: 'up' }],
+ certificate: { expiresAt: '2026-11-01T00:00:00Z', issuer: 'Pulse test issuer', subject: 'CN=pulse.test', hostnameValid: true, verificationState: 'valid' },
+ },
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ }));
+
+ render( );
+
+ expect(await screen.findByRole('heading', { level: 2, name: 'TLS-certificaat' })).toBeVisible();
+ expect(screen.getByText('Pulse test issuer')).toBeVisible();
+ expect(screen.queryByText('Geen TLS-certificaat waargenomen.')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/SourceStatusDetails.test.tsx b/apps/web/tests/unit/SourceStatusDetails.test.tsx
new file mode 100644
index 0000000..255b695
--- /dev/null
+++ b/apps/web/tests/unit/SourceStatusDetails.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { SourceStatusDetails } from '../../src/SourceStatusDetails';
+
+describe('SourceStatusDetails', () => {
+ it('scheidt bronstatus van databruikbaarheid en houdt de code ingeklapt', () => {
+ render( );
+
+ expect(screen.getByText(/Bronstatus:/).closest('span')).toHaveTextContent('Gezond');
+ expect(screen.getByText(/Data:/).closest('span')).toHaveTextContent('Verouderd');
+ expect(screen.getByText(/laatste meting is verouderd/i)).toBeVisible();
+ const code = screen.getByText('source_stale');
+ expect(code.closest('details')).not.toHaveAttribute('open');
+ expect(code).not.toBeVisible();
+ });
+
+ it('toont nooit ontvangen zonder een misleidend time-element', () => {
+ const { container } = render( );
+
+ expect(screen.getByText('Nooit ontvangen')).toBeVisible();
+ expect(container.querySelector('time')).toBeNull();
+ });
+});
diff --git a/apps/web/tests/unit/StoragePage.test.tsx b/apps/web/tests/unit/StoragePage.test.tsx
new file mode 100644
index 0000000..53628a8
--- /dev/null
+++ b/apps/web/tests/unit/StoragePage.test.tsx
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildStorageNodes, type StorageData } from '../../src/StoragePage';
+
+function data(): StorageData {
+ return {
+ array: { source: { state: 'operational', freshness: 'fresh' }, state: 'operational', members: [{ id: 'disk-10', name: 'disk10', role: 'data', state: 'online' }] },
+ disks: { source: { state: 'healthy', freshness: 'fresh' }, disks: [{ id: 'DISK-10', name: 'disk10', role: 'data', state: 'online', utilizationPercent: 99.99, capacitySeverity: 'critical', thermalSeverity: 'normal' }, { id: 'cache', name: 'cache', role: 'cache', state: 'online', utilizationPercent: 85.5, capacitySeverity: 'attention', thermalSeverity: 'critical' }] },
+ pools: { source: { state: 'healthy', freshness: 'fresh' }, pools: [{ id: 'cache', name: 'cache', filesystem: 'zfs', state: 'healthy', utilizationPercent: 85.5, capacitySeverity: 'attention' }] },
+ };
+}
+
+describe('buildStorageNodes', () => {
+ it('merges array and disk observations by canonical physical identity', () => {
+ const nodes = buildStorageNodes(data());
+ expect(nodes.filter((node) => node.label === 'disk10')).toHaveLength(1);
+ expect(nodes).toHaveLength(3);
+ });
+
+ it('keeps availability, capacity and thermal signals visible', () => {
+ const nodes = buildStorageNodes(data());
+ const disk = nodes.find((node) => node.label === 'disk10');
+ const cacheDisk = nodes.find((node) => node.id === 'disk-cache');
+ const cachePool = nodes.find((node) => node.id === 'pool-cache');
+ expect(disk).toMatchObject({ state: 'critical' });
+ expect(disk?.detail).toContain('Beschikbaarheid normaal · capaciteit kritiek · temperatuur normaal');
+ expect(cacheDisk).toMatchObject({ state: 'critical' });
+ expect(cachePool?.detail).toContain('Device-health normaal · capaciteit aandacht');
+ });
+});
diff --git a/apps/web/tests/unit/StorageVisuals.test.tsx b/apps/web/tests/unit/StorageVisuals.test.tsx
new file mode 100644
index 0000000..f3701a8
--- /dev/null
+++ b/apps/web/tests/unit/StorageVisuals.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { StorageMapWidget, TemperatureHeatmap } from '../../src/StorageVisuals';
+
+describe('storagevisualisaties', () => {
+ it('localiseert statuscodes in de kaart en het toegankelijke tabelalternatief', () => {
+ render( );
+
+ expect(screen.getAllByText('Verstoord')).toHaveLength(2);
+ expect(screen.getByRole('link', { name: 'Cachepool: Verstoord' })).toBeVisible();
+ expect(screen.queryByText('degraded')).not.toBeInTheDocument();
+ });
+
+ it('localiseert de status in het tabelalternatief van de temperatuurkaart', () => {
+ render( );
+
+ expect(screen.getByRole('cell', { name: 'Kritiek' })).toBeVisible();
+ expect(screen.queryByRole('cell', { name: 'critical' })).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/auth.test.ts b/apps/web/tests/unit/auth.test.ts
new file mode 100644
index 0000000..30a6aeb
--- /dev/null
+++ b/apps/web/tests/unit/auth.test.ts
@@ -0,0 +1,35 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { apiRequestInit, installSessionWatcher, onUnauthenticated } from '../../src/auth';
+
+const nativeFetch = window.fetch;
+afterEach(() => { window.fetch = nativeFetch; });
+
+describe('authenticated API request caching', () => {
+ it('forces same-origin API reads past stale pre-login responses', () => {
+ expect(apiRequestInit('/api/v1/system/status')).toMatchObject({ cache: 'no-store' });
+ expect(apiRequestInit(new URL('/api/v1/dashboards', window.location.href), { method: 'GET', cache: 'force-cache' })).toMatchObject({ method: 'GET', cache: 'no-store' });
+ });
+
+ it('does not rewrite non-API or cross-origin requests', () => {
+ const init = { method: 'GET' } satisfies RequestInit;
+ expect(apiRequestInit('/assets/app.js', init)).toBe(init);
+ expect(apiRequestInit('https://example.com/api/v1/status', init)).toBe(init);
+ });
+
+ it('revokes the shared API context after one 401 and stops further network churn', async () => {
+ const transport = vi.fn(async () => new Response(null, { status: 401 }));
+ window.fetch = transport;
+ const notices = vi.fn();
+ const unsubscribe = onUnauthenticated(notices);
+ installSessionWatcher();
+
+ expect((await window.fetch('/api/v1/system/status')).status).toBe(401);
+ expect((await window.fetch('/api/v1/dashboards')).status).toBe(401);
+ expect((await window.fetch('/api/v1/events')).status).toBe(401);
+
+ expect(transport).toHaveBeenCalledTimes(1);
+ expect(notices).toHaveBeenCalledTimes(1);
+ unsubscribe();
+ });
+});
diff --git a/apps/web/tests/unit/dashboardScope.test.ts b/apps/web/tests/unit/dashboardScope.test.ts
new file mode 100644
index 0000000..1aba02f
--- /dev/null
+++ b/apps/web/tests/unit/dashboardScope.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+import { resolveDashboardScope } from '../../src/dashboardScope';
+
+describe('resolveDashboardScope', () => {
+ it('resolves exact declared defaults and drops non-string scope data', () => {
+ expect(resolveDashboardScope(
+ { serverId: '$server', literal: 'disk-1', unsafe: 42 },
+ [{ name: 'server', default: 'primary' }],
+ )).toEqual({ serverId: 'primary', literal: 'disk-1' });
+ });
+
+ it('preserves unresolved and partial references for fail-closed API validation', () => {
+ expect(resolveDashboardScope(
+ { serverId: '$missing', containerId: 'prefix-$server' },
+ [{ name: 'server', default: 'primary' }],
+ )).toEqual({ serverId: '$missing', containerId: 'prefix-$server' });
+ });
+});
diff --git a/apps/web/tests/unit/liveClient.test.ts b/apps/web/tests/unit/liveClient.test.ts
new file mode 100644
index 0000000..4618cdf
--- /dev/null
+++ b/apps/web/tests/unit/liveClient.test.ts
@@ -0,0 +1,121 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { LiveClient, type SocketFactory } from '../../src/liveClient';
+import type { MetricQueryRequest } from '../../src/metricClient';
+
+class FakeSocket {
+ readyState = 0;
+ onopen: (() => void) | null = null;
+ onmessage: ((event: { data: unknown }) => void) | null = null;
+ onerror: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+ readonly sent: string[] = [];
+
+ open(): void { this.readyState = 1; this.onopen?.(); }
+ send(payload: string): void { this.sent.push(payload); }
+ close(): void { this.readyState = 3; this.onclose?.(); }
+}
+
+const request: MetricQueryRequest = {
+ metric: 'host.cpu.utilization',
+ scope: { serverId: 'smoke-host' },
+ range: { from: '2026-08-10T06:00:00.000Z', to: '2026-08-10T06:05:00.000Z', stepSeconds: 15 },
+ aggregation: 'avg',
+};
+
+afterEach(() => vi.useRealTimers());
+
+describe('LiveClient subscription lifecycle', () => {
+ it('reuses one socket when React replaces an equivalent listener inside the release grace', async () => {
+ vi.useFakeTimers();
+ const sockets: FakeSocket[] = [];
+ const factory = vi.fn(() => {
+ const socket = new FakeSocket();
+ sockets.push(socket);
+ return socket;
+ }) as unknown as SocketFactory;
+ const client = new LiveClient('/api/v1/live', factory);
+
+ const first = client.subscribe(request, () => undefined);
+ sockets[0].open();
+ await Promise.resolve();
+ first.unsubscribe();
+
+ const shifted = { ...request, range: { ...request.range, from: '2026-08-10T06:01:00.000Z', to: '2026-08-10T06:06:00.000Z' } };
+ const second = client.subscribe(shifted, () => undefined);
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(sockets[0].readyState).toBe(1);
+ expect(sockets[0].sent.filter((payload) => payload.includes('unsubscribe'))).toHaveLength(0);
+
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(sockets[0].sent.some((payload) => payload.includes('"type":"ping"'))).toBe(true);
+
+ second.unsubscribe();
+ await vi.advanceTimersByTimeAsync(251);
+ expect(sockets[0].readyState).toBe(1);
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(sockets[0].readyState).toBe(3);
+ });
+
+ it('reuses an idle transport while a rotating dashboard loads its next query', async () => {
+ vi.useFakeTimers();
+ const sockets: FakeSocket[] = [];
+ const factory = vi.fn(() => {
+ const socket = new FakeSocket();
+ sockets.push(socket);
+ return socket;
+ }) as unknown as SocketFactory;
+ const client = new LiveClient('/api/v1/live', factory);
+
+ const first = client.subscribe(request, () => undefined);
+ sockets[0].open();
+ await Promise.resolve();
+ first.unsubscribe();
+
+ // Subscription state is released after 250 ms, but the bounded transport
+ // grace bridges a slower dashboard document fetch.
+ await vi.advanceTimersByTimeAsync(2_000);
+ expect(sockets[0].readyState).toBe(1);
+
+ const nextRequest = { ...request, metric: 'host.memory.utilization' };
+ const second = client.subscribe(nextRequest, () => undefined);
+ await Promise.resolve();
+
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(sockets[0].readyState).toBe(1);
+ expect(sockets[0].sent.some((payload) => payload.includes('host.memory.utilization'))).toBe(true);
+
+ second.unsubscribe();
+ await vi.advanceTimersByTimeAsync(10_251);
+ expect(sockets[0].readyState).toBe(3);
+ });
+
+ it('resubscribes once after reconnect and resumes samples on the bounded subscription', async () => {
+ vi.useFakeTimers();
+ const sockets: FakeSocket[] = [];
+ const factory = vi.fn(() => { const socket = new FakeSocket(); sockets.push(socket); return socket; }) as unknown as SocketFactory;
+ const events: string[] = [];
+ const client = new LiveClient('/api/v1/live', factory);
+ const subscription = client.subscribe(request, (event) => events.push(event.type));
+ sockets[0].open();
+ await Promise.resolve();
+ expect(sockets[0].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1);
+
+ sockets[0].close();
+ expect(events).toContain('status');
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(factory).toHaveBeenCalledTimes(2);
+ sockets[1].open();
+ await Promise.resolve();
+ const subscribe = JSON.parse(sockets[1].sent.find((payload) => payload.includes('"type":"subscribe"')) ?? '{}') as { subscriptionId?: string };
+ expect(sockets[1].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1);
+ sockets[1].onmessage?.({ data: JSON.stringify({ type: 'samples', subscriptionId: subscribe.subscriptionId, sequence: 1, samples: [{ timestamp: '2026-08-10T06:06:00.000Z', value: 42, labels: {} }] }) });
+ expect(events.at(-1)).toBe('samples');
+ expect(sockets).toHaveLength(2);
+
+ subscription.unsubscribe();
+ await vi.advanceTimersByTimeAsync(10_251);
+ });
+});
diff --git a/apps/web/tests/unit/locale.test.ts b/apps/web/tests/unit/locale.test.ts
new file mode 100644
index 0000000..e005944
--- /dev/null
+++ b/apps/web/tests/unit/locale.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatDateTime, hasReceivedTimestamp, NEVER_RECEIVED } from '../../src/locale';
+
+describe('veilige tijdpresentatie', () => {
+ it('presenteert ontbrekende, ongeldige en nulwaarden als nooit ontvangen', () => {
+ expect(formatDateTime()).toBe(NEVER_RECEIVED);
+ expect(formatDateTime('ongeldig')).toBe(NEVER_RECEIVED);
+ expect(formatDateTime('0001-01-01T00:00:00Z')).toBe(NEVER_RECEIVED);
+ expect(formatDateTime('1970-01-01T00:00:00Z')).toBe(NEVER_RECEIVED);
+ });
+
+ it('behoudt een werkelijk ontvangen timestamp en de vaste Brusselse tijdzone', () => {
+ const value = '2026-08-21T14:05:00Z';
+ expect(hasReceivedTimestamp(value)).toBe(true);
+ expect(formatDateTime(value)).toContain('16:05');
+ });
+});
diff --git a/apps/web/tests/unit/overviewSignals.test.ts b/apps/web/tests/unit/overviewSignals.test.ts
new file mode 100644
index 0000000..a489209
--- /dev/null
+++ b/apps/web/tests/unit/overviewSignals.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+
+import { containerSignalTone, signalToneFromState, sourceSignalTone, worstSignalTone } from '../../src/overviewSignals';
+
+describe('overview signal semantics', () => {
+ it('fails source provenance closed when freshness is stale or unavailable', () => {
+ expect(sourceSignalTone({ state: 'healthy', freshness: 'stale' })).toBe('stale');
+ expect(sourceSignalTone({ state: 'healthy', freshness: 'unavailable' })).toBe('unknown');
+ expect(sourceSignalTone({ state: 'degraded', freshness: 'fresh' })).toBe('attention');
+ expect(sourceSignalTone({ state: 'healthy', freshness: 'fresh' })).toBe('healthy');
+ expect(sourceSignalTone(undefined)).toBe('unknown');
+ });
+
+ it('mirrors container domain state without escalating intentional stops', () => {
+ expect(containerSignalTone({ state: 'running', health: 'healthy' })).toBe('healthy');
+ expect(containerSignalTone({ state: 'running', health: 'unhealthy' })).toBe('attention');
+ expect(containerSignalTone({ state: 'restarting', health: 'unknown' })).toBe('attention');
+ expect(containerSignalTone({ state: 'stopped', health: 'unknown' })).toBe('critical');
+ expect(containerSignalTone({ state: 'stopped', health: 'unknown', intentionalStop: true })).toBe('unknown');
+ });
+
+ it('keeps the most severe state deterministically', () => {
+ expect(signalToneFromState('DOWN')).toBe('critical');
+ expect(worstSignalTone(['healthy', 'unknown', 'attention', 'stale'])).toBe('attention');
+ expect(worstSignalTone([])).toBe('unknown');
+ });
+});
diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts
new file mode 100644
index 0000000..0f691a9
--- /dev/null
+++ b/apps/web/tests/unit/presentation.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+
+import { operationalStorageState, plural, presentArrayRole, presentComponent, presentEntityType, presentEventSummary, presentEventType, presentInventoryField, presentMetric, presentReason, presentRelationType, presentStatus, presentStoragePolicy, presentUnit } from '../../src/presentation';
+
+describe('Nederlandse presentatielaag', () => {
+ it('vertaalt begrensde status- en reason-codes', () => {
+ expect(presentStatus('critical')).toBe('Kritiek');
+ expect(presentReason('source_health_unknown')).toBe('De gezondheid van deze bron is onbekend.');
+ expect(presentReason('source_stale')).toContain('meting is verouderd');
+ expect(presentReason('filesystem_root_not_configured')).toContain('Bestandssysteemmetingen');
+ expect(presentReason('container_exited')).toBe('De container is gestopt.');
+ expect(presentReason('backup_verified')).toBe('De backup is geverifieerd.');
+ expect(presentReason('backup_stale')).toContain('maak en verifieer een nieuwe backup');
+ expect(presentReason('backup_verification_failed')).toContain('controleer de backupbestemming');
+ expect(presentReason('authenticated_session')).toContain('aanmeldsessie is geldig');
+ expect(presentReason('internal.unknown_code')).toContain('Open de technische details');
+ });
+
+ it('laat de zwaarste opslagernst winnen zonder device-health te herschrijven', () => {
+ expect(operationalStorageState('healthy', 'critical')).toBe('critical');
+ expect(operationalStorageState('degraded', 'normal')).toBe('degraded');
+ expect(operationalStorageState('healthy', 'normal')).toBe('healthy');
+ });
+
+ it('presenteert componenten en metrics zonder implementatiecode', () => {
+ expect(presentComponent('worker')).toBe('Achtergrondverwerking');
+ expect(presentComponent('notifications')).toBe('Notificaties');
+ expect(presentComponent('oidc')).toBe('Aanmelding');
+ expect(presentComponent('probes')).toBe('Servicecontroles');
+ expect(presentMetric('storage.disk.temperature')).toBe('Temperatuur per disk');
+ expect(presentMetric('future.metric')).toBe('Goedgekeurde meting');
+ expect(presentUnit('percent')).toBe('%');
+ });
+
+ it('vertaalt operationele domeincodes voor primaire schermen', () => {
+ expect(presentStatus('sleeping')).toBe('Slapend');
+ expect(presentStatus('online')).toBe('Online');
+ expect(presentStoragePolicy('HIGHWATER')).toBe('Hoogwater');
+ expect(presentArrayRole('parity')).toBe('Pariteit');
+ expect(presentEntityType('probe')).toBe('Servicecontrole');
+ expect(presentEntityType('application-project')).toBe('Compose-project');
+ expect(presentInventoryField('runtimeState')).toBe('Runtime-status');
+ expect(presentRelationType('depends_on')).toBe('is afhankelijk van');
+ expect(presentEventType('container.restart')).toBe('Container herstart');
+ expect(presentEventSummary('container.restart', 'Container restarted.')).toBe('De container is opnieuw gestart.');
+ });
+
+ it('gebruikt de correcte enkelvoudsvorm alleen voor één', () => {
+ expect(plural(0, 'melding', 'meldingen')).toBe('meldingen');
+ expect(plural(1, 'melding', 'meldingen')).toBe('melding');
+ expect(plural(2, 'melding', 'meldingen')).toBe('meldingen');
+ });
+});
diff --git a/apps/web/tests/unit/routing.test.tsx b/apps/web/tests/unit/routing.test.tsx
new file mode 100644
index 0000000..894c1de
--- /dev/null
+++ b/apps/web/tests/unit/routing.test.tsx
@@ -0,0 +1,24 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import App from '../../src/App';
+import { routeFromLocation } from '../../src/routes';
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState({}, '', '/'); });
+
+describe('expliciete routing', () => {
+ it('behoudt de Events-route en projecteert onbekende adressen op 404', () => {
+ expect(routeFromLocation('/events')).toBe('/events');
+ expect(routeFromLocation('/bestaat-niet')).toBe('/404');
+ });
+
+ it('toont een toegankelijke not-foundpagina in plaats van het overzicht', async () => {
+ window.history.replaceState({}, '', '/bestaat-niet');
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'unknown', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+ render( );
+
+ expect(await screen.findByRole('heading', { level: 1, name: 'Deze pagina bestaat niet' })).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Naar overzicht' })).toHaveAttribute('href', '/');
+ expect(screen.queryByRole('heading', { name: 'Status nog niet bevestigd' })).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/systemStatus.test.ts b/apps/web/tests/unit/systemStatus.test.ts
new file mode 100644
index 0000000..fdbb0c9
--- /dev/null
+++ b/apps/web/tests/unit/systemStatus.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest';
+
+import { aggregateStatus, BACKUP_STALE_AFTER_SECONDS, backupPresentation, overviewTitle, STALE_AFTER_MS, statusProblems, type SystemStatus, type SystemStatusSnapshot } from '../../src/systemStatus';
+
+const observedAt = Date.parse('2026-08-10T04:00:00Z');
+
+function status(overrides: Partial = {}): SystemStatus {
+ return {
+ version: '1',
+ generatedAt: new Date(observedAt).toISOString(),
+ overallState: 'healthy',
+ components: [],
+ backup: { state: 'disabled', reason: 'not_configured' },
+ sourceLag: [],
+ ...overrides,
+ };
+}
+
+function ready(value: SystemStatus): SystemStatusSnapshot {
+ return { state: 'ready', status: value, fetchedAt: observedAt };
+}
+
+describe('aggregateStatus ADR-0008 invariant', () => {
+ it.each([
+ { state: 'loading', status: null, fetchedAt: 0 },
+ { state: 'error', status: null, fetchedAt: observedAt },
+ { state: 'unauthorized', status: null, fetchedAt: observedAt },
+ { state: 'forbidden', status: null, fetchedAt: observedAt },
+ ] satisfies SystemStatusSnapshot[])('maps $state without telemetry to Unknown', (snapshot) => {
+ expect(aggregateStatus(snapshot, observedAt)).toMatchObject({ state: 'unknown', tone: 'unknown', stale: false });
+ });
+
+ it('allows healthy only for a fresh ready payload that explicitly says healthy', () => {
+ expect(aggregateStatus(ready(status()), observedAt + 1_000)).toMatchObject({ state: 'healthy', tone: 'ready', stale: false });
+ });
+
+ it.each(['unknown', 'degraded', 'disabled'])('never maps %s to a ready tone', (overallState) => {
+ expect(aggregateStatus(ready(status({ overallState })), observedAt + 1_000).tone).toBe('unknown');
+ });
+
+ it('expires a formerly healthy payload to Unknown', () => {
+ expect(aggregateStatus(ready(status()), observedAt + STALE_AFTER_MS + 1)).toMatchObject({ state: 'unknown', tone: 'unknown', stale: true });
+ });
+});
+
+describe('statusProblems', () => {
+ it('keeps non-healthy signals bounded and omits disabled components', () => {
+ const value = status({
+ components: [
+ { id: 'database', state: 'healthy', reason: 'ok' },
+ { id: 'prometheus', state: 'unknown', reason: 'stale' },
+ { id: 'optional', state: 'disabled', reason: 'not_configured' },
+ ],
+ sourceLag: Array.from({ length: 12 }, (_, index) => ({ sourceId: `source-${index}`, state: 'unknown', reason: 'missing' })),
+ });
+ const problems = statusProblems(value);
+ expect(problems).toHaveLength(10);
+ expect(problems[0]).toEqual({ id: 'component:prometheus', label: 'Prometheus', reason: 'De laatste meting is verouderd; controleer de bronverbinding en collector.' });
+ expect(problems.some((problem) => problem.label === 'optional')).toBe(false);
+ });
+
+ it('does not repeat a source that is already represented by its component', () => {
+ const value = status({
+ components: [{ id: 'prometheus', state: 'unknown', reason: 'stale' }],
+ sourceLag: [{ sourceId: 'prometheus', state: 'unknown', reason: 'stale' }],
+ });
+ expect(statusProblems(value)).toEqual([{ id: 'component:prometheus', label: 'Prometheus', reason: 'De laatste meting is verouderd; controleer de bronverbinding en collector.' }]);
+ });
+
+ it('keeps a disabled required source actionable while omitting optional disabled features', () => {
+ const value = status({ components: [
+ { id: 'unraid', state: 'disabled', reason: 'not_configured' },
+ { id: 'notifications', state: 'disabled', reason: 'not_configured' },
+ ] });
+ expect(statusProblems(value)).toEqual([{ id: 'component:unraid', label: 'Unraid', reason: 'Dit onderdeel is nog niet geconfigureerd; open de instellingen om het te activeren.' }]);
+ });
+
+ it('promotes an old allegedly healthy backup to an actionable problem', () => {
+ const value = status({ backup: { state: 'healthy', reason: 'backup_verified', ageSeconds: BACKUP_STALE_AFTER_SECONDS + 1 } });
+ expect(statusProblems(value)).toEqual([{ id: 'backup', label: 'Backupstatus', reason: 'De laatste geverifieerde backup is verlopen; maak en verifieer een nieuwe backup.' }]);
+ });
+});
+
+describe('backupPresentation', () => {
+ it('allows healthy only while a verified backup is within the 24-hour boundary', () => {
+ expect(backupPresentation({ state: 'healthy', reason: 'backup_verified', ageSeconds: 3600 })).toMatchObject({ state: 'healthy', reason: 'backup_verified' });
+ expect(backupPresentation({ state: 'healthy', reason: 'backup_verified', ageSeconds: BACKUP_STALE_AFTER_SECONDS + 1 })).toMatchObject({ state: 'degraded', reason: 'backup_stale' });
+ });
+
+ it('fails closed when a healthy claim has no age or timestamp', () => {
+ expect(backupPresentation({ state: 'healthy', reason: 'backup_verified' })).toMatchObject({ state: 'unknown', reason: 'no_verified_backup' });
+ });
+});
+
+describe('overviewTitle', () => {
+ it('reserves the optimistic title for a healthy issue-free snapshot', () => {
+ const healthy = aggregateStatus(ready(status()), observedAt + 1_000);
+ expect(overviewTitle(healthy, 0)).toBe('Alles onder controle');
+ expect(overviewTitle(healthy, 1)).toBe('Aandacht vereist');
+ expect(overviewTitle(healthy, 0, true)).toBe('Aandacht vereist');
+ });
+
+ it('uses a conservative title for unknown telemetry', () => {
+ const unknown = aggregateStatus(ready(status({ overallState: 'unknown' })), observedAt + 1_000);
+ expect(overviewTitle(unknown, 0)).toBe('Status nog niet bevestigd');
+ });
+
+ it('does not claim that no sources exist when an unknown status has live sources', () => {
+ const unknown = aggregateStatus(ready(status({ overallState: 'unknown', sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh' }] })), observedAt + 1_000);
+ expect(unknown.detail).toContain('Databronnen zijn verbonden');
+ });
+});
diff --git a/apps/web/tests/unit/useLiveMetric.test.tsx b/apps/web/tests/unit/useLiveMetric.test.tsx
new file mode 100644
index 0000000..9a15987
--- /dev/null
+++ b/apps/web/tests/unit/useLiveMetric.test.tsx
@@ -0,0 +1,54 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { LiveClient } from '../../src/liveClient';
+import type { LiveSample } from '../../src/liveBuffer';
+import type { MetricQueryRequest } from '../../src/metricClient';
+import { useLiveMetric } from '../../src/useLiveMetric';
+
+const request: MetricQueryRequest = {
+ metric: 'host.cpu.utilization',
+ range: { from: '2026-08-10T06:00:00.000Z', to: '2026-08-10T06:05:00.000Z', stepSeconds: 15 },
+ aggregation: 'avg',
+};
+
+function sample(timestamp: string, value: number): LiveSample {
+ return { series: 'cpu', timestamp, value, freshness: 'fresh' };
+}
+
+describe('useLiveMetric lifecycle', () => {
+ it('updates historical seed data without recreating an unchanged live subscription', () => {
+ const unsubscribe = vi.fn();
+ const subscribe = vi.fn(() => ({ key: 'cpu', unsubscribe }));
+ const releaseUnused = vi.fn();
+ const client = { subscribe, releaseUnused } as unknown as LiveClient;
+
+ function Harness({ initial, query = request }: { initial: LiveSample[]; query?: MetricQueryRequest }) {
+ useLiveMetric(client, query, initial);
+ return null;
+ }
+
+ const view = render( );
+ expect(subscribe).toHaveBeenCalledTimes(1);
+
+ view.rerender( );
+
+ expect(subscribe).toHaveBeenCalledTimes(1);
+ expect(unsubscribe).not.toHaveBeenCalled();
+
+ view.rerender( );
+
+ expect(subscribe).toHaveBeenCalledTimes(1);
+ expect(unsubscribe).not.toHaveBeenCalled();
+
+ view.unmount();
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+ expect(releaseUnused).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/tests/unit/wallboardLayout.test.ts b/apps/web/tests/unit/wallboardLayout.test.ts
new file mode 100644
index 0000000..87f7bc5
--- /dev/null
+++ b/apps/web/tests/unit/wallboardLayout.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+
+import { wallboardPlacement, wallboardSlideIndex } from '../../src/wallboardLayout';
+
+describe('wallboard viewport layout', () => {
+ it('splits the default 19-row layout into two deterministic 1080p slides', () => {
+ expect([0, 6, 12, 13, 18].map(wallboardSlideIndex)).toEqual([0, 0, 0, 1, 1]);
+ });
+
+ it('keeps every widget inside the 24 by 13 slide grid', () => {
+ expect(wallboardPlacement({ x: 23, y: 12, w: 12, h: 8 })).toEqual({ columnStart: 24, columnSpan: 1, rowStart: 13, rowSpan: 1 });
+ expect(wallboardPlacement({ x: -4, y: 13, w: 0, h: 0 })).toEqual({ columnStart: 1, columnSpan: 1, rowStart: 1, rowSpan: 1 });
+ });
+
+ it('uses safe defaults for malformed coordinates', () => {
+ expect(wallboardSlideIndex('not-a-row')).toBe(0);
+ expect(wallboardPlacement({ x: Number.POSITIVE_INFINITY, y: null, w: 'wide', h: -2 }))
+ .toEqual({ columnStart: 1, columnSpan: 6, rowStart: 1, rowSpan: 1 });
+ });
+});
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..062c153
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src", "tests", "vite.config.ts", "playwright.config.ts"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..325d3d2
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./tests/setup.ts'],
+ include: ['./tests/unit/**/*.test.{ts,tsx}'],
+ restoreMocks: true,
+ clearMocks: true,
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json-summary'],
+ include: ['src/MetricWidgets.tsx', 'src/systemStatus.ts'],
+ },
+ },
+ build: {
+ rollupOptions: {
+ output: {
+ // The React runtime is stable across deploys and shared by every route,
+ // so it is worth its own long-lived chunk. Route-level splitting is
+ // driven by React.lazy in App.tsx.
+ manualChunks(id: string) {
+ if (id.includes('/node_modules/react-dom/') || id.includes('/node_modules/react/') || id.includes('/node_modules/scheduler/')) return 'react-vendor';
+ return undefined;
+ },
+ },
+ },
+ },
+});
diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go
new file mode 100644
index 0000000..1382953
--- /dev/null
+++ b/cmd/agent/agent.go
@@ -0,0 +1,372 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentprotocol"
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/array"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/disk"
+ "github.com/itworx/pulse/internal/host"
+ "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/process"
+ "github.com/itworx/pulse/internal/runtimeconfig"
+ "github.com/itworx/pulse/internal/share"
+)
+
+const (
+ // maxLoopInterval bounds one scheduling iteration so the heartbeat is refreshed at
+ // least every 10 seconds even when collection is configured to run far less often.
+ // See docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md point 3.
+ maxLoopInterval = 5 * time.Second
+ // operationTimeout bounds every blocking call inside one iteration — collection and
+ // the database write. It sits well under the 10 second heartbeat window so a hung
+ // database cannot stall the loop into a false "unhealthy" restart, and equally
+ // cannot hide a real hang: the call is abandoned and reported.
+ operationTimeout = 4 * time.Second
+ // heartbeatFileMode keeps the liveness file readable only by the agent's own user;
+ // the healthcheck script runs as the same user.
+ heartbeatFileMode = 0o600
+)
+
+// snapshotSource is the narrow view of the collector the runtime needs. It keeps the
+// loop testable without a procfs tree.
+type snapshotSource interface {
+ Host(context.Context) (host.RawSnapshot, error)
+ Processes(context.Context) (process.RawSnapshot, error)
+}
+
+type containerSnapshotSource interface {
+ Containers(context.Context) (container.RawSnapshot, error)
+}
+type arraySnapshotSource interface {
+ Array(context.Context) (array.RawSnapshot, error)
+}
+type diskSnapshotSource interface {
+ Disks(context.Context) (disk.RawSnapshot, error)
+}
+type poolSnapshotSource interface {
+ Pools(context.Context) (pool.RawSnapshot, error)
+}
+type shareSnapshotSource interface {
+ Shares(context.Context) (share.RawSnapshot, error)
+}
+
+// capability binds one telemetry surface to the collection that produces it. Every
+// capability the agent announces is read-only; there is no write path in this binary.
+type capability struct {
+ id agentstore.Capability
+ version string
+ collect func(context.Context) (json.RawMessage, time.Time, error)
+}
+
+// tickerFactory produces the loop's tick channel. Tests replace it with a channel they
+// drive by hand so loop behaviour is asserted without sleeping.
+type tickerFactory func(time.Duration) (<-chan time.Time, func())
+
+type agent struct {
+ agentID string
+ writer agentstore.Writer
+ logger *slog.Logger
+ now func() time.Time
+ collectInterval time.Duration
+ loopInterval time.Duration
+ operationTimeout time.Duration
+ heartbeatPath string
+ capabilities []capability
+ newTicker tickerFactory
+
+ // nextCollect is the earliest time the next collection pass may run. It is only
+ // touched from the loop goroutine.
+ nextCollect time.Time
+}
+
+func realTicker(interval time.Duration) (<-chan time.Time, func()) {
+ ticker := time.NewTicker(interval)
+ return ticker.C, ticker.Stop
+}
+
+func newAgent(config runtimeconfig.AgentConfig, source snapshotSource, writer agentstore.Writer, logger *slog.Logger) *agent {
+ loopInterval := config.CollectInterval
+ if loopInterval > maxLoopInterval {
+ loopInterval = maxLoopInterval
+ }
+ return &agent{
+ agentID: config.AgentID,
+ writer: writer,
+ logger: logger,
+ now: time.Now,
+ collectInterval: config.CollectInterval,
+ loopInterval: loopInterval,
+ operationTimeout: operationTimeout,
+ heartbeatPath: config.Service.HeartbeatFile,
+ capabilities: capabilities(source),
+ newTicker: realTicker,
+ }
+}
+
+// capabilities lists what this agent reports. Adding a capability here is the only way
+// to widen what the agent reads, which keeps the surface auditable.
+func capabilities(source snapshotSource) []capability {
+ result := []capability{
+ {
+ id: agentstore.CapabilityHost,
+ version: host.ContractVersion,
+ collect: func(ctx context.Context) (json.RawMessage, time.Time, error) {
+ snapshot, err := source.Host(ctx)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ payload, err := json.Marshal(snapshot)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ return payload, snapshot.ObservedAt, nil
+ },
+ },
+ {
+ id: agentstore.CapabilityProcesses,
+ version: process.ContractVersion,
+ collect: func(ctx context.Context) (json.RawMessage, time.Time, error) {
+ snapshot, err := source.Processes(ctx)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ payload, err := json.Marshal(snapshot)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ return payload, snapshot.ObservedAt, nil
+ },
+ },
+ }
+ if containers, ok := source.(containerSnapshotSource); ok {
+ result = append(result, capability{
+ id: agentstore.CapabilityContainers, version: container.ContractVersion,
+ collect: func(ctx context.Context) (json.RawMessage, time.Time, error) {
+ snapshot, err := containers.Containers(ctx)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ payload, err := json.Marshal(snapshot)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ return payload, snapshot.ObservedAt, nil
+ },
+ })
+ }
+ if source, ok := source.(arraySnapshotSource); ok {
+ result = append(result, rawCapability(agentstore.CapabilityArray, array.ContractVersion, source.Array))
+ }
+ if source, ok := source.(diskSnapshotSource); ok {
+ result = append(result, rawCapability(agentstore.CapabilityDisks, disk.ContractVersion, source.Disks))
+ }
+ if source, ok := source.(poolSnapshotSource); ok {
+ result = append(result, rawCapability(agentstore.CapabilityPools, pool.ContractVersion, source.Pools))
+ }
+ if source, ok := source.(shareSnapshotSource); ok {
+ result = append(result, rawCapability(agentstore.CapabilityShares, share.ContractVersion, source.Shares))
+ }
+ return result
+}
+
+func rawCapability[T any](id agentstore.Capability, version string, collect func(context.Context) (T, error)) capability {
+ return capability{id: id, version: version, collect: func(ctx context.Context) (json.RawMessage, time.Time, error) {
+ snapshot, err := collect(ctx)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ payload, err := json.Marshal(snapshot)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ observed := observedAt(snapshot)
+ return payload, observed, nil
+ }}
+}
+
+// All raw snapshot contracts carry ObservedAt. Keep this tiny type assertion local to
+// the agent rather than introducing a repository-wide generic telemetry abstraction.
+func observedAt(snapshot any) time.Time {
+ switch value := snapshot.(type) {
+ case array.RawSnapshot:
+ return value.ObservedAt
+ case disk.RawSnapshot:
+ return value.ObservedAt
+ case pool.RawSnapshot:
+ return value.ObservedAt
+ case share.RawSnapshot:
+ return value.ObservedAt
+ default:
+ return time.Time{}
+ }
+}
+
+// hello is the capability announcement. Every entry is read-only, which is not a
+// decoration: agentprotocol.Hello.Validate rejects a hello that claims anything else,
+// so the agent's own start-up self-check fails loudly if a mutating capability is ever
+// added here by mistake.
+func (a *agent) hello() agentprotocol.Hello {
+ announced := make([]agentprotocol.Capability, 0, len(a.capabilities))
+ for _, item := range a.capabilities {
+ announced = append(announced, agentprotocol.Capability{
+ ID: string(item.id),
+ Version: item.version,
+ ReadOnly: true,
+ })
+ }
+ return agentprotocol.Hello{
+ Protocol: agentprotocol.Version,
+ AgentID: a.agentID,
+ ObservedAt: a.now().UTC(),
+ Capabilities: announced,
+ }
+}
+
+// run drives the collection loop until the context is cancelled.
+//
+// The order is fixed by the healthcheck contract: validate, log, write one heartbeat
+// before the first blocking call, then loop. Each iteration does its bounded work and
+// ends by refreshing the heartbeat, so a stuck iteration stops the heartbeat instead of
+// a background ticker papering over the stall.
+func (a *agent) run(ctx context.Context) error {
+ if a.writer == nil {
+ return errNoWriter
+ }
+ hello := a.hello()
+ if err := hello.Validate(a.now().UTC()); err != nil {
+ return fmt.Errorf("agent hello failed its own read-only validation: %w", err)
+ }
+ announced := make([]string, 0, len(hello.Capabilities))
+ for _, item := range hello.Capabilities {
+ announced = append(announced, item.ID)
+ }
+ a.logger.Info("pulse agent started",
+ "agent_id", a.agentID,
+ "protocol", hello.Protocol,
+ "capabilities", announced,
+ "read_only", true,
+ "collect_interval", a.collectInterval.String(),
+ "loop_interval", a.loopInterval.String(),
+ "operation_timeout", a.operationTimeout.String(),
+ "heartbeat_file", a.heartbeatPath,
+ )
+
+ // Contract point 4: a slow-but-healthy cold start must not look like a hang.
+ a.heartbeat()
+
+ ticks, stop := a.newTicker(a.loopInterval)
+ defer stop()
+
+loop:
+ for ctx.Err() == nil {
+ a.iterate(ctx)
+ select {
+ case <-ctx.Done():
+ break loop
+ case <-ticks:
+ }
+ }
+ a.logger.Info("pulse agent stopped", "agent_id", a.agentID)
+ return nil
+}
+
+// iterate is one unit of work: collect when due, then heartbeat. An empty iteration —
+// nothing due yet — is still a completed iteration and still heartbeats.
+func (a *agent) iterate(ctx context.Context) {
+ now := a.now()
+ if !now.Before(a.nextCollect) {
+ a.collectOnce(ctx)
+ a.nextCollect = a.now().Add(a.collectInterval)
+ }
+ a.heartbeat()
+}
+
+// collectOnce publishes every capability independently. One failing capability is
+// logged and skipped; it neither aborts the pass nor causes a stale or empty snapshot
+// to be written in its place. A missing snapshot is exactly what the reader turns into
+// Unknown (ADR-0008), which is the honest outcome.
+func (a *agent) collectOnce(ctx context.Context) {
+ for _, item := range a.capabilities {
+ if ctx.Err() != nil {
+ return
+ }
+ started := a.now()
+ bytesWritten, err := a.publish(ctx, item)
+ if err != nil {
+ a.logger.Error("agent capability collection failed",
+ "agent_id", a.agentID,
+ "capability", string(item.id),
+ "duration_ms", a.now().Sub(started).Milliseconds(),
+ "error", err.Error(),
+ )
+ continue
+ }
+ a.logger.Info("agent snapshot published",
+ "agent_id", a.agentID,
+ "capability", string(item.id),
+ "payload_bytes", bytesWritten,
+ "duration_ms", a.now().Sub(started).Milliseconds(),
+ )
+ }
+}
+
+// publish collects and writes one capability under a bounded context.
+func (a *agent) publish(ctx context.Context, item capability) (int, error) {
+ operation, cancel := context.WithTimeout(ctx, a.operationTimeout)
+ defer cancel()
+
+ payload, observedAt, err := item.collect(operation)
+ if err != nil {
+ return 0, fmt.Errorf("collect %s: %w", item.id, err)
+ }
+ if len(payload) == 0 {
+ return 0, fmt.Errorf("collect %s: empty payload", item.id)
+ }
+ if len(payload) > agentstore.MaxPayloadBytes {
+ // Writing a truncated snapshot would be worse than writing none: the reader
+ // cannot tell a truncated payload from a complete one.
+ return 0, fmt.Errorf("collect %s: payload of %d bytes exceeds the %d byte limit", item.id, len(payload), agentstore.MaxPayloadBytes)
+ }
+ if observedAt.IsZero() {
+ observedAt = a.now().UTC()
+ }
+ if err := a.writer.Put(operation, agentstore.Snapshot{
+ AgentID: a.agentID,
+ Capability: item.id,
+ ObservedAt: observedAt.UTC(),
+ Payload: payload,
+ }); err != nil {
+ return 0, fmt.Errorf("publish %s: %w", item.id, err)
+ }
+ return len(payload), nil
+}
+
+// heartbeat refreshes the liveness file the compose healthcheck watches. Only the mtime
+// matters to the script; the RFC 3339 body exists so `docker exec … cat /tmp/healthy`
+// tells an operator something. A write failure is logged and the loop continues:
+// crashing on a tmpfs hiccup would turn a cosmetic problem into an outage, and a
+// sustained failure surfaces on its own as staleness.
+func (a *agent) heartbeat() {
+ if a.heartbeatPath == "" {
+ return
+ }
+ content := a.now().UTC().Format(time.RFC3339) + "\n"
+ if err := os.WriteFile(a.heartbeatPath, []byte(content), heartbeatFileMode); err != nil {
+ a.logger.Error("agent heartbeat write failed",
+ "agent_id", a.agentID,
+ "heartbeat_file", a.heartbeatPath,
+ "error", err.Error(),
+ )
+ }
+}
+
+var errNoWriter = errors.New("agent snapshot writer is required")
diff --git a/cmd/agent/agent_test.go b/cmd/agent/agent_test.go
new file mode 100644
index 0000000..8a8fba2
--- /dev/null
+++ b/cmd/agent/agent_test.go
@@ -0,0 +1,543 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/array"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/disk"
+ "github.com/itworx/pulse/internal/host"
+ "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/process"
+ "github.com/itworx/pulse/internal/runtimeconfig"
+ "github.com/itworx/pulse/internal/share"
+)
+
+// fakeWriter records what the agent publishes and can fail a chosen capability.
+type fakeWriter struct {
+ mu sync.Mutex
+ puts []agentstore.Snapshot
+ deadlines []bool
+ failures map[agentstore.Capability]error
+ blockUntil chan struct{}
+}
+
+func newFakeWriter() *fakeWriter {
+ return &fakeWriter{failures: map[agentstore.Capability]error{}}
+}
+
+func (w *fakeWriter) Put(ctx context.Context, snapshot agentstore.Snapshot) error {
+ w.mu.Lock()
+ failure := w.failures[snapshot.Capability]
+ block := w.blockUntil
+ w.mu.Unlock()
+ if block != nil {
+ select {
+ case <-block:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+ _, hasDeadline := ctx.Deadline()
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.deadlines = append(w.deadlines, hasDeadline)
+ if failure != nil {
+ return failure
+ }
+ w.puts = append(w.puts, snapshot)
+ return nil
+}
+
+func (w *fakeWriter) recorded() []agentstore.Snapshot {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return append([]agentstore.Snapshot(nil), w.puts...)
+}
+
+func (w *fakeWriter) countFor(capability agentstore.Capability) int {
+ count := 0
+ for _, snapshot := range w.recorded() {
+ if snapshot.Capability == capability {
+ count++
+ }
+ }
+ return count
+}
+
+func (w *fakeWriter) failCapability(capability agentstore.Capability, err error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.failures[capability] = err
+}
+
+// fakeSource stands in for the procfs collector.
+type fakeSource struct {
+ mu sync.Mutex
+ hostErr error
+ processErr error
+ hostCalls int
+ procCalls int
+ release chan struct{}
+ hugePayload bool
+}
+
+type fakeContainerSource struct{ *fakeSource }
+
+func (s fakeContainerSource) Containers(context.Context) (container.RawSnapshot, error) {
+ return container.RawSnapshot{Source: container.Source{ID: "unraid", Type: "unraid"}, Containers: []container.RawContainer{{ID: "runtime-1", Name: "pulse-api", State: "running", Health: "healthy"}}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
+}
+
+type fakeInventorySource struct{ *fakeSource }
+
+func (s fakeInventorySource) Containers(context.Context) (container.RawSnapshot, error) {
+ return container.RawSnapshot{Source: container.Source{ID: "unraid", Type: "unraid"}, Containers: []container.RawContainer{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
+}
+func (s fakeInventorySource) Array(context.Context) (array.RawSnapshot, error) {
+ return array.RawSnapshot{Source: array.Source{ID: "unraid", Type: "unraid"}, State: array.StateOperational, Members: []array.RawMember{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
+}
+func (s fakeInventorySource) Disks(context.Context) (disk.RawSnapshot, error) {
+ return disk.RawSnapshot{Source: disk.Source{ID: "unraid", Type: "unraid"}, Disks: []disk.RawDisk{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
+}
+func (s fakeInventorySource) Pools(context.Context) (pool.RawSnapshot, error) {
+ return pool.RawSnapshot{Source: pool.Source{ID: "unraid", Type: "unraid"}, Pools: []pool.RawPool{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
+}
+func (s fakeInventorySource) Shares(context.Context) (share.RawSnapshot, error) {
+ return share.RawSnapshot{Source: share.Source{ID: "unraid", Type: "unraid"}, Shares: []share.RawShare{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
+}
+
+func (s *fakeSource) Host(ctx context.Context) (host.RawSnapshot, error) {
+ s.mu.Lock()
+ release := s.release
+ err := s.hostErr
+ s.hostCalls++
+ huge := s.hugePayload
+ s.mu.Unlock()
+ if release != nil {
+ select {
+ case <-release:
+ case <-ctx.Done():
+ return host.RawSnapshot{}, ctx.Err()
+ }
+ }
+ if err != nil {
+ return host.RawSnapshot{}, err
+ }
+ snapshot := host.RawSnapshot{
+ Identity: host.HostIdentity{Name: "tower"},
+ UptimeSeconds: 100,
+ Memory: host.RawMemory{TotalBytes: 1024, AvailableBytes: 512},
+ ObservedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC),
+ }
+ if huge {
+ warning := strings.Repeat("x", agentstore.MaxPayloadBytes)
+ snapshot.Warnings = []string{warning}
+ }
+ return snapshot, nil
+}
+
+func (s *fakeSource) Processes(context.Context) (process.RawSnapshot, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.procCalls++
+ if s.processErr != nil {
+ return process.RawSnapshot{}, s.processErr
+ }
+ return process.RawSnapshot{
+ Processes: []process.RawProcess{{PID: 1, Name: "init", State: "sleeping"}},
+ ObservedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC),
+ }, nil
+}
+
+func (s *fakeSource) calls() (int, int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.hostCalls, s.procCalls
+}
+
+type testClock struct {
+ mu sync.Mutex
+ current time.Time
+ // step advances the clock on every read. A loop test needs strictly increasing
+ // timestamps, otherwise two iterations can observe the same instant and the second
+ // one is legitimately "not due yet".
+ step time.Duration
+}
+
+func (c *testClock) now() time.Time {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ current := c.current
+ c.current = c.current.Add(c.step)
+ return current
+}
+
+func (c *testClock) setStep(step time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.step = step
+}
+
+func (c *testClock) advance(d time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.current = c.current.Add(d)
+}
+
+func newTestAgent(t *testing.T, source snapshotSource, writer agentstore.Writer, collectInterval time.Duration) (*agent, *testClock, string) {
+ t.Helper()
+ heartbeat := filepath.Join(t.TempDir(), "healthy")
+ config := runtimeconfig.AgentConfig{
+ AgentID: "pulse-agent-test",
+ CollectInterval: collectInterval,
+ Service: runtimeconfig.ServiceConfig{ServiceName: "agent", HeartbeatFile: heartbeat},
+ }
+ logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
+ instance := newAgent(config, source, writer, logger)
+ clock := &testClock{current: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)}
+ instance.now = clock.now
+ return instance, clock, heartbeat
+}
+
+func readHeartbeat(t *testing.T, path string) string {
+ t.Helper()
+ data, err := os.ReadFile(path) //nolint:gosec // test-controlled path
+ if err != nil {
+ t.Fatalf("read heartbeat: %v", err)
+ }
+ return strings.TrimSpace(string(data))
+}
+
+func TestIterateCollectsOnTheIntervalAndHeartbeatsEveryIteration(t *testing.T) {
+ writer := newFakeWriter()
+ source := &fakeSource{}
+ // A 15 second collection interval must not stretch the loop: newAgent caps the
+ // loop at maxLoopInterval so the heartbeat stays inside the contract's 10s window.
+ instance, clock, heartbeat := newTestAgent(t, source, writer, 15*time.Second)
+ if instance.loopInterval != maxLoopInterval {
+ t.Fatalf("loop interval = %s, want %s", instance.loopInterval, maxLoopInterval)
+ }
+
+ ctx := context.Background()
+ for iteration := 0; iteration < 7; iteration++ {
+ instance.iterate(ctx)
+ if got := readHeartbeat(t, heartbeat); got != clock.now().UTC().Format(time.RFC3339) {
+ t.Fatalf("iteration %d heartbeat = %q, want the current time", iteration, got)
+ }
+ clock.advance(instance.loopInterval)
+ }
+
+ // Seven iterations five seconds apart cover t=0s..t=30s: collection is due at
+ // t=0s, t=15s and t=30s. The four iterations in between do no work, yet every one
+ // of them completed and refreshed the heartbeat, which is what the contract asks
+ // for ("an empty poll is still a completed iteration").
+ hostCalls, procCalls := source.calls()
+ if hostCalls != 3 || procCalls != 3 {
+ t.Fatalf("collections = host %d, processes %d; want 3 each over 30 seconds", hostCalls, procCalls)
+ }
+ if writer.countFor(agentstore.CapabilityHost) != 3 || writer.countFor(agentstore.CapabilityProcesses) != 3 {
+ t.Fatalf("unexpected publications: %+v", writer.recorded())
+ }
+}
+
+func TestOneFailingCapabilityDoesNotStopTheOthers(t *testing.T) {
+ writer := newFakeWriter()
+ source := &fakeSource{hostErr: errors.New("procfs read failed")}
+ instance, _, _ := newTestAgent(t, source, writer, time.Second)
+
+ instance.collectOnce(context.Background())
+
+ recorded := writer.recorded()
+ if len(recorded) != 1 || recorded[0].Capability != agentstore.CapabilityProcesses {
+ t.Fatalf("expected only the process snapshot, got %+v", recorded)
+ }
+
+ // The reverse case: the store rejects one capability.
+ writer.failCapability(agentstore.CapabilityProcesses, errors.New("store rejected"))
+ source.mu.Lock()
+ source.hostErr = nil
+ source.mu.Unlock()
+ instance.collectOnce(context.Background())
+
+ if writer.countFor(agentstore.CapabilityHost) != 1 {
+ t.Fatalf("host must still be published: %+v", writer.recorded())
+ }
+ if writer.countFor(agentstore.CapabilityProcesses) != 1 {
+ t.Fatalf("the rejected capability must not be recorded twice: %+v", writer.recorded())
+ }
+}
+
+func TestFailedCollectionWritesNothingRatherThanAnEmptySnapshot(t *testing.T) {
+ writer := newFakeWriter()
+ source := &fakeSource{hostErr: errors.New("boom"), processErr: errors.New("boom")}
+ instance, _, _ := newTestAgent(t, source, writer, time.Second)
+
+ instance.collectOnce(context.Background())
+
+ if recorded := writer.recorded(); len(recorded) != 0 {
+ t.Fatalf("a failed collection must publish nothing, got %+v", recorded)
+ }
+}
+
+func TestOversizedPayloadIsRefused(t *testing.T) {
+ writer := newFakeWriter()
+ source := &fakeSource{hugePayload: true}
+ instance, _, _ := newTestAgent(t, source, writer, time.Second)
+
+ if _, err := instance.publish(context.Background(), instance.capabilities[0]); err == nil {
+ t.Fatal("expected an oversized payload to be refused")
+ }
+ if writer.countFor(agentstore.CapabilityHost) != 0 {
+ t.Fatal("an oversized payload must never reach the store")
+ }
+}
+
+func TestPublishUsesABoundedContext(t *testing.T) {
+ writer := newFakeWriter()
+ instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
+
+ instance.collectOnce(context.Background())
+
+ writer.mu.Lock()
+ defer writer.mu.Unlock()
+ if len(writer.deadlines) == 0 {
+ t.Fatal("no writes recorded")
+ }
+ for index, bounded := range writer.deadlines {
+ if !bounded {
+ t.Fatalf("write %d ran without a deadline", index)
+ }
+ }
+}
+
+func TestPublishedSnapshotCarriesTheObservedTimeAndPayload(t *testing.T) {
+ writer := newFakeWriter()
+ instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
+
+ instance.collectOnce(context.Background())
+
+ for _, snapshot := range writer.recorded() {
+ if snapshot.AgentID != "pulse-agent-test" {
+ t.Fatalf("agent id = %q", snapshot.AgentID)
+ }
+ if !snapshot.Capability.Valid() {
+ t.Fatalf("unknown capability %q", snapshot.Capability)
+ }
+ if snapshot.ObservedAt.IsZero() || snapshot.ObservedAt.Location() != time.UTC {
+ t.Fatalf("observed at = %v", snapshot.ObservedAt)
+ }
+ var decoded map[string]any
+ if err := json.Unmarshal(snapshot.Payload, &decoded); err != nil {
+ t.Fatalf("payload is not a JSON object: %v", err)
+ }
+ }
+}
+
+func TestRunWritesTheFirstHeartbeatBeforeTheFirstBlockingCall(t *testing.T) {
+ writer := newFakeWriter()
+ release := make(chan struct{})
+ source := &fakeSource{release: release}
+ instance, clock, heartbeat := newTestAgent(t, source, writer, time.Second)
+ ticks := make(chan time.Time)
+ instance.newTicker = func(time.Duration) (<-chan time.Time, func()) { return ticks, func() {} }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- instance.run(ctx) }()
+
+ // The collector is blocked inside the first iteration, yet the heartbeat must
+ // already exist: a slow cold start is not a hang.
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ if _, err := os.Stat(heartbeat); err == nil {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("no heartbeat was written before the first blocking call")
+ }
+ time.Sleep(time.Millisecond)
+ }
+ if got := readHeartbeat(t, heartbeat); got != clock.now().UTC().Format(time.RFC3339) {
+ t.Fatalf("heartbeat = %q", got)
+ }
+
+ close(release)
+ cancel()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("run returned error: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("run did not return after cancellation")
+ }
+}
+
+func TestRunHeartbeatsFromTheLoopAndShutsDownCleanly(t *testing.T) {
+ writer := newFakeWriter()
+ source := &fakeSource{}
+ instance, clock, heartbeat := newTestAgent(t, source, writer, time.Second)
+ clock.setStep(time.Second)
+ start := clock.now()
+ ticks := make(chan time.Time)
+ instance.newTicker = func(time.Duration) (<-chan time.Time, func()) { return ticks, func() {} }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- instance.run(ctx) }()
+
+ // An unbuffered tick is only received once the previous iteration has finished, so
+ // each successful send proves one more completed loop iteration.
+ for iteration := 0; iteration < 3; iteration++ {
+ select {
+ case ticks <- time.Now():
+ case <-time.After(5 * time.Second):
+ t.Fatalf("iteration %d never completed", iteration)
+ }
+ }
+ if hostCalls, procCalls := source.calls(); hostCalls < 3 || procCalls < 3 {
+ t.Fatalf("collections = host %d, processes %d; want at least 3 each", hostCalls, procCalls)
+ }
+
+ cancel()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("run returned error: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("run did not stop on cancellation")
+ }
+
+ // Read the heartbeat only after the loop has stopped: the file is truncated and
+ // rewritten in place, so a concurrent reader can legitimately observe it empty.
+ // The healthcheck script reads only the mtime, which is why that is safe.
+ written, err := time.Parse(time.RFC3339, readHeartbeat(t, heartbeat))
+ if err != nil {
+ t.Fatalf("heartbeat content is not an RFC 3339 timestamp: %v", err)
+ }
+ if written.Before(start) {
+ t.Fatalf("heartbeat %s predates the loop start %s", written, start)
+ }
+}
+
+func TestRunDoesNotBlockShutdownOnAStuckWrite(t *testing.T) {
+ writer := newFakeWriter()
+ writer.blockUntil = make(chan struct{})
+ instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
+ ticks := make(chan time.Time)
+ instance.newTicker = func(time.Duration) (<-chan time.Time, func()) { return ticks, func() {} }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- instance.run(ctx) }()
+
+ time.Sleep(20 * time.Millisecond)
+ cancel()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("run returned error: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("a blocked store write must not hold up shutdown")
+ }
+}
+
+func TestRunRefusesToStartWhenTheHelloIsNotValid(t *testing.T) {
+ writer := newFakeWriter()
+ instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
+ instance.agentID = ""
+
+ if err := instance.run(context.Background()); err == nil {
+ t.Fatal("an agent whose own hello fails validation must refuse to start")
+ }
+ if len(writer.recorded()) != 0 {
+ t.Fatal("nothing may be published before the self-check passes")
+ }
+}
+
+func TestRunRequiresAWriter(t *testing.T) {
+ instance, _, _ := newTestAgent(t, &fakeSource{}, nil, time.Second)
+ instance.writer = nil
+ if err := instance.run(context.Background()); !errors.Is(err, errNoWriter) {
+ t.Fatalf("error = %v, want errNoWriter", err)
+ }
+}
+
+func TestHelloAnnouncesOnlyReadOnlyCapabilities(t *testing.T) {
+ instance, clock, _ := newTestAgent(t, &fakeSource{}, newFakeWriter(), time.Second)
+ hello := instance.hello()
+ if err := hello.Validate(clock.now()); err != nil {
+ t.Fatalf("hello validation failed: %v", err)
+ }
+ if len(hello.Capabilities) != 2 {
+ t.Fatalf("capabilities = %+v", hello.Capabilities)
+ }
+ for _, item := range hello.Capabilities {
+ if !item.ReadOnly {
+ t.Fatalf("capability %q is not read-only", item.ID)
+ }
+ if !agentstore.Capability(item.ID).Valid() {
+ t.Fatalf("capability %q is not a recognised store capability", item.ID)
+ }
+ }
+}
+
+func TestContainerCapabilityIsPublishedOnlyWhenSourceSupportsIt(t *testing.T) {
+ writer := newFakeWriter()
+ instance, _, _ := newTestAgent(t, fakeContainerSource{fakeSource: &fakeSource{}}, writer, time.Second)
+ if len(instance.hello().Capabilities) != 3 {
+ t.Fatalf("capabilities = %+v, want host/processes/containers", instance.hello().Capabilities)
+ }
+ instance.collectOnce(context.Background())
+ if writer.countFor(agentstore.CapabilityContainers) != 1 {
+ t.Fatalf("container snapshot was not published: %+v", writer.recorded())
+ }
+}
+
+func TestInventoryCapabilitiesArePublishedOnlyForConfiguredUnraidSource(t *testing.T) {
+ writer := newFakeWriter()
+ instance, _, _ := newTestAgent(t, fakeInventorySource{fakeSource: &fakeSource{}}, writer, time.Second)
+ if got := len(instance.hello().Capabilities); got != 7 {
+ t.Fatalf("capabilities = %+v, want host/processes/containers/array/disks/pools/shares", instance.hello().Capabilities)
+ }
+ instance.collectOnce(context.Background())
+ for _, capability := range []agentstore.Capability{agentstore.CapabilityContainers, agentstore.CapabilityArray, agentstore.CapabilityDisks, agentstore.CapabilityPools, agentstore.CapabilityShares} {
+ if writer.countFor(capability) != 1 {
+ t.Fatalf("capability %q was not published: %+v", capability, writer.recorded())
+ }
+ }
+}
+
+func TestHeartbeatFailureIsNotFatal(t *testing.T) {
+ writer := newFakeWriter()
+ instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
+ instance.heartbeatPath = filepath.Join(t.TempDir(), "missing-directory", "healthy")
+
+ instance.iterate(context.Background())
+
+ if len(writer.recorded()) == 0 {
+ t.Fatal("a heartbeat write failure must not stop the collection loop")
+ }
+}
+
+func TestSnapshotWriterRefusesAMissingPool(t *testing.T) {
+ // A nil pool would let every publish fail deep in the driver. The agent must refuse
+ // to start instead, so an operator sees the misconfiguration rather than an agent
+ // that appears healthy while storing nothing.
+ if _, err := newSnapshotWriter(nil); !errors.Is(err, errStoreNotLinked) {
+ t.Fatalf("error = %v, want errStoreNotLinked", err)
+ }
+}
diff --git a/cmd/agent/main.go b/cmd/agent/main.go
new file mode 100644
index 0000000..ab58de4
--- /dev/null
+++ b/cmd/agent/main.go
@@ -0,0 +1,157 @@
+// Command pulse-agent collects read-only host telemetry and publishes it as bounded
+// snapshots for pulse-api to read.
+//
+// It exposes no network port, holds no Docker socket, and performs no mutation: it
+// reads procfs/sysfs and writes one row per capability through agentstore.Writer. See
+// docs/architecture/SYSTEM_ARCHITECTURE.md ("pulse-agent"), ADR-0005 and
+// docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md.
+package main
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/itworx/pulse/internal/array"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/disk"
+ "github.com/itworx/pulse/internal/host"
+ "github.com/itworx/pulse/internal/hostcollect"
+ "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/process"
+ "github.com/itworx/pulse/internal/runtimeconfig"
+ "github.com/itworx/pulse/internal/share"
+ "github.com/itworx/pulse/internal/unraid"
+)
+
+type configuredSource struct {
+ host *hostcollect.Collector
+}
+
+func (s configuredSource) Host(ctx context.Context) (host.RawSnapshot, error) {
+ return s.host.Host(ctx)
+}
+func (s configuredSource) Processes(ctx context.Context) (process.RawSnapshot, error) {
+ return s.host.Processes(ctx)
+}
+
+type configuredUnraidSource struct {
+ configuredSource
+ containers interface {
+ Snapshot(context.Context) (container.RawSnapshot, error)
+ }
+ array interface {
+ Snapshot(context.Context) (array.RawSnapshot, error)
+ }
+ disks interface {
+ Snapshot(context.Context) (disk.RawSnapshot, error)
+ }
+ pools interface {
+ Snapshot(context.Context) (pool.RawSnapshot, error)
+ }
+ shares interface {
+ Snapshot(context.Context) (share.RawSnapshot, error)
+ }
+}
+
+func (s configuredUnraidSource) Containers(ctx context.Context) (container.RawSnapshot, error) {
+ if s.containers == nil {
+ return container.RawSnapshot{}, errUnraidSourceNotConfigured
+ }
+ return s.containers.Snapshot(ctx)
+}
+func (s configuredUnraidSource) Array(ctx context.Context) (array.RawSnapshot, error) {
+ if s.array == nil {
+ return array.RawSnapshot{}, errUnraidSourceNotConfigured
+ }
+ return s.array.Snapshot(ctx)
+}
+func (s configuredUnraidSource) Disks(ctx context.Context) (disk.RawSnapshot, error) {
+ if s.disks == nil {
+ return disk.RawSnapshot{}, errUnraidSourceNotConfigured
+ }
+ return s.disks.Snapshot(ctx)
+}
+func (s configuredUnraidSource) Pools(ctx context.Context) (pool.RawSnapshot, error) {
+ if s.pools == nil {
+ return pool.RawSnapshot{}, errUnraidSourceNotConfigured
+ }
+ return s.pools.Snapshot(ctx)
+}
+func (s configuredUnraidSource) Shares(ctx context.Context) (share.RawSnapshot, error) {
+ if s.shares == nil {
+ return share.RawSnapshot{}, errUnraidSourceNotConfigured
+ }
+ return s.shares.Snapshot(ctx)
+}
+
+var errUnraidSourceNotConfigured = errors.New("Unraid source is not configured")
+
+func main() {
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
+ if err := run(logger); err != nil {
+ logger.Error("pulse agent failed", "error", err.Error())
+ os.Exit(1)
+ }
+}
+
+func run(logger *slog.Logger) error {
+ config, err := runtimeconfig.LoadAgent()
+ if err != nil {
+ return err
+ }
+ collector, err := hostcollect.New(hostcollect.Options{
+ ProcRoot: config.ProcRoot,
+ SysRoot: config.SysRoot,
+ FilesystemRoot: config.FilesystemRoot,
+ HostName: config.HostName,
+ SourceID: "host",
+ ProcessLimits: process.Limits{MaxRows: config.MaxProcesses},
+ })
+ if err != nil {
+ return err
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ writer, closeStore, err := openStore(ctx, config)
+ if err != nil {
+ return err
+ }
+ defer closeStore()
+
+ logger.Info("pulse agent configuration loaded",
+ "agent_id", config.AgentID,
+ "proc_root", config.ProcRoot,
+ "sys_root", config.SysRoot,
+ "filesystem_root", config.FilesystemRoot,
+ "collect_interval", config.CollectInterval.String(),
+ "shutdown_timeout", config.Service.ShutdownAfter.String(),
+ )
+ var source snapshotSource = configuredSource{host: collector}
+ if config.UnraidURL != "" {
+ var client *unraid.Client
+ if config.UnraidCAFile == "" {
+ client, err = unraid.New(config.UnraidURL, config.UnraidAPIToken, nil)
+ } else {
+ info, statErr := os.Stat(config.UnraidCAFile)
+ if statErr != nil || info.Size() <= 0 || info.Size() > 1<<20 {
+ return errors.New("PULSE_UNRAID_CA_FILE must be a readable certificate no larger than 1 MiB")
+ }
+ caPEM, readErr := os.ReadFile(config.UnraidCAFile)
+ if readErr != nil {
+ return errors.New("read PULSE_UNRAID_CA_FILE")
+ }
+ client, err = unraid.NewWithCAPEM(config.UnraidURL, config.UnraidAPIToken, caPEM)
+ }
+ if err != nil {
+ return err
+ }
+ source = configuredUnraidSource{configuredSource: configuredSource{host: collector}, containers: unraid.ContainerSource{Client: client}, array: unraid.ArraySource{Client: client}, disks: unraid.DiskSource{Client: client}, pools: unraid.PoolSource{Client: client}, shares: unraid.ShareSource{Client: client}}
+ }
+ return newAgent(config, source, writer, logger).run(ctx)
+}
diff --git a/cmd/agent/store.go b/cmd/agent/store.go
new file mode 100644
index 0000000..c435849
--- /dev/null
+++ b/cmd/agent/store.go
@@ -0,0 +1,70 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/database"
+ "github.com/itworx/pulse/internal/runtimeconfig"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+const (
+ // storeConnectTimeout bounds the start-up reachability check. The healthcheck
+ // contract wants the first heartbeat written after the database is reachable, so
+ // this must stay far below the 45 second staleness threshold.
+ storeConnectTimeout = 5 * time.Second
+ // agentPoolMaxConns keeps the agent's footprint on the shared database small: it
+ // issues one small write per capability per interval and never reads.
+ agentPoolMaxConns = 2
+)
+
+// errStoreNotLinked is returned by every write while no snapshot store implementation
+// is compiled into this binary. It is deliberately an error on the write path rather
+// than a silent drop: the reader turns a missing snapshot into Unknown, and the agent
+// log states plainly why nothing arrives.
+var errStoreNotLinked = errors.New("no agent snapshot store is linked into this build")
+
+// openStore connects to PostgreSQL and returns the narrow Writer the agent publishes
+// through, plus a close function.
+//
+// The agent depends on the agentstore.Writer interface only; the concrete PostgreSQL
+// store owns migration 0016 and the agent_snapshots table.
+func openStore(ctx context.Context, config runtimeconfig.AgentConfig) (agentstore.Writer, func(), error) {
+ pool, err := database.NewPool(ctx, database.Config{
+ URL: config.DatabaseURL,
+ MaxConns: agentPoolMaxConns,
+ MinConns: 1,
+ })
+ if err != nil {
+ // Never wrap the URL itself into the error: it carries the database password.
+ return nil, nil, errors.New("agent database pool could not be created")
+ }
+ reachable, cancel := context.WithTimeout(ctx, storeConnectTimeout)
+ defer cancel()
+ if err := database.Ping(reachable, pool); err != nil {
+ pool.Close()
+ return nil, nil, fmt.Errorf("agent database is unreachable: %w", err)
+ }
+ writer, err := newSnapshotWriter(pool)
+ if err != nil {
+ pool.Close()
+ return nil, nil, err
+ }
+ return writer, pool.Close, nil
+}
+
+// newSnapshotWriter builds the store the agent writes through. A nil pool would make
+// every publish fail silently at the driver, so it is refused here instead: the agent
+// must either have a real store or fail to start.
+func newSnapshotWriter(pool *pgxpool.Pool) (agentstore.Writer, error) {
+ if pool == nil {
+ return nil, errStoreNotLinked
+ }
+ return agentstore.PostgresStore{Pool: pool}, nil
+}
+
+var _ agentstore.Writer = agentstore.PostgresStore{}
diff --git a/cmd/api/main.go b/cmd/api/main.go
new file mode 100644
index 0000000..ea428ad
--- /dev/null
+++ b/cmd/api/main.go
@@ -0,0 +1,569 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentsource"
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/alertapi"
+ "github.com/itworx/pulse/internal/alertcontrol"
+ "github.com/itworx/pulse/internal/alertcontrolapi"
+ "github.com/itworx/pulse/internal/alertdefaults"
+ "github.com/itworx/pulse/internal/alertopsapi"
+ "github.com/itworx/pulse/internal/applicationapi"
+ "github.com/itworx/pulse/internal/array"
+ "github.com/itworx/pulse/internal/arrayapi"
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/authapi"
+ "github.com/itworx/pulse/internal/backup"
+ "github.com/itworx/pulse/internal/backupapi"
+ "github.com/itworx/pulse/internal/config"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/containerapi"
+ "github.com/itworx/pulse/internal/correlation"
+ "github.com/itworx/pulse/internal/dashboard"
+ "github.com/itworx/pulse/internal/dashboardapi"
+ "github.com/itworx/pulse/internal/database"
+ "github.com/itworx/pulse/internal/disk"
+ "github.com/itworx/pulse/internal/diskapi"
+ "github.com/itworx/pulse/internal/eventapi"
+ forecastdomain "github.com/itworx/pulse/internal/forecast"
+ "github.com/itworx/pulse/internal/forecastapi"
+ "github.com/itworx/pulse/internal/host"
+ "github.com/itworx/pulse/internal/hostapi"
+ "github.com/itworx/pulse/internal/incident"
+ "github.com/itworx/pulse/internal/incidentapi"
+ "github.com/itworx/pulse/internal/inventory"
+ "github.com/itworx/pulse/internal/inventoryapi"
+ "github.com/itworx/pulse/internal/live"
+ "github.com/itworx/pulse/internal/livesampler"
+ "github.com/itworx/pulse/internal/metriccatalog"
+ "github.com/itworx/pulse/internal/metricquery"
+ "github.com/itworx/pulse/internal/metricsapi"
+ "github.com/itworx/pulse/internal/network"
+ "github.com/itworx/pulse/internal/networkapi"
+ "github.com/itworx/pulse/internal/observability"
+ "github.com/itworx/pulse/internal/onboarding"
+ "github.com/itworx/pulse/internal/onboardingapi"
+ pooldomain "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/poolapi"
+ "github.com/itworx/pulse/internal/problem"
+ "github.com/itworx/pulse/internal/process"
+ "github.com/itworx/pulse/internal/processapi"
+ "github.com/itworx/pulse/internal/prometheus"
+ "github.com/itworx/pulse/internal/queryplan"
+ "github.com/itworx/pulse/internal/reverseproxy"
+ "github.com/itworx/pulse/internal/reverseproxyapi"
+ "github.com/itworx/pulse/internal/runtimeconfig"
+ "github.com/itworx/pulse/internal/service"
+ "github.com/itworx/pulse/internal/serviceapi"
+ sharedomain "github.com/itworx/pulse/internal/share"
+ "github.com/itworx/pulse/internal/shareapi"
+ "github.com/itworx/pulse/internal/systemstatus"
+ "github.com/itworx/pulse/internal/systemstatusapi"
+ "github.com/itworx/pulse/internal/widget"
+ "github.com/itworx/pulse/internal/widgetapi"
+ "github.com/itworx/pulse/internal/workerruntime"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func main() {
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
+ if err := run(logger); err != nil {
+ logger.Error("pulse api failed", "error", err)
+ os.Exit(1)
+ }
+}
+
+func run(logger *slog.Logger) error {
+ runtime, err := runtimeconfig.Load("api")
+ if err != nil {
+ return err
+ }
+ application, err := config.Load()
+ if err != nil {
+ return err
+ }
+
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ return err
+ }
+ ctx := context.Background()
+ internalMetrics := observability.NewRegistry(time.Now().UTC())
+ var pool *pgxpool.Pool
+ var inventoryRepo *inventory.Repository
+ var dashboardRepo dashboard.Repository
+ var alertRepo alert.Store
+ var alertControlStore alertcontrol.Store
+ var alertOperationsStore alertopsapi.Store
+ var incidentStore incident.Store
+ var serviceProvider service.Provider = service.UnknownProvider{Reason: "source_unavailable"}
+ var reverseProxyProvider reverseproxy.Provider = reverseproxy.DisabledProvider{SourceID: "reverse-proxy", SourceType: "connector", Reason: "connector_disabled"}
+ var dependencyRepo *service.DependencyRepository
+ var onboardingService onboarding.Service
+ // agentReader is the read half of the pulse-agent telemetry transport. It stays nil
+ // without a database, which keeps every monitoring surface Unknown instead of
+ // inventing state (ADR-0008).
+ var agentReader agentstore.Reader
+ databaseReady := false
+ if application.DatabaseURL != "" {
+ pool, err = database.NewPool(ctx, database.Config{URL: application.DatabaseURL})
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+ if err := database.Ping(ctx, pool); err != nil {
+ return err
+ }
+ databaseReady = true
+ agentReader = agentstore.PostgresStore{Pool: pool}
+ dashboardRepo = dashboard.Repository{Pool: pool}
+ alertRepo = alert.Repository{Pool: pool, Registry: registry}
+ if report, seedErr := alertdefaults.Seed(ctx, alertRepo, registry, "system-defaults"); seedErr != nil {
+ return fmt.Errorf("seed alert defaults: %w", seedErr)
+ } else {
+ logger.Info("alert defaults reconciled", "added", report.Added, "existing", report.Existing)
+ }
+ alertControlStore = alertcontrol.Repository{Pool: pool}
+ alertOperationsStore = alert.StateRepository{Pool: pool}
+ incidentStore, err = incident.NewRepository(pool)
+ if err != nil {
+ return err
+ }
+ serviceProvider, err = service.NewPostgresProvider(pool, service.StatusPolicy{})
+ if err != nil {
+ return err
+ }
+ dependencyRepo, err = service.NewDependencyRepository(pool, audit.PostgresStore{Pool: pool})
+ if err != nil {
+ return err
+ }
+ inventoryRepo, err = inventory.NewRepository(pool)
+ if err != nil {
+ return err
+ }
+ onboardingService = onboarding.Service{State: onboarding.StateStore{Pool: pool}, Pool: pool, Dashboards: dashboardRepo, Alerts: alertRepo, AuthMode: application.AuthMode, OIDCIssuer: application.OIDCIssuer, OIDCClient: application.OIDCClientID, OIDCRedirect: application.OIDCRedirectURL, Prometheus: application.PrometheusURL != "", Unraid: application.UnraidURL != "" && application.UnraidAPIToken != ""}
+ }
+
+ var queryService *metricquery.Service
+ var liveSampler live.Sampler
+ var promSource *prometheus.Client
+ if application.PrometheusURL != "" {
+ source, sourceErr := prometheus.New(application.PrometheusURL, nil, prometheus.Limits{Timeout: application.PrometheusTimeout})
+ if sourceErr != nil {
+ return sourceErr
+ }
+ promSource = source
+ queryService = metricquery.NewService(queryplan.NewPlanner(registry, queryplan.Limits{}), source, nil)
+ sampler, samplerErr := livesampler.New(registry, source, livesampler.Options{})
+ if samplerErr != nil {
+ return samplerErr
+ }
+ liveSampler = sampler
+ }
+ if err != nil {
+ return err
+ }
+ // publishSourceMetrics refreshes adapter counters into the internal registry just
+ // before it is read, so operators see current Prometheus latency and error counts
+ // rather than the values captured at process start.
+ publishSourceMetrics := func() {
+ if promSource != nil {
+ promSource.PublishMetrics(internalMetrics)
+ }
+ }
+ sessions := auth.NewSlidingSessionManager("pulse_session", application.SessionIdleTTL, application.SessionAbsoluteTTL, application.Environment == config.Production)
+ backupManager := &backup.Manager{Pool: pool, Directory: application.BackupDirectory, Retention: application.BackupRetention}
+ var backupObservation struct {
+ sync.Mutex
+ checkedAt time.Time
+ latest time.Time
+ verified time.Time
+ err error
+ }
+ readBackupObservation := func(ctx context.Context, now time.Time) (time.Time, time.Time, error) {
+ backupObservation.Lock()
+ defer backupObservation.Unlock()
+ if !backupObservation.checkedAt.IsZero() && now.Sub(backupObservation.checkedAt) < 5*time.Minute {
+ return backupObservation.latest, backupObservation.verified, backupObservation.err
+ }
+ results, err := backupManager.List(ctx)
+ latest := time.Time{}
+ if len(results) > 0 {
+ latest = results[0].Created
+ }
+ verified := time.Time{}
+ if err == nil {
+ verified = now
+ }
+ backupObservation.checkedAt, backupObservation.latest, backupObservation.verified, backupObservation.err = now, latest, verified, err
+ return latest, verified, err
+ }
+ invalidateBackupObservation := func() {
+ backupObservation.Lock()
+ defer backupObservation.Unlock()
+ backupObservation.checkedAt = time.Time{}
+ }
+ mux := service.HealthMuxWithReadiness(func() bool { return databaseReady })
+ mux.HandleFunc("/auth/test-login", func(response http.ResponseWriter, request *http.Request) {
+ if application.Environment == config.Production || application.AuthMode != "mock" {
+ problem.Write(response, request, http.StatusNotFound, "NOT_FOUND", "Not found", "The requested resource does not exist.", nil)
+ return
+ }
+ principal := auth.Principal{Subject: "development-user", Role: auth.RoleAdministrator}
+ if err := sessions.Issue(response, principal, time.Now().UTC()); err != nil {
+ problem.Write(response, request, http.StatusInternalServerError, "SESSION_ERROR", "Session unavailable", "The session could not be created.", nil)
+ return
+ }
+ if pool != nil {
+ if err := audit.RecordSecurityAction(request.Context(), audit.PostgresStore{Pool: pool}, principal.Subject, "auth.test_login", "success", correlation.FromContext(request.Context())); err != nil {
+ problem.Write(response, request, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "Authentication unavailable", "The authentication event could not be recorded.", nil)
+ return
+ }
+ }
+ response.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(response).Encode(map[string]string{"status": "authenticated", "mode": "mock-development"})
+ })
+ mux.HandleFunc("/session/logout", func(response http.ResponseWriter, request *http.Request) {
+ sessions.Clear(response, request)
+ response.WriteHeader(http.StatusNoContent)
+ })
+ if application.AuthMode == "oidc" && application.OIDCIssuer != "" {
+ oidcLogin, loginErr := authapi.New(authapi.Options{
+ OIDC: auth.OIDCConfig{
+ Issuer: application.OIDCIssuer,
+ ClientID: application.OIDCClientID,
+ ClientSecret: application.OIDCClientSecret,
+ RedirectURL: application.OIDCRedirectURL,
+ },
+ RoleMapping: roleMapping(application.OIDCRoleMapping),
+ GroupsClaim: application.OIDCGroupsClaim,
+ Sessions: sessions,
+ Secure: application.Environment == config.Production,
+ Logger: logger,
+ // Failures land on the overview route, where the web app reads the reason
+ // code from the query string, shows a localized notice and strips it from
+ // the URL. There is deliberately no dedicated error page to maintain.
+ ErrorPath: "/",
+ Audit: func(ctx context.Context, actor, result string) error {
+ if pool == nil {
+ return nil
+ }
+ return audit.RecordSecurityAction(ctx, audit.PostgresStore{Pool: pool}, actor, "auth.login", result, correlation.FromContext(ctx))
+ },
+ })
+ if loginErr != nil {
+ return loginErr
+ }
+ mux.Handle("/auth/login", oidcLogin.LoginHandler())
+ mux.Handle("/auth/callback", oidcLogin.CallbackHandler())
+ logger.Info("oidc login enabled", "mapped_claims", len(application.OIDCRoleMapping))
+ }
+ // reportedJobs is the metadata-only view of the worker schedule. The API never runs
+ // these jobs; it reads their recorded outcomes from job_runs so the status surface
+ // reflects what the worker actually did instead of the hardcoded "not recorded"
+ // placeholders it used before the worker runtime existed.
+ reportedJobs := workerruntime.Schedule(workerruntime.ScheduleRuns{})
+ snapshot := func(requestContext context.Context) (systemstatus.Snapshot, error) {
+ publishSourceMetrics()
+ var auditEvents *int64
+ var statusOptions []systemstatus.Option
+ now := time.Now().UTC()
+ _, authenticated := auth.PrincipalFromContext(requestContext)
+ statusOptions = append(statusOptions, systemstatus.WithAuthenticatedSession(authenticated))
+ sourceHealth := make([]systemstatus.SourceHealth, 0, 3)
+ if promSource != nil {
+ sourceHealth = append(sourceHealth, systemstatus.FromDatasource("prometheus", promSource.Health(requestContext), now))
+ }
+ if agentReader != nil {
+ unraidHealth, storageHealth, healthErr := readAgentSourceHealth(requestContext, agentReader, now)
+ if healthErr != nil {
+ return systemstatus.Snapshot{}, fmt.Errorf("read agent source health: %w", healthErr)
+ }
+ if unraidHealth.ReasonCode != agentsource.ReasonUnavailable || storageHealth.ReasonCode != agentsource.ReasonUnavailable {
+ internalMetrics.SetGauge("pulse_unraid_configured", 1)
+ }
+ sourceHealth = append(sourceHealth, systemstatus.FromDatasource("unraid", unraidHealth, now), systemstatus.FromDatasource("storage", storageHealth, now))
+ }
+ if len(sourceHealth) > 0 {
+ statusOptions = append(statusOptions, systemstatus.WithSources(sourceHealth...))
+ }
+ if application.BackupDirectory != "" {
+ latestBackup, verifiedAt, backupErr := readBackupObservation(requestContext, now)
+ if backupErr != nil {
+ statusOptions = append(statusOptions, systemstatus.WithBackupObservation(time.Time{}, time.Time{}, backupErr))
+ } else if !latestBackup.IsZero() {
+ statusOptions = append(statusOptions, systemstatus.WithBackupObservation(latestBackup, verifiedAt, nil))
+ }
+ }
+ if pool != nil && databaseReady {
+ var count int64
+ if err := pool.QueryRow(requestContext, `SELECT count(*) FROM audit_events`).Scan(&count); err != nil {
+ return systemstatus.Snapshot{}, fmt.Errorf("read audit event count: %w", err)
+ }
+ auditEvents = &count
+ var migrationVersion string
+ if err := pool.QueryRow(requestContext, `SELECT id FROM schema_migrations ORDER BY applied_at DESC, id DESC LIMIT 1`).Scan(&migrationVersion); err != nil {
+ return systemstatus.Snapshot{}, fmt.Errorf("read migration version: %w", err)
+ }
+ statusOptions = append(statusOptions, systemstatus.WithMigrationVersion(migrationVersion))
+ jobs, jobsErr := workerruntime.ReadJobHealth(requestContext, pool, reportedJobs)
+ if jobsErr != nil {
+ // A failed read must not be reported as healthy. Omitting the option
+ // leaves every job component Unknown, which is the honest answer when
+ // the worker's recorded state cannot be established (ADR-0008).
+ logger.Error("read worker job health", "error", jobsErr)
+ } else {
+ statusOptions = append(statusOptions, systemstatus.WithJobs(0, jobs...))
+ }
+ }
+ return systemstatus.Build(application, databaseReady, now, auditEvents, statusOptions...), nil
+ }
+ internalMetrics.SetGauge("pulse_database_ready", boolMetric(databaseReady))
+ internalMetrics.SetGauge("pulse_prometheus_configured", boolMetric(application.PrometheusURL != ""))
+ internalMetrics.SetGauge("pulse_unraid_configured", boolMetric(application.UnraidURL != "" && application.UnraidAPIToken != ""))
+ if onboardingService.State.Pool != nil {
+ onboardingService.RuntimeCapabilities = func(requestContext context.Context) ([]onboarding.Capability, error) {
+ status, statusErr := snapshot(requestContext)
+ if statusErr != nil {
+ return nil, statusErr
+ }
+ capabilities := make([]onboarding.Capability, 0, 2)
+ for _, component := range status.Components {
+ if component.ID != "prometheus" && component.ID != "unraid" {
+ continue
+ }
+ state, detail := "unknown", "Geen actuele runtimewaarneming beschikbaar."
+ switch component.State {
+ case systemstatus.StateHealthy:
+ state, detail = "ready", "Actuele telemetrie wordt ontvangen via de veilige runtimebron."
+ case systemstatus.StateDegraded:
+ state, detail = "incomplete", "De runtimebron vraagt aandacht."
+ case systemstatus.StateDisabled:
+ state, detail = "not-ready", "De runtimebron is niet geconfigureerd."
+ }
+ capabilities = append(capabilities, onboarding.Capability{ID: component.ID, State: state, Detail: detail})
+ }
+ return capabilities, nil
+ }
+ }
+ statusHandler := systemstatusapi.Handler{
+ Snapshot: snapshot,
+ Diagnostics: func(requestContext context.Context) (systemstatusapi.Diagnostics, error) {
+ status, err := snapshot(requestContext)
+ if err != nil {
+ return systemstatusapi.Diagnostics{}, err
+ }
+ return systemstatusapi.Diagnostics{
+ Status: status,
+ Config: systemstatusapi.ConfigSummary{
+ Environment: string(application.Environment), Timezone: application.Timezone, Locale: application.DefaultLocale, AuthMode: application.AuthMode,
+ PublicURLConfigured: application.PublicURL != "", DatabaseConfigured: application.DatabaseURL != "", PrometheusConfigured: application.PrometheusURL != "",
+ UnraidConfigured: application.UnraidURL != "" && application.UnraidAPIToken != "", OIDCConfigured: application.OIDCIssuer != "" && application.OIDCClientID != "",
+ },
+ Runtime: systemstatusapi.Runtime(), Metrics: internalMetrics.Exposition(time.Now().UTC()),
+ }, nil
+ },
+ }
+ protectedStatus := withSession(sessions, auth.Require(auth.PermissionView, statusHandler))
+ protectedDiagnostics := withSession(sessions, auth.Require(auth.PermissionOperate, statusHandler))
+ mux.Handle("/api/v1/system/status", protectedStatus)
+ mux.Handle("/api/v1/system/diagnostics", protectedDiagnostics)
+ metricsExposition := internalMetrics.Handler()
+ mux.Handle("/api/v1/system/metrics", withSession(sessions, auth.Require(auth.PermissionOperate, http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ publishSourceMetrics()
+ metricsExposition.ServeHTTP(response, request)
+ }))))
+ mux.Handle("/api/v1/system/backups", withSession(sessions, auth.Require(auth.PermissionAdmin, backupapi.Handler{Manager: backupManager, OnCreated: invalidateBackupObservation, Audit: func(ctx context.Context, actor, result string) error {
+ return audit.RecordSecurityAction(ctx, audit.PostgresStore{Pool: pool}, actor, "backup.create", result, correlation.FromContext(ctx))
+ }})))
+
+ onboardingHandler := onboardingapi.Handler{Service: onboardingService, Audit: audit.PostgresStore{Pool: pool}}
+ mux.Handle("/api/v1/onboarding", withSession(sessions, auth.Require(auth.PermissionView, onboardingHandler)))
+ metricsHandler := metricsapi.Handler{Registry: registry}
+ widgetRegistry, err := widget.NewRegistry(widget.DefaultDefinitions())
+ if err != nil {
+ return err
+ }
+ widgetHandler := widgetapi.Handler{Registry: widgetRegistry}
+ mux.Handle("/api/v1/widgets/catalog", withSession(sessions, auth.Require(auth.PermissionView, widgetHandler)))
+ mux.Handle("/api/v1/widgets/preview", withSession(sessions, auth.Require(auth.PermissionEdit, widgetHandler)))
+ mux.Handle("/api/v1/metrics/catalog", withSession(sessions, auth.Require(auth.PermissionView, metricsHandler)))
+ queryHandler := metricquery.Handler{Service: queryService}
+ mux.Handle("/api/v1/metrics/query", withSession(sessions, auth.Require(auth.PermissionView, queryHandler)))
+ mux.Handle("/api/v1/metrics/query-range", withSession(sessions, auth.Require(auth.PermissionView, queryHandler)))
+ mux.Handle("/api/v1/metrics/inspect", withSession(sessions, auth.Require(auth.PermissionOperate, queryHandler)))
+ livePlanner := queryplan.NewPlanner(registry, queryplan.Limits{})
+ liveRegistry := live.NewRegistry(liveSampler, live.RegistryOptions{})
+ liveHandler := live.Handler{Planner: &livePlanner, Registry: liveRegistry}
+ mux.Handle("/api/v1/live", withSession(sessions, auth.Require(auth.PermissionView, liveHandler)))
+ if alertRepo != nil {
+ alertHandler := alertapi.Handler{Repository: alertRepo, Registry: registry, Audit: audit.PostgresStore{Pool: pool}}
+ mux.Handle("/api/v1/alert-rules", withSession(sessions, alertHandler))
+ mux.Handle("/api/v1/alert-rules/", withSession(sessions, alertHandler))
+ operationsHandler := alertopsapi.Handler{Store: alertOperationsStore, Audit: audit.PostgresStore{Pool: pool}}
+ protectedOperations := withSession(sessions, auth.Require(auth.PermissionView, operationsHandler))
+ mux.Handle("/api/v1/alerts", protectedOperations)
+ mux.Handle("/api/v1/alerts/", protectedOperations)
+ controlHandler := alertcontrolapi.Handler{Store: alertControlStore, Audit: audit.PostgresStore{Pool: pool}}
+ protectedControls := withSession(sessions, auth.Require(auth.PermissionView, controlHandler))
+ mux.Handle("/api/v1/alert-silences", protectedControls)
+ mux.Handle("/api/v1/alert-silences/", protectedControls)
+ mux.Handle("/api/v1/maintenance-windows", protectedControls)
+ mux.Handle("/api/v1/maintenance-windows/", protectedControls)
+ }
+ if incidentStore != nil {
+ incidentHandler := incidentapi.Handler{Store: incidentStore, Audit: audit.PostgresStore{Pool: pool}}
+ protectedIncidents := withSession(sessions, auth.Require(auth.PermissionView, incidentHandler))
+ mux.Handle("/api/v1/incidents", protectedIncidents)
+ mux.Handle("/api/v1/incidents/", protectedIncidents)
+ }
+ if dashboardRepo.Pool != nil {
+ dashboardHandler := dashboardapi.Handler{Repository: dashboardRepo, Audit: audit.PostgresStore{Pool: pool}}
+ mux.Handle("/api/v1/dashboards", withSession(sessions, dashboardHandler))
+ mux.Handle("/api/v1/dashboards/", withSession(sessions, dashboardHandler))
+ }
+ if inventoryRepo != nil {
+ inventoryHandler := inventoryapi.Handler{Repository: inventoryRepo}
+ protectedInventory := withSession(sessions, auth.Require(auth.PermissionView, inventoryHandler))
+ mux.Handle("/api/v1/entities", protectedInventory)
+ mux.Handle("/api/v1/entities/", protectedInventory)
+ }
+ if pool != nil {
+ eventsHandler := eventapi.Handler{Store: eventapi.PostgresStore{Pool: pool}}
+ mux.Handle("/api/v1/events", withSession(sessions, auth.Require(auth.PermissionView, eventsHandler)))
+ }
+ // Monitoring surfaces are served from the bounded snapshots pulse-agent writes into
+ // PostgreSQL. Without a database there is no transport at all, so each surface keeps
+ // the empty adapter it had before, which resolves to Unknown rather than Healthy.
+ agentWindows := agentsource.Windows{}
+ if err := agentWindows.Validate(); err != nil {
+ return err
+ }
+ var hostProvider host.Provider = host.UnknownProvider{SourceID: "host", SourceType: "agent", Reason: "source_unavailable"}
+ var processProvider interface {
+ Snapshot(context.Context) (process.Snapshot, error)
+ } = process.Adapter{}
+ var containerProvider container.Provider = container.Adapter{}
+ var arrayProvider array.Provider = array.Adapter{}
+ var diskProvider disk.Provider = disk.Adapter{}
+ var poolProvider pooldomain.Provider = pooldomain.Adapter{}
+ var shareProvider sharedomain.Provider = sharedomain.Adapter{}
+ if agentReader != nil {
+ hostProvider = agentsource.HostProvider{Reader: agentReader, Windows: agentWindows}
+ processProvider = agentsource.ProcessProvider{Reader: agentReader, Windows: agentWindows}
+ containerProvider = agentsource.ContainerProvider{Reader: agentReader, Windows: agentWindows}
+ arrayProvider = agentsource.ArrayProvider{Reader: agentReader, Windows: agentWindows}
+ diskProvider = agentsource.DiskProvider{Reader: agentReader, Windows: agentWindows}
+ poolProvider = agentsource.PoolProvider{Reader: agentReader, Windows: agentWindows}
+ shareProvider = agentsource.ShareProvider{Reader: agentReader, Windows: agentWindows}
+ }
+ // Applications have no capability of their own: they aggregate the container
+ // inventory with the service probe results, and report Unknown when either input is
+ // missing or stale.
+ applicationProvider := agentsource.ApplicationProvider{Containers: containerProvider, Services: serviceProvider}
+
+ hostHandler := hostapi.Handler{Provider: hostProvider}
+ mux.Handle("/api/v1/host", withSession(sessions, auth.Require(auth.PermissionView, hostHandler)))
+ processHandler := processapi.Handler{Provider: processProvider}
+ mux.Handle("/api/v1/processes", withSession(sessions, auth.Require(auth.PermissionView, processHandler)))
+ containerHandler := containerapi.Handler{Provider: containerProvider}
+ mux.Handle("/api/v1/containers", withSession(sessions, auth.Require(auth.PermissionView, containerHandler)))
+ mux.Handle("/api/v1/containers/", withSession(sessions, auth.Require(auth.PermissionView, containerHandler)))
+ applicationHandler := applicationapi.Handler{Provider: applicationProvider}
+ mux.Handle("/api/v1/applications", withSession(sessions, auth.Require(auth.PermissionView, applicationHandler)))
+ mux.Handle("/api/v1/applications/", withSession(sessions, auth.Require(auth.PermissionView, applicationHandler)))
+ arrayHandler := arrayapi.Handler{Provider: arrayProvider}
+ mux.Handle("/api/v1/array", withSession(sessions, auth.Require(auth.PermissionView, arrayHandler)))
+ diskHandler := diskapi.Handler{Provider: diskProvider}
+ mux.Handle("/api/v1/disks", withSession(sessions, auth.Require(auth.PermissionView, diskHandler)))
+ mux.Handle("/api/v1/disks/", withSession(sessions, auth.Require(auth.PermissionView, diskHandler)))
+ poolHandler := poolapi.Handler{Provider: poolProvider}
+ mux.Handle("/api/v1/pools", withSession(sessions, auth.Require(auth.PermissionView, poolHandler)))
+ mux.Handle("/api/v1/pools/", withSession(sessions, auth.Require(auth.PermissionView, poolHandler)))
+ shareHandler := shareapi.Handler{Provider: shareProvider}
+ mux.Handle("/api/v1/shares", withSession(sessions, auth.Require(auth.PermissionView, shareHandler)))
+ mux.Handle("/api/v1/shares/", withSession(sessions, auth.Require(auth.PermissionView, shareHandler)))
+
+ forecastHandler := forecastapi.Handler{Provider: forecastdomain.StorageProvider{Shares: shareProvider, Pools: poolProvider, History: forecastdomain.PostgresHistory{Pool: pool}, Policy: forecastdomain.Policy{Enabled: true}}}
+ mux.Handle("/api/v1/forecasts", withSession(sessions, auth.Require(auth.PermissionView, forecastHandler)))
+
+ serviceHandler := serviceapi.Handler{Provider: serviceProvider, Dependencies: dependencyRepo, ReverseProxy: reverseProxyProvider}
+ networkHandler := networkapi.Handler{Provider: network.Aggregator{Host: hostProvider, Services: serviceProvider}}
+ mux.Handle("/api/v1/services", withSession(sessions, auth.Require(auth.PermissionView, serviceHandler)))
+ mux.Handle("/api/v1/services/", withSession(sessions, auth.Require(auth.PermissionView, serviceHandler)))
+ mux.Handle("/api/v1/topology", withSession(sessions, auth.Require(auth.PermissionView, serviceHandler)))
+ mux.Handle("/api/v1/network", withSession(sessions, auth.Require(auth.PermissionView, networkHandler)))
+ reverseProxyHandler := reverseproxyapi.Handler{Provider: reverseProxyProvider}
+ mux.Handle("/api/v1/reverse-proxy", withSession(sessions, auth.Require(auth.PermissionView, reverseProxyHandler)))
+
+ server := &http.Server{Addr: runtime.ListenAddress, Handler: observability.Middleware(internalMetrics, correlation.Middleware(mux)), ReadHeaderTimeout: 5 * time.Second}
+ go func() {
+ logger.Info("pulse api listening", "addr", runtime.ListenAddress, "environment", application.Environment)
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ logger.Error("pulse api stopped", "error", err)
+ }
+ }()
+
+ stopContext, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+ go alertcontrol.RunExpiryLoop(stopContext, alertControlStore, time.Minute, logger)
+ service.WaitForStop(stopContext, nil)
+ shutdownContext, cancel := context.WithTimeout(context.Background(), runtime.ShutdownAfter)
+ defer cancel()
+ if err := server.Shutdown(shutdownContext); err != nil {
+ return err
+ }
+ logger.Info("pulse api stopped")
+ return nil
+}
+
+func boolMetric(value bool) float64 {
+ if value {
+ return 1
+ }
+ return 0
+}
+
+// roleMapping converts the validated configuration mapping of identity provider
+// group claims onto the typed roles used by the authorization layer. Configuration
+// already rejects unknown role names, so no further validation is needed here.
+func roleMapping(configured map[string]string) map[string]auth.Role {
+ if len(configured) == 0 {
+ return nil
+ }
+ mapping := make(map[string]auth.Role, len(configured))
+ for claim, role := range configured {
+ mapping[claim] = auth.Role(role)
+ }
+ return mapping
+}
+
+func withSession(manager *auth.SessionManager, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ authentication, ok := manager.AuthenticateSession(response, request, time.Now().UTC())
+ if !ok {
+ next.ServeHTTP(response, request)
+ return
+ }
+ ctx, cancel := context.WithCancel(request.Context())
+ stop := context.AfterFunc(authentication.Context, cancel)
+ defer func() {
+ stop()
+ cancel()
+ }()
+ next.ServeHTTP(response, request.WithContext(auth.WithPrincipal(ctx, authentication.Principal)))
+ })
+}
diff --git a/cmd/api/session_live_test.go b/cmd/api/session_live_test.go
new file mode 100644
index 0000000..2b88b45
--- /dev/null
+++ b/cmd/api/session_live_test.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/live"
+)
+
+func TestClearingSessionClosesAuthenticatedLiveConnection(t *testing.T) {
+ manager := auth.NewSlidingSessionManager("pulse_test_session", time.Minute, time.Hour, false)
+ issued := httptest.NewRecorder()
+ if err := manager.Issue(issued, auth.Principal{Subject: "viewer", Role: auth.RoleViewer}, time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ cookie := issued.Result().Cookies()[0]
+ server := httptest.NewServer(withSession(manager, auth.Require(auth.PermissionView, live.Handler{})))
+ defer server.Close()
+ options := &websocket.DialOptions{HTTPHeader: http.Header{"Cookie": []string{cookie.String()}}}
+ conn, _, err := websocket.Dial(context.Background(), "ws"+server.URL[4:]+"/api/v1/live", options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.CloseNow()
+ clearRequest := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
+ clearRequest.AddCookie(cookie)
+ manager.Clear(httptest.NewRecorder(), clearRequest)
+ readCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if _, _, err := conn.Read(readCtx); err == nil {
+ t.Fatal("live connection survived session revocation")
+ }
+}
diff --git a/cmd/api/source_health.go b/cmd/api/source_health.go
new file mode 100644
index 0000000..47b9584
--- /dev/null
+++ b/cmd/api/source_health.go
@@ -0,0 +1,40 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentsource"
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/datasource"
+)
+
+var requiredUnraidCapabilities = []agentstore.Capability{
+ agentstore.CapabilityHost,
+ agentstore.CapabilityProcesses,
+ agentstore.CapabilityContainers,
+ agentstore.CapabilityArray,
+ agentstore.CapabilityDisks,
+ agentstore.CapabilityPools,
+ agentstore.CapabilityShares,
+}
+
+var requiredStorageCapabilities = []agentstore.Capability{
+ agentstore.CapabilityArray,
+ agentstore.CapabilityDisks,
+ agentstore.CapabilityPools,
+ agentstore.CapabilityShares,
+}
+
+func readAgentSourceHealth(ctx context.Context, reader agentstore.Reader, now time.Time) (datasource.SourceHealth, datasource.SourceHealth, error) {
+ unraidHealth, err := agentsource.Health(ctx, reader, requiredUnraidCapabilities, agentsource.Windows{}, now)
+ if err != nil {
+ return datasource.SourceHealth{}, datasource.SourceHealth{}, fmt.Errorf("summarize Unraid capabilities: %w", err)
+ }
+ storageHealth, err := agentsource.Health(ctx, reader, requiredStorageCapabilities, agentsource.Windows{}, now)
+ if err != nil {
+ return datasource.SourceHealth{}, datasource.SourceHealth{}, fmt.Errorf("summarize storage capabilities: %w", err)
+ }
+ return unraidHealth, storageHealth, nil
+}
diff --git a/cmd/api/source_health_test.go b/cmd/api/source_health_test.go
new file mode 100644
index 0000000..0328464
--- /dev/null
+++ b/cmd/api/source_health_test.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentsource"
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/datasource"
+)
+
+type sourceHealthReader map[agentstore.Capability]agentstore.Snapshot
+
+func (reader sourceHealthReader) Latest(_ context.Context, capability agentstore.Capability) (agentstore.Snapshot, error) {
+ snapshot, ok := reader[capability]
+ if !ok {
+ return agentstore.Snapshot{}, agentstore.ErrNoSnapshot
+ }
+ return snapshot, nil
+}
+
+func TestReadAgentSourceHealthRequiresContainersForHolisticUnraidHealth(t *testing.T) {
+ now := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC)
+ reader := sourceHealthReader{}
+ for _, capability := range requiredUnraidCapabilities {
+ reader[capability] = agentstore.Snapshot{Capability: capability, ObservedAt: now.Add(-time.Second), ReceivedAt: now}
+ }
+ stale := reader[agentstore.CapabilityContainers]
+ stale.ObservedAt = now.Add(-time.Hour)
+ reader[agentstore.CapabilityContainers] = stale
+
+ unraidHealth, storageHealth, err := readAgentSourceHealth(context.Background(), reader, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if unraidHealth.State != datasource.HealthUnknown || unraidHealth.ReasonCode != agentsource.ReasonStale {
+ t.Fatalf("partially stale Unraid health = %#v", unraidHealth)
+ }
+ if storageHealth.State != datasource.HealthHealthy {
+ t.Fatalf("fresh storage health = %#v", storageHealth)
+ }
+
+ fresh := reader[agentstore.CapabilityContainers]
+ fresh.ObservedAt = now.Add(-time.Second)
+ reader[agentstore.CapabilityContainers] = fresh
+ unraidHealth, storageHealth, err = readAgentSourceHealth(context.Background(), reader, now)
+ if err != nil || unraidHealth.State != datasource.HealthHealthy || storageHealth.State != datasource.HealthHealthy {
+ t.Fatalf("fully fresh health unraid=%#v storage=%#v err=%v", unraidHealth, storageHealth, err)
+ }
+}
+
+func TestReadAgentSourceHealthPropagatesCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, _, err := readAgentSourceHealth(ctx, sourceHealthReader{}, time.Now().UTC())
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation error = %v", err)
+ }
+}
diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go
new file mode 100644
index 0000000..a508566
--- /dev/null
+++ b/cmd/migrate/main.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func main() {
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ databaseURL := os.Getenv("PULSE_DATABASE_URL")
+ if databaseURL == "" {
+ logger.Error("migration failed", "error", "PULSE_DATABASE_URL is required")
+ os.Exit(1)
+ }
+ pool, err := database.NewPool(ctx, database.Config{URL: databaseURL})
+ if err == nil {
+ err = database.Ping(ctx, pool)
+ }
+ if err == nil {
+ err = database.Migrate(ctx, pool)
+ }
+ if pool != nil {
+ pool.Close()
+ }
+ if err != nil {
+ logger.Error("migration failed", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("database migrations complete")
+}
diff --git a/cmd/worker/main.go b/cmd/worker/main.go
new file mode 100644
index 0000000..5ca5395
--- /dev/null
+++ b/cmd/worker/main.go
@@ -0,0 +1,324 @@
+// Command worker is the ITWorx Pulse background runtime.
+//
+// It runs discovery/reconciliation, alert evaluation, service probes and the
+// notification outbox drain on independent schedules, coordinated with any
+// other worker through database leases. It is strictly observational
+// (ADR-0001): it reads sources and writes Pulse's own state, and never mutates
+// Unraid, Docker, the array or volumes.
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/netip"
+ "os"
+ "os/signal"
+ "reflect"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentsource"
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/alertworker"
+ "github.com/itworx/pulse/internal/config"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/database"
+ "github.com/itworx/pulse/internal/discovery"
+ "github.com/itworx/pulse/internal/inventory"
+ "github.com/itworx/pulse/internal/metriccatalog"
+ "github.com/itworx/pulse/internal/metricquery"
+ "github.com/itworx/pulse/internal/notification"
+ "github.com/itworx/pulse/internal/observability"
+ "github.com/itworx/pulse/internal/probe"
+ "github.com/itworx/pulse/internal/prometheus"
+ "github.com/itworx/pulse/internal/queryplan"
+ "github.com/itworx/pulse/internal/runtimeconfig"
+ "github.com/itworx/pulse/internal/servicedefaults"
+ "github.com/itworx/pulse/internal/workerruntime"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// startupTimeout bounds every blocking call made before the scheduling loop
+// starts, so a slow database cannot hold the process before its first
+// heartbeat.
+const startupTimeout = 15 * time.Second
+
+func main() {
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
+ if err := run(logger); err != nil {
+ logger.Error("pulse worker failed", "error", err)
+ os.Exit(1)
+ }
+}
+
+func run(logger *slog.Logger) error {
+ runtimeConfig, err := runtimeconfig.Load("worker")
+ if err != nil {
+ return err
+ }
+ application, err := config.LoadWorker()
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(application.DatabaseURL) == "" {
+ return errors.New("PULSE_DATABASE_URL is required: every worker job is database-coordinated")
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ startupCtx, cancelStartup := context.WithTimeout(ctx, startupTimeout)
+ pool, err := database.NewPool(startupCtx, database.Config{URL: application.DatabaseURL, MaxConns: 8, MinConns: 1})
+ if err != nil {
+ cancelStartup()
+ return err
+ }
+ defer pool.Close()
+ if err := database.Ping(startupCtx, pool); err != nil {
+ cancelStartup()
+ return err
+ }
+ cancelStartup()
+
+ owner := workerOwner()
+ metrics := observability.NewRegistry(time.Now().UTC())
+ jobs, probeJob, err := buildJobs(application, pool, owner, logger)
+ if err != nil {
+ return err
+ }
+ runtime, err := workerruntime.New(workerruntime.Config{
+ Owner: owner,
+ Tick: workerruntime.DefaultTick,
+ HeartbeatFile: runtimeConfig.HeartbeatFile,
+ DrainTimeout: runtimeConfig.ShutdownAfter,
+ Leases: workerruntime.PostgresLeaseStore{Pool: pool},
+ Logger: logger,
+ Metrics: metrics,
+ }, jobs...)
+ if err != nil {
+ return err
+ }
+ logger.Info("pulse worker started",
+ "owner", owner, "environment", application.Environment, "jobs", jobNames(jobs),
+ "heartbeat_file", runtimeConfig.HeartbeatFile, "shutdown_timeout", runtimeConfig.ShutdownAfter.String(),
+ "config", application.String())
+
+ runErr := runtime.Run(ctx)
+
+ // Probe execution owns goroutines of its own; give them the same bounded
+ // grace as the scheduler before the process exits.
+ shutdownCtx, cancelShutdown := context.WithTimeout(context.WithoutCancel(ctx), runtimeConfig.ShutdownAfter)
+ defer cancelShutdown()
+ if probeJob != nil {
+ if err := probeJob.Shutdown(shutdownCtx); err != nil {
+ logger.Warn("probe shutdown incomplete", "error", err.Error())
+ }
+ }
+ for _, status := range runtime.Status() {
+ logger.Info("worker job final state", "job", status.Name, "component", status.Component,
+ "last_status", status.LastStatus, "runs", status.Runs, "failures", status.Failures, "skips", status.Skips)
+ }
+ logger.Info("pulse worker stopped")
+ return runErr
+}
+
+// buildJobs wires the repositories each job needs. A capability without its
+// dependencies is scheduled anyway and reports Disabled with a reason, so an
+// unconfigured feature is visible in system status instead of missing.
+func buildJobs(application config.Config, pool *pgxpool.Pool, owner string, logger *slog.Logger) ([]workerruntime.Job, *workerruntime.ProbeJob, error) {
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ return nil, nil, err
+ }
+ inventoryRepo, err := inventory.NewRepository(pool)
+ if err != nil {
+ return nil, nil, err
+ }
+ discoveryStore, err := discovery.NewPostgresStore(pool, owner)
+ if err != nil {
+ return nil, nil, err
+ }
+ notificationRepo, err := notification.NewRepository(pool)
+ if err != nil {
+ return nil, nil, err
+ }
+ configureCtx, cancelConfigure := context.WithTimeout(context.Background(), startupTimeout)
+ defer cancelConfigure()
+ notificationFactories, err := configureWebhookChannel(configureCtx, application, notificationRepo)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ discoveryJob := workerruntime.DiscoveryJob{
+ SourceID: application.ContainerSourceID,
+ // Reuse the same bounded agent snapshot transport as the public API. A
+ // missing or stale snapshot resolves to Unknown and is skipped without
+ // tombstoning inventory; a fresh snapshot drives idempotent reconciliation.
+ Provider: containerDiscoveryProvider(pool),
+ Aliases: workerruntime.PostgresContainerAliasStore{Pool: pool},
+ Inventory: inventoryRepo,
+ Runner: discovery.Runner{Store: discoveryStore, MaxAttempts: 2, BaseRetry: 250 * time.Millisecond},
+ }
+
+ evaluator := &workerruntime.AlertEvaluator{
+ States: alert.StateRepository{Pool: pool},
+ Prior: workerruntime.PostgresAlertStateReader{Pool: pool},
+ Versions: alert.Repository{Pool: pool, Registry: registry},
+ Notifications: notificationRepo,
+ Logger: logger,
+ }
+ alertJob := workerruntime.AlertEvaluationJob{Reason: "metric_source_not_configured"}
+ if application.PrometheusURL != "" {
+ source, sourceErr := prometheus.New(application.PrometheusURL, nil, prometheus.Limits{Timeout: application.PrometheusTimeout})
+ if sourceErr != nil {
+ return nil, nil, sourceErr
+ }
+ planner := queryplan.NewPlanner(registry, queryplan.Limits{})
+ evaluator.Metrics = workerruntime.PrometheusMetricSource{Service: metricquery.NewService(planner, source, nil)}
+ worker, workerErr := alertworker.New(alert.Repository{Pool: pool, Registry: registry}, alertworker.PostgresLeaseStore{Pool: pool}, evaluator, alertworker.Config{
+ MaxConcurrent: 8, MaxBatch: workerruntime.MaxAlertRules, AttemptTimeout: 15 * time.Second, LeaseTTL: 2 * time.Minute, Owner: owner, Now: time.Now,
+ })
+ if workerErr != nil {
+ return nil, nil, workerErr
+ }
+ alertJob = workerruntime.AlertEvaluationJob{Worker: &worker, Enabled: true}
+ }
+
+ policy, err := probePolicy(application)
+ if err != nil {
+ return nil, nil, err
+ }
+ serviceSummary, err := servicedefaults.Seed(configureCtx, pool, servicedefaults.Options{
+ PublicURL: application.PublicURL, OIDCIssuer: application.OIDCIssuer,
+ }, policy, probe.NetResolver{})
+ if err != nil {
+ return nil, nil, fmt.Errorf("configure system service monitoring: %w", err)
+ }
+ if serviceSummary.Services > 0 {
+ logger.Info("system service monitoring configured", "services", serviceSummary.Services,
+ "endpoints", serviceSummary.Endpoints, "probes", serviceSummary.Probes, "dependencies", serviceSummary.Dependencies)
+ }
+ probeJob, err := workerruntime.NewProbeJob(workerruntime.PostgresProbeStore{Pool: pool}, policy, logger)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ notificationJob := workerruntime.NotificationDrainJob{
+ Store: notificationRepo,
+ Channels: notificationRepo,
+ Senders: map[string]notification.ChannelSender{},
+ Factories: notificationFactories,
+ Logger: logger,
+ }
+
+ jobs := workerruntime.Schedule(workerruntime.ScheduleRuns{
+ Discovery: discoveryJob.Run,
+ AlertEvaluation: alertJob.Run,
+ ProbeExecution: probeJob.Run,
+ NotificationDrain: notificationJob.Run,
+ })
+ return jobs, probeJob, nil
+}
+
+func containerDiscoveryProvider(pool *pgxpool.Pool) container.Provider {
+ return agentsource.ContainerProvider{
+ Reader: agentstore.PostgresStore{Pool: pool},
+ Windows: agentsource.Windows{},
+ }
+}
+
+func configureWebhookChannel(ctx context.Context, application config.Config, repository notification.ChannelStore) (map[string]notification.ChannelSenderFactory, error) {
+ if repository == nil {
+ return nil, notification.ErrUnavailable
+ }
+ current, getErr := repository.GetChannel(ctx, notification.DefaultWebhookChannelID)
+ if application.NotificationWebhookURL == "" {
+ if errors.Is(getErr, notification.ErrNotFound) {
+ return map[string]notification.ChannelSenderFactory{}, nil
+ }
+ if getErr != nil {
+ return nil, fmt.Errorf("read system webhook channel: %w", getErr)
+ }
+ if current.Enabled {
+ current.Enabled = false
+ if _, err := repository.UpdateChannel(ctx, current, current.Revision); err != nil {
+ return nil, fmt.Errorf("disable system webhook channel: %w", err)
+ }
+ }
+ return map[string]notification.ChannelSenderFactory{}, nil
+ }
+ desired := notification.Channel{
+ ID: notification.DefaultWebhookChannelID, Name: "Pulse webhook", Type: "webhook", Enabled: true,
+ SecretRef: notification.SecretRef{ID: notification.WebhookSecretReference},
+ Configuration: map[string]any{"url": application.NotificationWebhookURL, "timeoutSeconds": application.NotificationWebhookTimeout.Seconds()},
+ Revision: 1,
+ }
+ if errors.Is(getErr, notification.ErrNotFound) {
+ if _, err := repository.CreateChannel(ctx, desired); err != nil {
+ return nil, fmt.Errorf("create system webhook channel: %w", err)
+ }
+ } else if getErr != nil {
+ return nil, fmt.Errorf("read system webhook channel: %w", getErr)
+ } else if current.Name != desired.Name || current.Type != desired.Type || !current.Enabled || current.SecretRef != desired.SecretRef || !reflect.DeepEqual(current.Configuration, desired.Configuration) {
+ desired.Revision = current.Revision
+ if _, err := repository.UpdateChannel(ctx, desired, current.Revision); err != nil {
+ return nil, fmt.Errorf("update system webhook channel: %w", err)
+ }
+ }
+ resolver := notification.SecretResolverFunc(func(_ context.Context, ref notification.SecretRef) (string, error) {
+ if ref.ID != notification.WebhookSecretReference {
+ return "", notification.ErrNotFound
+ }
+ return application.NotificationWebhookToken, nil
+ })
+ return map[string]notification.ChannelSenderFactory{
+ "webhook": notification.WebhookFactory{
+ Secrets: resolver,
+ AllowHTTP: application.Environment != config.Production,
+ },
+ }, nil
+}
+
+// probePolicy builds the probe network policy. Only administrator-configured
+// private ranges are added to the allowlist; every other protection in
+// internal/probe/policy.go keeps its default, so link-local, multicast, cloud
+// metadata and unlisted private addresses stay blocked.
+func probePolicy(application config.Config) (probe.NetworkPolicy, error) {
+ policy := probe.NetworkPolicy{}
+ for _, entry := range application.ProbeAllowedNetworks {
+ prefix, err := netip.ParsePrefix(entry)
+ if err != nil {
+ return probe.NetworkPolicy{}, fmt.Errorf("probe allowlist entry %q is invalid", entry)
+ }
+ policy.AllowedNetworks = append(policy.AllowedNetworks, prefix)
+ }
+ if err := policy.Validate(); err != nil {
+ return probe.NetworkPolicy{}, err
+ }
+ return policy, nil
+}
+
+// workerOwner identifies this process in job_runs.lease_owner. It contains no
+// secret and stays stable for the lifetime of the process.
+func workerOwner() string {
+ host, err := os.Hostname()
+ if err != nil || strings.TrimSpace(host) == "" {
+ host = "worker"
+ }
+ owner := fmt.Sprintf("%s/%d", host, os.Getpid())
+ if len(owner) > 120 {
+ owner = owner[:120]
+ }
+ return owner
+}
+
+func jobNames(jobs []workerruntime.Job) []string {
+ names := make([]string, 0, len(jobs))
+ for _, job := range jobs {
+ names = append(names, job.Name+"@"+job.Interval.String())
+ }
+ return names
+}
diff --git a/cmd/worker/main_test.go b/cmd/worker/main_test.go
new file mode 100644
index 0000000..4636b96
--- /dev/null
+++ b/cmd/worker/main_test.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentsource"
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/config"
+ "github.com/itworx/pulse/internal/notification"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type channelStoreStub struct {
+ channels map[string]notification.Channel
+ creates int
+ updates int
+}
+
+func TestContainerDiscoveryProviderUsesAgentSnapshotStore(t *testing.T) {
+ pool := &pgxpool.Pool{}
+ provider, ok := containerDiscoveryProvider(pool).(agentsource.ContainerProvider)
+ if !ok {
+ t.Fatalf("container discovery provider = %T, want agentsource.ContainerProvider", containerDiscoveryProvider(pool))
+ }
+ store, ok := provider.Reader.(agentstore.PostgresStore)
+ if !ok || store.Pool != pool {
+ t.Fatalf("container discovery reader = %#v, want PostgresStore with worker pool", provider.Reader)
+ }
+}
+
+func (store *channelStoreStub) CreateChannel(_ context.Context, channel notification.Channel) (notification.Channel, error) {
+ if _, exists := store.channels[channel.ID]; exists {
+ return notification.Channel{}, notification.ErrConflict
+ }
+ store.creates++
+ channel.Revision = 1
+ store.channels[channel.ID] = channel
+ return channel, nil
+}
+func (store *channelStoreStub) GetChannel(_ context.Context, id string) (notification.Channel, error) {
+ channel, exists := store.channels[id]
+ if !exists {
+ return notification.Channel{}, notification.ErrNotFound
+ }
+ return channel, nil
+}
+func (store *channelStoreStub) ListChannels(context.Context, int) ([]notification.Channel, error) {
+ channels := make([]notification.Channel, 0, len(store.channels))
+ for _, channel := range store.channels {
+ channels = append(channels, channel)
+ }
+ return channels, nil
+}
+func (store *channelStoreStub) UpdateChannel(_ context.Context, channel notification.Channel, expected int64) (notification.Channel, error) {
+ current, exists := store.channels[channel.ID]
+ if !exists {
+ return notification.Channel{}, notification.ErrNotFound
+ }
+ if current.Revision != expected {
+ return notification.Channel{}, notification.ErrConflict
+ }
+ store.updates++
+ channel.Revision = expected + 1
+ store.channels[channel.ID] = channel
+ return channel, nil
+}
+func (store *channelStoreStub) DeleteChannel(_ context.Context, id string) error {
+ if _, exists := store.channels[id]; !exists {
+ return notification.ErrNotFound
+ }
+ delete(store.channels, id)
+ return nil
+}
+
+func TestConfigureWebhookChannelReconcilesWithoutRevisionChurn(t *testing.T) {
+ store := &channelStoreStub{channels: map[string]notification.Channel{}}
+ application := config.Config{
+ Environment: config.Development, NotificationWebhookURL: "http://127.0.0.1:18080/pulse",
+ NotificationWebhookToken: "runtime-only", NotificationWebhookTimeout: 3 * time.Second,
+ }
+ factories, err := configureWebhookChannel(context.Background(), application, store)
+ if err != nil {
+ t.Fatal(err)
+ }
+ channel := store.channels[notification.DefaultWebhookChannelID]
+ if store.creates != 1 || store.updates != 0 || !channel.Enabled || channel.SecretRef.ID != notification.WebhookSecretReference {
+ t.Fatalf("channel=%+v creates=%d updates=%d", channel, store.creates, store.updates)
+ }
+ if _, exists := channel.Configuration["token"]; exists {
+ t.Fatal("runtime credential entered persistent configuration")
+ }
+ if _, err := factories["webhook"].Sender(context.Background(), channel); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := configureWebhookChannel(context.Background(), application, store); err != nil {
+ t.Fatal(err)
+ }
+ if store.creates != 1 || store.updates != 0 {
+ t.Fatalf("idempotent reconciliation created=%d updated=%d", store.creates, store.updates)
+ }
+}
+
+func TestConfigureWebhookChannelDisablesRemovedRuntimeConfiguration(t *testing.T) {
+ store := &channelStoreStub{channels: map[string]notification.Channel{
+ notification.DefaultWebhookChannelID: {
+ ID: notification.DefaultWebhookChannelID, Name: "Pulse webhook", Type: "webhook", Enabled: true,
+ SecretRef: notification.SecretRef{ID: notification.WebhookSecretReference}, Revision: 4,
+ },
+ }}
+ factories, err := configureWebhookChannel(context.Background(), config.Config{}, store)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(factories) != 0 || store.updates != 1 || store.channels[notification.DefaultWebhookChannelID].Enabled {
+ t.Fatalf("factories=%v updates=%d channel=%+v", factories, store.updates, store.channels[notification.DefaultWebhookChannelID])
+ }
+ if _, err := configureWebhookChannel(context.Background(), config.Config{}, nil); !errors.Is(err, notification.ErrUnavailable) {
+ t.Fatalf("nil repository error=%v", err)
+ }
+}
diff --git a/config/README.md b/config/README.md
new file mode 100644
index 0000000..18b38e8
--- /dev/null
+++ b/config/README.md
@@ -0,0 +1,25 @@
+# Configuration examples
+
+Files in this directory are **schemas/examples**, not production secrets.
+
+- `metrics/catalog.example.json`: initial semantic metric definitions.
+- `dashboards/default-overview.example.json`: system overview template.
+- `alerts/default-rules.example.json`: conservative baseline rules.
+- `probes/probe.example.json`: disabled probe example.
+
+Codex must:
+- validate them against schemas;
+- convert them to implementation-owned seed/migration mechanisms;
+- version changes;
+- never overwrite a user's customized dashboard/rule silently;
+- never insert real credentials.
+
+## Contract mapping
+
+- `metrics/catalog.example.json` → `specs/metric-catalog.schema.json`
+- `dashboards/default-overview.example.json` → `specs/dashboard.schema.json`
+- `alerts/default-rules.example.json` → `specs/alert-rule-set.schema.json`
+- `probes/probe.example.json` → `specs/probe.schema.json`
+
+
+- `internal/alertdefaults/seed.json` is the implementation-owned, embedded v1 seed. It is reconciled at API startup without overwriting an existing rule ID or user customization.
diff --git a/config/alerts/default-rules.example.json b/config/alerts/default-rules.example.json
new file mode 100644
index 0000000..ee72c3b
--- /dev/null
+++ b/config/alerts/default-rules.example.json
@@ -0,0 +1,173 @@
+{
+ "schemaVersion": 1,
+ "rules": [
+ {
+ "schemaVersion": 1,
+ "id": "71111111-1111-4111-8111-111111111111",
+ "name": "Monitoringbron levert geen recente gegevens",
+ "enabled": true,
+ "severity": "degraded",
+ "scope": {
+ "entityType": "data-source",
+ "required": true
+ },
+ "condition": {
+ "inputType": "datasource-health",
+ "operator": "==",
+ "threshold": "stale",
+ "windowSeconds": 120
+ },
+ "evaluationIntervalSeconds": 30,
+ "pendingSeconds": 120,
+ "resolveSeconds": 60,
+ "unknownBehavior": "become-unknown",
+ "groupBy": [
+ "source"
+ ],
+ "suppressWhen": [],
+ "message": {
+ "titleKey": "alerts.datasourceStale.title",
+ "bodyKey": "alerts.datasourceStale.body"
+ }
+ },
+ {
+ "schemaVersion": 1,
+ "id": "81111111-1111-4111-8111-111111111111",
+ "name": "Container bevindt zich in een herstartlus",
+ "enabled": true,
+ "severity": "degraded",
+ "scope": {
+ "entityType": "container",
+ "excludeIntentionalStopped": true
+ },
+ "condition": {
+ "inputType": "event",
+ "operator": ">=",
+ "threshold": 3,
+ "aggregation": "count",
+ "windowSeconds": 900
+ },
+ "evaluationIntervalSeconds": 30,
+ "pendingSeconds": 0,
+ "resolveSeconds": 900,
+ "unknownBehavior": "become-unknown",
+ "groupBy": [
+ "container",
+ "application"
+ ],
+ "suppressWhen": [
+ "host.unreachable"
+ ],
+ "message": {
+ "titleKey": "alerts.containerRestartLoop.title",
+ "bodyKey": "alerts.containerRestartLoop.body"
+ }
+ },
+ {
+ "schemaVersion": 1,
+ "id": "91111111-1111-4111-8111-111111111111",
+ "name": "Disktemperatuur te hoog",
+ "enabled": true,
+ "severity": "degraded",
+ "scope": {
+ "entityType": "disk"
+ },
+ "condition": {
+ "inputType": "metric",
+ "metric": "storage.disk.temperature.maximum",
+ "operator": ">=",
+ "threshold": 50,
+ "recoveryThreshold": 46,
+ "aggregation": "max",
+ "windowSeconds": 300
+ },
+ "evaluationIntervalSeconds": 30,
+ "pendingSeconds": 300,
+ "resolveSeconds": 300,
+ "unknownBehavior": "retain-firing-as-unknown",
+ "groupBy": [
+ "disk",
+ "server"
+ ],
+ "suppressWhen": [
+ "host.unreachable",
+ "storage.source.unavailable"
+ ],
+ "message": {
+ "titleKey": "alerts.diskTemperature.title",
+ "bodyKey": "alerts.diskTemperature.body"
+ }
+ },
+ {
+ "schemaVersion": 1,
+ "id": "a1111111-1111-4111-8111-111111111111",
+ "name": "Service is niet bereikbaar",
+ "enabled": true,
+ "severity": "degraded",
+ "scope": {
+ "entityType": "service",
+ "critical": true
+ },
+ "condition": {
+ "inputType": "metric",
+ "metric": "service.availability.minimum",
+ "operator": "<",
+ "threshold": 1,
+ "aggregation": "min",
+ "windowSeconds": 90
+ },
+ "evaluationIntervalSeconds": 30,
+ "pendingSeconds": 90,
+ "resolveSeconds": 60,
+ "unknownBehavior": "become-unknown",
+ "groupBy": [
+ "service",
+ "application"
+ ],
+ "suppressWhen": [
+ "host.unreachable",
+ "network.gateway.unreachable",
+ "dns.unavailable"
+ ],
+ "message": {
+ "titleKey": "alerts.serviceUnavailable.title",
+ "bodyKey": "alerts.serviceUnavailable.body"
+ }
+ },
+ {
+ "schemaVersion": 1,
+ "id": "b1111111-1111-4111-8111-111111111111",
+ "name": "Opslagpool bijna vol",
+ "enabled": true,
+ "severity": "critical",
+ "scope": {
+ "entityType": "storage_pool"
+ },
+ "condition": {
+ "inputType": "metric",
+ "metric": "storage.pool.utilization",
+ "operator": ">=",
+ "threshold": 97,
+ "recoveryThreshold": 90,
+ "aggregation": "max",
+ "windowSeconds": 300
+ },
+ "evaluationIntervalSeconds": 60,
+ "pendingSeconds": 300,
+ "resolveSeconds": 300,
+ "unknownBehavior": "retain-firing-as-unknown",
+ "groupBy": [
+ "pool",
+ "server"
+ ],
+ "suppressWhen": [
+ "host.unreachable",
+ "storage.source.unavailable"
+ ],
+ "message": {
+ "titleKey": "alerts.storagePoolCritical.title",
+ "bodyKey": "alerts.storagePoolCritical.body"
+ }
+ }
+ ]
+}
diff --git a/config/dashboards/default-overview.example.json b/config/dashboards/default-overview.example.json
new file mode 100644
index 0000000..37e3a4f
--- /dev/null
+++ b/config/dashboards/default-overview.example.json
@@ -0,0 +1,331 @@
+{
+ "schemaVersion": 1,
+ "id": "11111111-1111-4111-8111-111111111111",
+ "slug": "overview",
+ "name": "Overzicht",
+ "description": "Standaard operationeel overzicht.",
+ "scope": "system",
+ "variables": [
+ {
+ "name": "server",
+ "type": "server",
+ "label": "Server",
+ "default": "primary"
+ },
+ {
+ "name": "timeRange",
+ "type": "time-range",
+ "label": "Periode",
+ "default": "1h"
+ }
+ ],
+ "widgets": [
+ {
+ "id": "21111111-1111-4111-8111-111111111111",
+ "type": "stat",
+ "title": "Serverstatus",
+ "description": "Samengevoegde status met verklaringen.",
+ "data": {
+ "sourceType": "inventory",
+ "scope": {
+ "entityType": "server",
+ "variable": "server"
+ },
+ "transformations": []
+ },
+ "visualization": {
+ "legend": false,
+ "showSparkline": false,
+ "decimals": 0,
+ "thresholds": []
+ },
+ "behavior": {
+ "locked": false,
+ "hidden": false,
+ "hideWhenEmpty": false,
+ "showOnlyOnProblem": false,
+ "link": "/infrastructure/host",
+ "liveIntervalSeconds": 5,
+ "independentTimeRange": null
+ },
+ "layouts": {
+ "desktop": {
+ "x": 0,
+ "y": 0,
+ "w": 6,
+ "h": 4,
+ "visible": true
+ },
+ "tablet": {
+ "x": 0,
+ "y": 0,
+ "w": 4,
+ "h": 4,
+ "visible": true
+ },
+ "mobile": {
+ "x": 0,
+ "y": 0,
+ "w": 1,
+ "h": 4,
+ "visible": true
+ },
+ "wallboard": {
+ "x": 0,
+ "y": 0,
+ "w": 6,
+ "h": 4,
+ "visible": true
+ }
+ }
+ },
+ {
+ "id": "31111111-1111-4111-8111-111111111111",
+ "type": "timeseries",
+ "title": "CPU en belasting",
+ "data": {
+ "sourceType": "semantic-metric",
+ "metric": "host.cpu.utilization",
+ "scope": {
+ "serverId": "$server"
+ },
+ "range": "$timeRange",
+ "aggregation": "avg",
+ "groupBy": [
+ "instance"
+ ],
+ "transformations": []
+ },
+ "visualization": {
+ "unit": "percent",
+ "decimals": 1,
+ "legend": true,
+ "showSparkline": false,
+ "min": 0,
+ "max": 100,
+ "thresholds": []
+ },
+ "behavior": {
+ "locked": false,
+ "hidden": false,
+ "hideWhenEmpty": false,
+ "showOnlyOnProblem": false,
+ "link": "/infrastructure/host",
+ "liveIntervalSeconds": 2,
+ "independentTimeRange": null
+ },
+ "layouts": {
+ "desktop": {
+ "x": 6,
+ "y": 0,
+ "w": 12,
+ "h": 8,
+ "visible": true
+ },
+ "tablet": {
+ "x": 4,
+ "y": 0,
+ "w": 4,
+ "h": 5,
+ "visible": true
+ },
+ "mobile": {
+ "x": 0,
+ "y": 4,
+ "w": 1,
+ "h": 5,
+ "visible": true
+ },
+ "wallboard": {
+ "x": 6,
+ "y": 0,
+ "w": 10,
+ "h": 6,
+ "visible": true
+ }
+ }
+ },
+ {
+ "id": "41111111-1111-4111-8111-111111111111",
+ "type": "storage-map",
+ "title": "Array en pools",
+ "data": {
+ "sourceType": "inventory",
+ "scope": {
+ "entityTypes": [
+ "array",
+ "pool",
+ "disk"
+ ]
+ },
+ "transformations": []
+ },
+ "visualization": {
+ "legend": true,
+ "showSparkline": false,
+ "decimals": 1,
+ "thresholds": []
+ },
+ "behavior": {
+ "locked": false,
+ "hidden": false,
+ "hideWhenEmpty": false,
+ "showOnlyOnProblem": false,
+ "link": "/infrastructure/storage",
+ "liveIntervalSeconds": 15,
+ "independentTimeRange": null
+ },
+ "layouts": {
+ "desktop": {
+ "x": 0,
+ "y": 8,
+ "w": 9,
+ "h": 8,
+ "visible": true
+ },
+ "tablet": {
+ "x": 0,
+ "y": 5,
+ "w": 8,
+ "h": 7,
+ "visible": true
+ },
+ "mobile": {
+ "x": 0,
+ "y": 9,
+ "w": 1,
+ "h": 7,
+ "visible": true
+ },
+ "wallboard": {
+ "x": 0,
+ "y": 6,
+ "w": 12,
+ "h": 7,
+ "visible": true
+ }
+ }
+ },
+ {
+ "id": "51111111-1111-4111-8111-111111111111",
+ "type": "status-grid",
+ "title": "Applicaties",
+ "data": {
+ "sourceType": "inventory",
+ "scope": {
+ "entityType": "application"
+ },
+ "transformations": [],
+ "limit": 50
+ },
+ "visualization": {
+ "legend": true,
+ "showSparkline": false,
+ "decimals": 0,
+ "thresholds": []
+ },
+ "behavior": {
+ "locked": false,
+ "hidden": false,
+ "hideWhenEmpty": false,
+ "showOnlyOnProblem": false,
+ "link": "/applications",
+ "liveIntervalSeconds": 5,
+ "independentTimeRange": null
+ },
+ "layouts": {
+ "desktop": {
+ "x": 9,
+ "y": 8,
+ "w": 9,
+ "h": 8,
+ "visible": true
+ },
+ "tablet": {
+ "x": 0,
+ "y": 12,
+ "w": 8,
+ "h": 7,
+ "visible": true
+ },
+ "mobile": {
+ "x": 0,
+ "y": 16,
+ "w": 1,
+ "h": 7,
+ "visible": true
+ },
+ "wallboard": {
+ "x": 12,
+ "y": 6,
+ "w": 12,
+ "h": 7,
+ "visible": true
+ }
+ }
+ },
+ {
+ "id": "61111111-1111-4111-8111-111111111111",
+ "type": "event-timeline",
+ "title": "Recente gebeurtenissen",
+ "data": {
+ "sourceType": "events",
+ "scope": {},
+ "range": "$timeRange",
+ "transformations": [],
+ "limit": 100
+ },
+ "visualization": {
+ "legend": false,
+ "showSparkline": false,
+ "decimals": 0,
+ "thresholds": []
+ },
+ "behavior": {
+ "locked": false,
+ "hidden": false,
+ "hideWhenEmpty": false,
+ "showOnlyOnProblem": false,
+ "link": "/events",
+ "liveIntervalSeconds": 5,
+ "independentTimeRange": null
+ },
+ "layouts": {
+ "desktop": {
+ "x": 0,
+ "y": 16,
+ "w": 18,
+ "h": 7,
+ "visible": true
+ },
+ "tablet": {
+ "x": 0,
+ "y": 19,
+ "w": 8,
+ "h": 7,
+ "visible": true
+ },
+ "mobile": {
+ "x": 0,
+ "y": 23,
+ "w": 1,
+ "h": 7,
+ "visible": true
+ },
+ "wallboard": {
+ "x": 0,
+ "y": 13,
+ "w": 24,
+ "h": 6,
+ "visible": true
+ }
+ }
+ }
+ ],
+ "settings": {
+ "defaultTimeRange": "1h",
+ "live": true,
+ "refreshSeconds": 5,
+ "rotationSeconds": null
+ }
+}
diff --git a/config/metrics/catalog.example.json b/config/metrics/catalog.example.json
new file mode 100644
index 0000000..ce899e3
--- /dev/null
+++ b/config/metrics/catalog.example.json
@@ -0,0 +1,397 @@
+{
+ "schemaVersion": 1,
+ "metrics": [
+ {
+ "semanticName": "host.cpu.utilization",
+ "version": 1,
+ "description": "Average host CPU utilization excluding idle time.",
+ "unit": "percent",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\",instance={{instance}}}[{{window}}])) * 100)",
+ "requiredCapabilities": [
+ "node-exporter.cpu"
+ ],
+ "allowedLabels": [
+ "instance"
+ ],
+ "defaultAggregation": "avg",
+ "cardinalityBudget": 10,
+ "limits": {
+ "maxRangeSeconds": 2592000,
+ "maxSeries": 10,
+ "maxPoints": 20000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "gauge"
+ ],
+ "freshnessSeconds": 30,
+ "defaultThresholds": [
+ {
+ "state": "attention",
+ "operator": ">=",
+ "value": 75,
+ "durationSeconds": 300
+ },
+ {
+ "state": "degraded",
+ "operator": ">=",
+ "value": 90,
+ "durationSeconds": 300
+ }
+ ]
+ },
+ {
+ "semanticName": "host.memory.utilization",
+ "version": 1,
+ "description": "Host memory utilization based on available memory.",
+ "unit": "percent",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "(1 - node_memory_MemAvailable_bytes{instance={{instance}}} / node_memory_MemTotal_bytes{instance={{instance}}}) * 100",
+ "requiredCapabilities": [
+ "node-exporter.memory"
+ ],
+ "allowedLabels": [
+ "instance"
+ ],
+ "defaultAggregation": "avg",
+ "cardinalityBudget": 10,
+ "limits": {
+ "maxRangeSeconds": 2592000,
+ "maxSeries": 10,
+ "maxPoints": 20000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "gauge"
+ ],
+ "freshnessSeconds": 30,
+ "defaultThresholds": [
+ {
+ "state": "attention",
+ "operator": ">=",
+ "value": 80,
+ "durationSeconds": 300
+ },
+ {
+ "state": "degraded",
+ "operator": ">=",
+ "value": 92,
+ "durationSeconds": 300
+ }
+ ]
+ },
+ {
+ "semanticName": "container.cpu.utilization",
+ "version": 1,
+ "description": "Container CPU utilization normalized to a percentage of one core unless configured otherwise.",
+ "unit": "percent",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "rate(container_cpu_usage_seconds_total{name={{container}}}[{{window}}]) * 100",
+ "requiredCapabilities": [
+ "container.cpu"
+ ],
+ "allowedLabels": [
+ "instance",
+ "container",
+ "image"
+ ],
+ "defaultAggregation": "avg",
+ "cardinalityBudget": 500,
+ "limits": {
+ "maxRangeSeconds": 2592000,
+ "maxSeries": 200,
+ "maxPoints": 100000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "ranked-list",
+ "table"
+ ],
+ "freshnessSeconds": 30,
+ "defaultThresholds": []
+ },
+ {
+ "semanticName": "container.memory.used",
+ "version": 1,
+ "description": "Current working-set memory used by a container.",
+ "unit": "bytes",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "container_memory_working_set_bytes{name={{container}}}",
+ "requiredCapabilities": [
+ "container.memory"
+ ],
+ "allowedLabels": [
+ "instance",
+ "container",
+ "image"
+ ],
+ "defaultAggregation": "avg",
+ "cardinalityBudget": 500,
+ "limits": {
+ "maxRangeSeconds": 2592000,
+ "maxSeries": 200,
+ "maxPoints": 100000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "ranked-list",
+ "table"
+ ],
+ "freshnessSeconds": 30,
+ "defaultThresholds": []
+ },
+ {
+ "semanticName": "storage.disk.temperature",
+ "version": 1,
+ "description": "Observed disk temperature.",
+ "unit": "celsius",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "smartctl_device_temperature{device={{device}}}",
+ "requiredCapabilities": [
+ "smart.temperature"
+ ],
+ "allowedLabels": [
+ "instance",
+ "device",
+ "model",
+ "serial_hash"
+ ],
+ "defaultAggregation": "max",
+ "cardinalityBudget": 100,
+ "limits": {
+ "maxRangeSeconds": 7776000,
+ "maxSeries": 100,
+ "maxPoints": 100000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "gauge",
+ "heatmap",
+ "table",
+ "storage-map"
+ ],
+ "freshnessSeconds": 300,
+ "defaultThresholds": [
+ {
+ "state": "attention",
+ "operator": ">=",
+ "value": 45,
+ "durationSeconds": 300
+ },
+ {
+ "state": "degraded",
+ "operator": ">=",
+ "value": 50,
+ "durationSeconds": 300
+ },
+ {
+ "state": "critical",
+ "operator": ">=",
+ "value": 60,
+ "durationSeconds": 60
+ }
+ ]
+ },
+ {
+ "semanticName": "storage.disk.temperature.maximum",
+ "version": 1,
+ "description": "Maximum observed disk temperature across the approved source set.",
+ "unit": "celsius",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "max(max_over_time(smartctl_device_temperature[{{window}}]))",
+ "requiredCapabilities": [
+ "smart.temperature"
+ ],
+ "allowedLabels": [
+ "instance",
+ "device",
+ "model",
+ "serial_hash"
+ ],
+ "defaultAggregation": "max",
+ "cardinalityBudget": 1,
+ "limits": {
+ "maxRangeSeconds": 7776000,
+ "maxSeries": 1,
+ "maxPoints": 100000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "gauge"
+ ],
+ "freshnessSeconds": 300,
+ "defaultThresholds": []
+ },
+ {
+ "semanticName": "storage.pool.utilization",
+ "version": 1,
+ "description": "Used capacity as a percentage of usable pool capacity.",
+ "unit": "percent",
+ "valueKind": "gauge",
+ "sourceKind": "derived",
+ "queryTemplate": "pulse_storage_pool_used_bytes{pool={{pool}}} / pulse_storage_pool_capacity_bytes{pool={{pool}}} * 100",
+ "requiredCapabilities": [
+ "storage.pool.capacity"
+ ],
+ "allowedLabels": [
+ "instance",
+ "pool",
+ "filesystem"
+ ],
+ "defaultAggregation": "max",
+ "cardinalityBudget": 100,
+ "limits": {
+ "maxRangeSeconds": 7776000,
+ "maxSeries": 100,
+ "maxPoints": 100000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "gauge",
+ "table",
+ "storage-map"
+ ],
+ "freshnessSeconds": 120,
+ "defaultThresholds": [
+ {
+ "state": "attention",
+ "operator": ">=",
+ "value": 80,
+ "durationSeconds": 600
+ },
+ {
+ "state": "degraded",
+ "operator": ">=",
+ "value": 90,
+ "durationSeconds": 600
+ },
+ {
+ "state": "critical",
+ "operator": ">=",
+ "value": 97,
+ "durationSeconds": 300
+ }
+ ]
+ },
+ {
+ "semanticName": "service.response_time",
+ "version": 1,
+ "description": "End-to-end probe response time.",
+ "unit": "seconds",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "probe_duration_seconds{probe_id={{probe_id}}}",
+ "requiredCapabilities": [
+ "probe.duration"
+ ],
+ "allowedLabels": [
+ "probe_id",
+ "service_id",
+ "probe_type"
+ ],
+ "defaultAggregation": "p95",
+ "cardinalityBudget": 1000,
+ "limits": {
+ "maxRangeSeconds": 7776000,
+ "maxSeries": 500,
+ "maxPoints": 200000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "ranked-list",
+ "table",
+ "heatmap",
+ "service-matrix"
+ ],
+ "freshnessSeconds": 120,
+ "defaultThresholds": []
+ },
+ {
+ "semanticName": "service.availability",
+ "version": 1,
+ "description": "Probe success represented as 0 or 1 and aggregated to availability.",
+ "unit": "ratio",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "probe_success{probe_id={{probe_id}}}",
+ "requiredCapabilities": [
+ "probe.success"
+ ],
+ "allowedLabels": [
+ "probe_id",
+ "service_id",
+ "probe_type"
+ ],
+ "defaultAggregation": "avg",
+ "cardinalityBudget": 1000,
+ "limits": {
+ "maxRangeSeconds": 7776000,
+ "maxSeries": 500,
+ "maxPoints": 200000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries",
+ "table",
+ "service-matrix"
+ ],
+ "freshnessSeconds": 120,
+ "defaultThresholds": []
+ },
+ {
+ "semanticName": "service.availability.minimum",
+ "version": 1,
+ "description": "Minimum probe availability across the approved service source set.",
+ "unit": "ratio",
+ "valueKind": "gauge",
+ "sourceKind": "prometheus",
+ "queryTemplate": "min(min_over_time(probe_success[{{window}}]))",
+ "requiredCapabilities": [
+ "probe.success"
+ ],
+ "allowedLabels": [
+ "probe_id",
+ "service_id",
+ "probe_type"
+ ],
+ "defaultAggregation": "min",
+ "cardinalityBudget": 1,
+ "limits": {
+ "maxRangeSeconds": 7776000,
+ "maxSeries": 1,
+ "maxPoints": 100000,
+ "timeoutSeconds": 10
+ },
+ "allowedVisualizations": [
+ "stat",
+ "timeseries"
+ ],
+ "freshnessSeconds": 120,
+ "defaultThresholds": []
+ }
+ ]
+}
diff --git a/config/probes/probe.example.json b/config/probes/probe.example.json
new file mode 100644
index 0000000..e66ef07
--- /dev/null
+++ b/config/probes/probe.example.json
@@ -0,0 +1,23 @@
+{
+ "schemaVersion": 1,
+ "id": "b1111111-1111-4111-8111-111111111111",
+ "name": "Pulse self-check",
+ "type": "http",
+ "target": {
+ "scheme": "https",
+ "host": "pulse.example.invalid",
+ "port": 443,
+ "path": "/health/ready"
+ },
+ "intervalSeconds": 30,
+ "timeoutSeconds": 5,
+ "enabled": false,
+ "expectedStatusCodes": [
+ 200
+ ],
+ "followRedirects": false,
+ "verifyTls": true,
+ "contentAssertion": null,
+ "secretReference": null,
+ "networkPolicyId": null
+}
diff --git a/deploy/IMAGE_DIGESTS.md b/deploy/IMAGE_DIGESTS.md
new file mode 100644
index 0000000..1afffe0
--- /dev/null
+++ b/deploy/IMAGE_DIGESTS.md
@@ -0,0 +1,67 @@
+# Base image digest ledger
+
+Every external base image referenced by `deploy/*.Dockerfile` must be pinned
+by an immutable `@sha256:` digest before a production release
+(`docs/operations/DEPLOYMENT_UNRAID.md` §6 "immutable release/image digests
+recorded"; `docs/architecture/SECURITY_THREAT_MODEL.md` §5 "immutable image
+digest in production record").
+
+The digests below were resolved with `docker buildx imagetools inspect` on
+2026-08-10. Do not hand-type a digest into this table or a Dockerfile without
+resolving it against the real registry first.
+
+## Status
+
+| Image | Used in | Status |
+|---|---|---|
+| `golang:1.26.6-alpine` | `agent.Dockerfile:1`, `api.Dockerfile:1`, `migrate.Dockerfile:1`, `worker.Dockerfile:1`, `smoke-fixture.Dockerfile:1` (build stage), `postgres.Dockerfile:4` (`gosu-builder` stage) | DONE — pinned `@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83` |
+| `alpine:3.22` | `agent.Dockerfile:8`, `api.Dockerfile:8`, `migrate.Dockerfile:8`, `worker.Dockerfile:8` (runtime stage) | DONE — pinned `@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` |
+| `node:24-bookworm-slim` | `web.Dockerfile:4` (build stage) | DONE — pinned `@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03` |
+| `nginx:1.29-alpine` | `web.Dockerfile:10` (runtime stage) | DONE — pinned `@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de` |
+| `postgres:17-alpine` | `postgres.Dockerfile:26` (runtime stage) | DONE — pinned `@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193` |
+
+## Resolving a digest
+
+Requires network access and Docker. From a trusted machine (not
+necessarily this repo's build host):
+
+```sh
+# Option A: buildx imagetools (works without pulling the image locally)
+docker buildx imagetools inspect golang:1.26.6-alpine
+# Read the top-level "Digest:" line for the manifest list, or the
+# platform-specific line under the linux/amd64 entry if this project only
+# ever builds for one architecture (check TARGETARCH usage in the
+# Dockerfile before choosing).
+
+# Option B: pull + inspect
+docker pull golang:1.26.6-alpine
+docker inspect --format='{{index .RepoDigests 0}}' golang:1.26.6-alpine
+```
+
+Repeat for every external registry image. Docker's built-in `scratch` rootfs is
+not a registry image and therefore has no manifest digest to pin.
+
+## Applying a resolved digest
+
+1. Edit the Dockerfile's `FROM` line to `FROM :@sha256:`
+ (keep the tag alongside the digest for human readability — the digest is
+ what actually pins the build).
+2. Update the row above from `TODO: unresolved` to `DONE — pinned
+ @sha256:` plus the resolution date.
+3. Run `sh deploy/verify-image-digests.sh` and confirm it reports `OK`.
+4. Record the resolved digest set in the deployment evidence per
+ `docs/operations/DEPLOYMENT_UNRAID.md` §8 step 16 ("record
+ digests/migration/config checksum").
+
+## Enforcement
+
+`deploy/verify-image-digests.sh` scans every `deploy/*.Dockerfile`, skips
+internal multi-stage references (`FROM `) and the built-in
+empty `scratch` rootfs, and fails (non-zero exit) if any external registry
+`FROM` line lacks `@sha256:`. All current external images are pinned and the
+gate passes. Run it as
+part of `docs/operations/DEPLOYMENT_UNRAID.md` §8 step 1 ("validate clean
+build and images") before any production build, and wire it into whichever
+CI/Makefile target performs that step (`make build`, `make compose-up`) as a
+blocking pre-flight — this repo's `Makefile`/CI config is outside `deploy/`
+so it is not modified by this change; that wiring is a follow-up.
diff --git a/deploy/agent.Dockerfile b/deploy/agent.Dockerfile
new file mode 100644
index 0000000..3e56ed2
--- /dev/null
+++ b/deploy/agent.Dockerfile
@@ -0,0 +1,21 @@
+FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
+WORKDIR /src
+COPY go.mod go.sum go.work go.work.sum ./
+COPY cmd ./cmd
+COPY internal ./internal
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pulse-agent ./cmd/agent
+
+FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
+RUN addgroup -S -g 65532 pulse && adduser -S -D -H -u 65532 -G pulse pulse
+COPY --from=build /out/pulse-agent /usr/local/bin/pulse-agent
+COPY deploy/pulse-entrypoint.sh /usr/local/bin/pulse-entrypoint.sh
+COPY deploy/healthcheck-heartbeat.sh /usr/local/bin/pulse-healthcheck.sh
+RUN chmod 0755 /usr/local/bin/pulse-entrypoint.sh /usr/local/bin/pulse-healthcheck.sh
+# Mount points for the read-only host procfs/sysfs bind mounts declared in
+# deploy/compose.yaml. They are created in the image so the destinations exist under
+# read_only: true and so the image documents the only host access the agent has.
+RUN mkdir -p /host/proc /host/sys && chmod 0555 /host /host/proc /host/sys
+USER 65532:65532
+# Heartbeat contract: docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
+HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/pulse-healthcheck.sh"]
+ENTRYPOINT ["/usr/local/bin/pulse-entrypoint.sh", "/usr/local/bin/pulse-agent"]
diff --git a/deploy/api.Dockerfile b/deploy/api.Dockerfile
new file mode 100644
index 0000000..44b0138
--- /dev/null
+++ b/deploy/api.Dockerfile
@@ -0,0 +1,17 @@
+FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
+WORKDIR /src
+COPY go.mod go.sum go.work go.work.sum ./
+COPY cmd ./cmd
+COPY internal ./internal
+ARG PULSE_BUILD_VERSION=development
+ARG PULSE_BUILD_COMMIT=unknown
+ARG PULSE_BUILD_TIME=unknown
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X github.com/itworx/pulse/internal/buildinfo.Version=${PULSE_BUILD_VERSION} -X github.com/itworx/pulse/internal/buildinfo.Commit=${PULSE_BUILD_COMMIT} -X github.com/itworx/pulse/internal/buildinfo.BuildTime=${PULSE_BUILD_TIME}" -o /out/pulse-api ./cmd/api
+
+FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
+RUN addgroup -S -g 65532 pulse && adduser -S -D -H -u 65532 -G pulse pulse && apk add --no-cache ca-certificates tzdata
+COPY --from=build /out/pulse-api /usr/local/bin/pulse-api
+USER 65532:65532
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["sh", "-c", "wget -qO- http://127.0.0.1:8081/healthz >/dev/null"]
+EXPOSE 8081
+ENTRYPOINT ["/usr/local/bin/pulse-api"]
diff --git a/deploy/compose.dev.yaml b/deploy/compose.dev.yaml
new file mode 100644
index 0000000..48a0771
--- /dev/null
+++ b/deploy/compose.dev.yaml
@@ -0,0 +1,19 @@
+services:
+ pulse-api:
+ environment:
+ PULSE_ENV: development
+ PULSE_AUTH_MODE: mock
+ ports:
+ - "${PULSE_DEV_API_PORT:-18081}:8081"
+ pulse-worker:
+ environment:
+ PULSE_ENV: development
+ pulse-agent:
+ environment:
+ PULSE_ENV: development
+ pulse-postgres:
+ ports:
+ - "${PULSE_DEV_DB_PORT:-55432}:5432"
+ pulse-web:
+ ports:
+ - "${PULSE_DEV_WEB_PORT:-18080}:8080"
diff --git a/deploy/compose.prod.yaml b/deploy/compose.prod.yaml
new file mode 100644
index 0000000..f79f34d
--- /dev/null
+++ b/deploy/compose.prod.yaml
@@ -0,0 +1,65 @@
+# Production overlay for the ITWorx Pulse deployment on the Unraid host.
+#
+# Usage:
+# docker compose -f deploy/compose.yaml -f deploy/compose.prod.yaml up -d
+#
+# The base compose file deliberately publishes no host port: ADR-0010 requires a
+# controlled proxy edge and forbids guessing a host port. The port is no longer a
+# guess — 1238 was chosen by the operator for this host (ADR-0011) — so it is
+# recorded here, in a separate overlay, rather than being baked into the base file
+# that other environments share.
+#
+# Only pulse-web is published. pulse-api stays on the internal-only network and is
+# reached exclusively through nginx inside pulse-web, so the API cannot be addressed
+# directly from the LAN even by accident.
+#
+# The bind address is configurable and defaults to all interfaces, because Nginx
+# Proxy Manager runs in its own container and cannot reach a 127.0.0.1 binding on the
+# host. If NPM is configured to reach Pulse over a shared Docker network instead, set
+# PULSE_PUBLISH_ADDRESS=127.0.0.1 to remove the LAN exposure entirely.
+services:
+ pulse-web:
+ ports:
+ - "${PULSE_PUBLISH_ADDRESS:-0.0.0.0}:${PULSE_HOST_PORT:-1238}:8080"
+
+ pulse-api:
+ environment:
+ PULSE_ENV: production
+ # Must match the externally reachable URL that terminates TLS, not the
+ # host:port published above, because the OIDC redirect and every absolute
+ # link the API emits have to be valid from the browser's point of view.
+ PULSE_PUBLIC_URL: ${PULSE_PUBLIC_URL:?PULSE_PUBLIC_URL must be set to the externally reachable HTTPS URL}
+ # Fail Compose resolution before touching the running stack if no
+ # identity claim can be mapped onto a bounded Pulse role.
+ PULSE_OIDC_ROLE_MAPPING: ${PULSE_OIDC_ROLE_MAPPING:?PULSE_OIDC_ROLE_MAPPING must contain at least one claim=role entry}
+ PULSE_OIDC_GROUPS_CLAIM: ${PULSE_OIDC_GROUPS_CLAIM:-groups}
+ # The host path is interpolated into the bind mount below; inside the
+ # container the manager always receives this fixed, non-secret path.
+ PULSE_BACKUP_DIR: /var/lib/pulse/backups
+ PULSE_BACKUP_RETENTION: ${PULSE_BACKUP_RETENTION:-5}
+ volumes:
+ - type: bind
+ source: ${PULSE_BACKUP_DIR:?PULSE_BACKUP_DIR must be an operator-owned host directory outside the database volume}
+ target: /var/lib/pulse/backups
+
+ pulse-worker:
+ environment:
+ PULSE_ENV: production
+
+ pulse-agent:
+ environment:
+ PULSE_ENV: production
+ # The kernel reports the container's own name through the UTS namespace, so
+ # the real host name has to be supplied explicitly.
+ PULSE_AGENT_HOST_NAME: ${PULSE_AGENT_HOST_NAME:?PULSE_AGENT_HOST_NAME must be set to the Unraid host name}
+ PULSE_UNRAID_CA_FILE: /run/pulse/unraid-ca.pem
+ volumes:
+ - type: bind
+ source: ${PULSE_UNRAID_CA_FILE_HOST:?PULSE_UNRAID_CA_FILE_HOST must point to the public Unraid TLS certificate}
+ target: /run/pulse/unraid-ca.pem
+ read_only: true
+ # Resolve the certificate hostname from PULSE_UNRAID_URL to the explicitly
+ # discovered reachable Unraid host address. This avoids host networking and
+ # keeps normal certificate hostname verification active.
+ extra_hosts:
+ - "${PULSE_UNRAID_HOST_NAME:?PULSE_UNRAID_HOST_NAME must match the hostname in PULSE_UNRAID_URL}:${PULSE_UNRAID_HOST_GATEWAY:?PULSE_UNRAID_HOST_GATEWAY must be the reachable address of this Unraid host}"
diff --git a/deploy/compose.real-source-smoke.yaml b/deploy/compose.real-source-smoke.yaml
new file mode 100644
index 0000000..737da7d
--- /dev/null
+++ b/deploy/compose.real-source-smoke.yaml
@@ -0,0 +1,10 @@
+# Optional server-side acceptance overlay. It keeps every Pulse service inside
+# the uniquely named smoke project while pointing only the bounded read-only
+# query clients at an operator-supplied Prometheus-compatible source.
+services:
+ pulse-api:
+ environment:
+ PULSE_PROMETHEUS_URL: ${PULSE_REAL_PROMETHEUS_URL:?PULSE_REAL_PROMETHEUS_URL must be set}
+ pulse-worker:
+ environment:
+ PULSE_PROMETHEUS_URL: ${PULSE_REAL_PROMETHEUS_URL:?PULSE_REAL_PROMETHEUS_URL must be set}
diff --git a/deploy/compose.server-smoke.yaml b/deploy/compose.server-smoke.yaml
new file mode 100644
index 0000000..0f65b54
--- /dev/null
+++ b/deploy/compose.server-smoke.yaml
@@ -0,0 +1,22 @@
+# Server-side integration-smoke overlay.
+#
+# Unlike compose.dev.yaml this publishes only the web edge. PostgreSQL and the
+# API remain private to the uniquely named smoke project, which makes this
+# suitable for validation on the shared Unraid host without widening either
+# service's exposure.
+services:
+ pulse-api:
+ environment:
+ PULSE_BACKUP_DIR: /tmp/pulse-backups
+ PULSE_BACKUP_RETENTION: 3
+
+ pulse-agent:
+ # The smoke agent collects only the bounded host/process capabilities and
+ # has no Unraid endpoint. Avoid allocating a third server-side bridge on
+ # shared hosts whose configured Docker address pools are already fully
+ # subnetted.
+ networks: !override [pulse-internal]
+
+ pulse-web:
+ ports:
+ - "${PULSE_SMOKE_PUBLISH_ADDRESS:-0.0.0.0}:${PULSE_SMOKE_WEB_PORT:?PULSE_SMOKE_WEB_PORT must be set}:8080"
diff --git a/deploy/compose.smoke.yaml b/deploy/compose.smoke.yaml
new file mode 100644
index 0000000..0f8ac13
--- /dev/null
+++ b/deploy/compose.smoke.yaml
@@ -0,0 +1,42 @@
+services:
+ pulse-smoke-fixture:
+ build:
+ context: ..
+ dockerfile: deploy/smoke-fixture.Dockerfile
+ environment:
+ PULSE_SMOKE_WEBHOOK_TOKEN: ${PULSE_SMOKE_WEBHOOK_TOKEN:?PULSE_SMOKE_WEBHOOK_TOKEN must be set}
+ networks: [pulse-internal]
+ read_only: true
+ tmpfs: ["/tmp"]
+ security_opt: [no-new-privileges:true]
+ cap_drop: [ALL]
+ pids_limit: 64
+ cpus: "0.25"
+ mem_limit: 64m
+ memswap_limit: 64m
+
+ pulse-api:
+ environment:
+ PULSE_ENV: development
+ PULSE_AUTH_MODE: mock
+ PULSE_PROMETHEUS_URL: http://pulse-smoke-fixture:9090
+ depends_on:
+ pulse-smoke-fixture: {condition: service_healthy}
+
+ pulse-worker:
+ environment:
+ PULSE_ENV: development
+ PULSE_CONTAINER_SOURCE_ID: b1011111-1111-4111-8111-111111111111
+ PULSE_PROMETHEUS_URL: http://pulse-smoke-fixture:9090
+ PULSE_NOTIFICATION_WEBHOOK_URL: http://pulse-smoke-fixture:9090/webhook
+ PULSE_NOTIFICATION_WEBHOOK_TOKEN: ${PULSE_SMOKE_WEBHOOK_TOKEN:?PULSE_SMOKE_WEBHOOK_TOKEN must be set}
+ PULSE_NOTIFICATION_WEBHOOK_TIMEOUT: 3s
+ depends_on:
+ pulse-smoke-fixture: {condition: service_healthy}
+
+ pulse-agent:
+ environment:
+ PULSE_ENV: development
+ PULSE_AGENT_ID: pulse-smoke-agent
+ PULSE_AGENT_HOST_NAME: smoke-host
+ PULSE_AGENT_COLLECT_INTERVAL: 2s
diff --git a/deploy/compose.yaml b/deploy/compose.yaml
new file mode 100644
index 0000000..951ad2d
--- /dev/null
+++ b/deploy/compose.yaml
@@ -0,0 +1,306 @@
+name: itworx-pulse
+
+# Unraid DockerMan reads these labels directly for Compose-managed containers.
+# FolderView3 provides the single application group; every contained service
+# keeps the same recognizable icon and opens the public Pulse UI when selected.
+x-unraid-labels: &unraid-labels
+ net.unraid.docker.icon: ${PULSE_UNRAID_ICON_URL:-}
+ net.unraid.docker.webui: ${PULSE_UNRAID_WEBUI_URL:-}
+ net.unraid.docker.managed: composeman
+ net.unraid.docker.shell: sh
+
+# Resource limits (finding: "no CPU/memory limits on any service")
+# ---------------------------------------------------------------
+# Deployment method is plain `docker compose -f deploy/compose.yaml ... up -d`
+# (see docs/operations/DEVELOPMENT_SETUP.md and DEPLOYMENT_UNRAID.md); this is
+# NOT Docker Swarm and there is no `docker stack deploy` anywhere in the repo.
+# `deploy.resources.reservations` is a swarm-only field and is silently
+# ignored by `docker compose up`; `deploy.resources.limits` support under
+# plain `up` varies by Compose CLI version and is therefore not a reliable
+# enforcement point on an operator's host we do not control. This file
+# already uses the non-swarm legacy resource keys (`pids_limit`), so the
+# same family (`cpus`, `mem_limit`, `mem_reservation`, `memswap_limit`) is
+# used here for consistency and guaranteed enforcement by the Compose CLI in
+# non-swarm mode, regardless of installed Compose version.
+#
+# Sizing rationale (see docs/architecture/SYSTEM_ARCHITECTURE.md §7 scale
+# targets: 1 host, 150 containers, 40 disks, 300 probes, 2,500 active
+# dashboard series, 10 concurrent users):
+# - pulse-postgres holds inventory/config/alerts/audit rows for that scale,
+# not raw time series (Prometheus stays external) -> generous but bounded.
+# - pulse-api serves 10 concurrent users and bounded query/WebSocket traffic.
+# - pulse-worker runs discovery/reconciliation/probes/alerts/notifications;
+# it is the service most exposed to "runaway probe loop" style incidents,
+# so its cap is deliberately tight relative to its risk.
+# - pulse-agent is a bounded read-only collector; pulse-web is static nginx.
+# - pulse-migrate is a short-lived one-shot job.
+# These are conservative starting points, not measured against the actual
+# host's free CPU/memory (M0 discovery in DEPLOYMENT_UNRAID.md §2 records
+# that separately per-host); re-tune after observing real usage. `memswap_limit`
+# equals `mem_limit` for every service so a service cannot spill onto host
+# swap and degrade the other ~80 containers on the shared Unraid host.
+
+services:
+ pulse-postgres:
+ labels: *unraid-labels
+ build:
+ context: ..
+ dockerfile: deploy/postgres.Dockerfile
+ image: itworx-pulse-postgres:17-hardened
+ environment:
+ POSTGRES_DB: ${PULSE_POSTGRES_DB:-pulse}
+ POSTGRES_USER: ${PULSE_POSTGRES_USER:-pulse}
+ POSTGRES_PASSWORD: ${PULSE_POSTGRES_PASSWORD:?PULSE_POSTGRES_PASSWORD must be set}
+ volumes:
+ - pulse-postgres-data:/var/lib/postgresql/data
+ networks: [pulse-internal]
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+ # Read-only exception check (threat model §5 "document and test each"):
+ # the data directory is already the dedicated `pulse-postgres-data`
+ # volume, so PGDATA writes are unaffected. Postgres additionally needs a
+ # writable unix-socket directory (used by both the server and the
+ # `pg_isready` healthcheck above, since no PGHOST is set) and a writable
+ # /tmp for on-disk sort/temp files that fall outside PGDATA. Both are
+ # provided via tmpfs below, matching the pattern of the other five
+ # services. This must be verified during deployment smoke testing
+ # (container starts, healthcheck goes healthy, migrations run
+ # successfully) before this is relied on in production.
+ read_only: true
+ tmpfs: ["/tmp", "/var/run/postgresql"]
+ security_opt: [no-new-privileges:true]
+ cap_drop: [ALL]
+ pids_limit: 256
+ cpus: "1.0"
+ mem_limit: 768m
+ mem_reservation: 256m
+ memswap_limit: 768m
+
+ pulse-api:
+ labels: *unraid-labels
+ build:
+ context: ..
+ dockerfile: deploy/api.Dockerfile
+ args:
+ PULSE_BUILD_VERSION: ${PULSE_BUILD_VERSION:-development}
+ PULSE_BUILD_COMMIT: ${PULSE_BUILD_COMMIT:-unknown}
+ PULSE_BUILD_TIME: ${PULSE_BUILD_TIME:-unknown}
+ environment:
+ PULSE_API_ADDR: 0.0.0.0:8081
+ PULSE_ENV: ${PULSE_ENV:-production}
+ PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set}
+ PULSE_AUTH_MODE: ${PULSE_AUTH_MODE:-oidc}
+ PULSE_DEFAULT_LOCALE: ${PULSE_DEFAULT_LOCALE:-nl}
+ PULSE_TIMEZONE: ${PULSE_TIMEZONE:-Europe/Brussels}
+ PULSE_LOG_LEVEL: ${PULSE_LOG_LEVEL:-info}
+ PULSE_BREAK_GLASS_ENABLED: ${PULSE_BREAK_GLASS_ENABLED:-false}
+ PULSE_OIDC_ISSUER: ${PULSE_OIDC_ISSUER:-}
+ PULSE_OIDC_CLIENT_ID: ${PULSE_OIDC_CLIENT_ID:-}
+ PULSE_OIDC_CLIENT_SECRET: ${PULSE_OIDC_CLIENT_SECRET:-}
+ PULSE_OIDC_REDIRECT_URL: ${PULSE_OIDC_REDIRECT_URL:-}
+ PULSE_OIDC_GROUPS_CLAIM: ${PULSE_OIDC_GROUPS_CLAIM:-groups}
+ PULSE_OIDC_ROLE_MAPPING: ${PULSE_OIDC_ROLE_MAPPING:-}
+ PULSE_SESSION_IDLE_TTL: ${PULSE_SESSION_IDLE_TTL:-8h}
+ PULSE_SESSION_ABSOLUTE_TTL: ${PULSE_SESSION_ABSOLUTE_TTL:-168h}
+ # API-side metric query/live handlers use the same bounded source as the
+ # worker alert evaluator. Without this explicit wiring the UI receives a
+ # source-unavailable adapter even while worker alerts can query metrics.
+ PULSE_PROMETHEUS_URL: ${PULSE_PROMETHEUS_URL:-}
+ PULSE_PROMETHEUS_TIMEOUT: ${PULSE_PROMETHEUS_TIMEOUT:-10s}
+ depends_on:
+ pulse-migrate: {condition: service_completed_successfully}
+ networks: [pulse-internal, pulse-edge]
+ expose: ["8081"]
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8081/healthz >/dev/null"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+ read_only: true
+ tmpfs: ["/tmp"]
+ security_opt: [no-new-privileges:true]
+ cap_drop: [ALL]
+ pids_limit: 256
+ cpus: "1.0"
+ mem_limit: 512m
+ mem_reservation: 128m
+ memswap_limit: 512m
+
+ pulse-worker:
+ labels: *unraid-labels
+ build:
+ context: ..
+ dockerfile: deploy/worker.Dockerfile
+ environment:
+ PULSE_ENV: ${PULSE_ENV:-production}
+ PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set}
+ # Non-secret public endpoints used to install the bounded, read-only
+ # Pulse and Authentik service checks. Both targets still pass the probe
+ # SSRF policy before any row is written and again at dial time.
+ PULSE_PUBLIC_URL: ${PULSE_PUBLIC_URL:-}
+ PULSE_OIDC_ISSUER: ${PULSE_OIDC_ISSUER:-}
+ # Alert evaluation stays Disabled without a metric source, and container
+ # discovery stays Disabled until a data_sources row exists to attribute
+ # inventory to. Both are reported as Disabled with a reason rather than
+ # silently absent, so an unconfigured capability is visible on the status
+ # page instead of looking healthy.
+ PULSE_PROMETHEUS_URL: ${PULSE_PROMETHEUS_URL:-}
+ PULSE_CONTAINER_SOURCE_ID: ${PULSE_CONTAINER_SOURCE_ID:-}
+ # Private/loopback CIDRs service probes may reach. Empty keeps all private
+ # space blocked; link-local, multicast and cloud metadata stay blocked
+ # regardless of this value.
+ PULSE_PROBE_ALLOWED_NETWORKS: ${PULSE_PROBE_ALLOWED_NETWORKS:-}
+ # Optional HTTPS webhook transport. The token is injected only into the
+ # worker process and is represented in PostgreSQL by an opaque reference.
+ PULSE_NOTIFICATION_WEBHOOK_URL: ${PULSE_NOTIFICATION_WEBHOOK_URL:-}
+ PULSE_NOTIFICATION_WEBHOOK_TOKEN: ${PULSE_NOTIFICATION_WEBHOOK_TOKEN:-}
+ PULSE_NOTIFICATION_WEBHOOK_TIMEOUT: ${PULSE_NOTIFICATION_WEBHOOK_TIMEOUT:-10s}
+ PULSE_HEARTBEAT_FILE: ${PULSE_HEARTBEAT_FILE:-/tmp/healthy}
+ depends_on:
+ pulse-migrate: {condition: service_completed_successfully}
+ # pulse-internal reaches PostgreSQL but deliberately has no public egress.
+ # pulse-edge supplies outbound HTTPS/DNS for SSRF-bounded probes. The
+ # worker has no listener, exposed port or published port on either network.
+ networks: [pulse-internal, pulse-edge]
+ # Heartbeat healthcheck contract: see
+ # docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md and
+ # deploy/healthcheck-heartbeat.sh. `kill -0 1` only proves the PID
+ # exists, not that the scheduling loop is making progress; the script
+ # below checks the freshness of a heartbeat file written by the process
+ # itself and force-restarts the container (via `restart: unless-stopped`)
+ # when it goes stale, because `docker compose up` does not restart a
+ # merely-"unhealthy" container on its own.
+ healthcheck:
+ test: ["CMD", "/usr/local/bin/pulse-healthcheck.sh"]
+ interval: 15s
+ timeout: 5s
+ start_period: 20s
+ retries: 3
+ restart: unless-stopped
+ read_only: true
+ tmpfs: ["/tmp"]
+ security_opt: [no-new-privileges:true]
+ cap_drop: [ALL]
+ pids_limit: 256
+ cpus: "1.0"
+ mem_limit: 512m
+ mem_reservation: 128m
+ memswap_limit: 512m
+
+ pulse-agent:
+ labels: *unraid-labels
+ build:
+ context: ..
+ dockerfile: deploy/agent.Dockerfile
+ environment:
+ PULSE_ENV: ${PULSE_ENV:-production}
+ PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set}
+ # Optional read-only Unraid GraphQL source. This is an API key, never a
+ # Docker socket or a host-control credential; omit both values to keep
+ # container telemetry explicitly Unknown.
+ PULSE_UNRAID_URL: ${PULSE_UNRAID_URL:-}
+ PULSE_UNRAID_API_TOKEN: ${PULSE_UNRAID_API_TOKEN:-}
+ PULSE_UNRAID_CA_FILE: ${PULSE_UNRAID_CA_FILE:-}
+ PULSE_AGENT_ID: ${PULSE_AGENT_ID:-pulse-agent}
+ PULSE_AGENT_COLLECT_INTERVAL: ${PULSE_AGENT_COLLECT_INTERVAL:-10s}
+ # The host's procfs and sysfs are bind mounted read-only below; the collector
+ # reads nothing else.
+ PULSE_AGENT_PROC_ROOT: /host/proc
+ PULSE_AGENT_SYS_ROOT: /host/sys
+ # /proc/sys/kernel/hostname is resolved through the reader's UTS namespace, so
+ # inside the container it returns the container name. Name the host explicitly.
+ PULSE_AGENT_HOST_NAME: ${PULSE_AGENT_HOST_NAME:-}
+ # Filesystem capacity is opt-in: it needs a read-only host root mount, which is a
+ # wider grant than /proc and /sys and is therefore an explicit operator decision.
+ # Set PULSE_AGENT_FS_ROOT=/host/root and add "- /:/host/root:ro" below to enable.
+ PULSE_AGENT_FS_ROOT: ${PULSE_AGENT_FS_ROOT:-}
+ # Read-only, no-exec bind mounts of the host's kernel interfaces. This is the whole
+ # of the agent's host access: no Docker socket (ADR-0005), no writable host path,
+ # no extra capability — statfs(2) and reading procfs need none, and the container
+ # keeps the read-only root filesystem, the dropped capabilities and no published ports.
+ volumes:
+ - /proc:/host/proc:ro
+ - /sys:/host/sys:ro
+ depends_on:
+ pulse-api: {condition: service_healthy}
+ networks: [pulse-internal, pulse-collector]
+ # See the heartbeat healthcheck contract note on pulse-worker above and
+ # docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md.
+ healthcheck:
+ test: ["CMD", "/usr/local/bin/pulse-healthcheck.sh"]
+ interval: 15s
+ timeout: 5s
+ start_period: 20s
+ retries: 3
+ restart: unless-stopped
+ read_only: true
+ tmpfs: ["/tmp"]
+ security_opt: [no-new-privileges:true]
+ cap_drop: [ALL]
+ pids_limit: 256
+ cpus: "0.5"
+ mem_limit: 256m
+ mem_reservation: 64m
+ memswap_limit: 256m
+
+ pulse-web:
+ labels: *unraid-labels
+ build:
+ context: ..
+ dockerfile: deploy/web.Dockerfile
+ depends_on:
+ pulse-api: {condition: service_healthy}
+ networks: [pulse-edge]
+ expose: ["8080"]
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+ read_only: true
+ tmpfs: ["/tmp:rw,noexec,nosuid", "/var/cache/nginx:rw,noexec,nosuid", "/var/run:rw,noexec,nosuid"]
+ security_opt: [no-new-privileges:true]
+ cap_drop: [ALL]
+ pids_limit: 256
+ cpus: "0.5"
+ mem_limit: 128m
+ mem_reservation: 32m
+ memswap_limit: 128m
+
+ pulse-migrate:
+ labels: *unraid-labels
+ build:
+ context: ..
+ dockerfile: deploy/migrate.Dockerfile
+ environment:
+ PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set}
+ depends_on:
+ pulse-postgres: {condition: service_healthy}
+ networks: [pulse-internal]
+ restart: "no"
+ read_only: true
+ tmpfs: ["/tmp"]
+ security_opt: [no-new-privileges:true]
+ cap_drop: [ALL]
+ pids_limit: 256
+ cpus: "1.0"
+ mem_limit: 256m
+ mem_reservation: 64m
+ memswap_limit: 256m
+
+volumes:
+ pulse-postgres-data:
+
+networks:
+ pulse-internal:
+ internal: true
+ pulse-edge:
+ # Dedicated outbound boundary for the non-root read-only agent. No other
+ # service joins it, and the agent still exposes no port or host capability.
+ pulse-collector:
diff --git a/deploy/healthcheck-heartbeat.sh b/deploy/healthcheck-heartbeat.sh
new file mode 100644
index 0000000..c632058
--- /dev/null
+++ b/deploy/healthcheck-heartbeat.sh
@@ -0,0 +1,63 @@
+#!/bin/sh
+# Heartbeat liveness check for pulse-worker and pulse-agent.
+#
+# Full contract: docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
+#
+# Summary: the running process must write/touch $PULSE_HEARTBEAT_FILE at
+# least every ~10s as long as its main loop is alive and making forward
+# progress. This script treats the file's mtime as the source of truth
+# (not its contents) so it never needs to parse a timestamp format.
+#
+# `docker compose up` (non-swarm) does not restart a container solely
+# because its HEALTHCHECK reports "unhealthy" -- that status is only
+# informational under plain Compose. To actually recover a hung-but-alive
+# process without an external watcher (which would need Docker socket
+# access, forbidden host-wide per ADR-0005), this script self-terminates
+# PID 1 once staleness is confirmed, which turns "unhealthy" into a real
+# container exit that `restart: unless-stopped` then recovers from.
+#
+# It deliberately does NOT self-terminate merely because the heartbeat file
+# is absent shortly after container start (that's the normal boot path,
+# handled separately below using the entrypoint-recorded start time), which
+# avoids a restart loop on legitimately slow startups (DB dial, migrations
+# wait, etc.).
+set -eu
+
+HEARTBEAT_FILE="${PULSE_HEARTBEAT_FILE:-/tmp/healthy}"
+STARTED_AT_FILE="${PULSE_STARTED_AT_FILE:-/tmp/.pulse-started-at}"
+MAX_AGE_SECONDS="${PULSE_HEARTBEAT_MAX_AGE_SECONDS:-45}"
+
+now=$(date +%s)
+
+self_restart() {
+ reason="$1"
+ echo "pulse-healthcheck: $reason; forcing container exit for restart" >&2
+ # Same-UID SIGKILL to PID 1 requires no capabilities (cap_drop: [ALL] is
+ # fine): standard POSIX signal permission only requires a matching UID,
+ # not CAP_KILL, when sender and target share a UID.
+ kill -9 1 2>/dev/null || true
+}
+
+if [ ! -f "$HEARTBEAT_FILE" ]; then
+ started_at=$(cat "$STARTED_AT_FILE" 2>/dev/null || echo "$now")
+ uptime=$((now - started_at))
+ if [ "$uptime" -gt "$MAX_AGE_SECONDS" ]; then
+ self_restart "no heartbeat ${uptime}s after container start"
+ else
+ echo "pulse-healthcheck: heartbeat file not yet written (uptime ${uptime}s)" >&2
+ fi
+ exit 1
+fi
+
+mtime=$(stat -c %Y "$HEARTBEAT_FILE" 2>/dev/null) || {
+ echo "pulse-healthcheck: cannot stat heartbeat file $HEARTBEAT_FILE" >&2
+ exit 1
+}
+age=$((now - mtime))
+
+if [ "$age" -gt "$MAX_AGE_SECONDS" ]; then
+ self_restart "heartbeat stale (${age}s > ${MAX_AGE_SECONDS}s)"
+ exit 1
+fi
+
+exit 0
diff --git a/deploy/migrate.Dockerfile b/deploy/migrate.Dockerfile
new file mode 100644
index 0000000..e21dc32
--- /dev/null
+++ b/deploy/migrate.Dockerfile
@@ -0,0 +1,13 @@
+FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
+WORKDIR /src
+COPY go.mod go.sum go.work go.work.sum ./
+COPY cmd ./cmd
+COPY internal ./internal
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pulse-migrate ./cmd/migrate
+
+FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
+RUN addgroup -S -g 65532 pulse && adduser -S -D -H -u 65532 -G pulse pulse && apk add --no-cache ca-certificates
+COPY --from=build /out/pulse-migrate /usr/local/bin/pulse-migrate
+USER 65532:65532
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["sh", "-c", "test -x /usr/local/bin/pulse-migrate"]
+ENTRYPOINT ["/usr/local/bin/pulse-migrate"]
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
new file mode 100644
index 0000000..ca63097
--- /dev/null
+++ b/deploy/nginx.conf
@@ -0,0 +1,87 @@
+pid /tmp/nginx.pid;
+error_log /dev/stderr warn;
+events { worker_connections 256; }
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+ access_log /dev/stdout;
+ sendfile on;
+ client_body_temp_path /tmp/client_temp;
+ proxy_temp_path /tmp/proxy_temp;
+ fastcgi_temp_path /tmp/fastcgi_temp;
+ uwsgi_temp_path /tmp/uwsgi_temp;
+ scgi_temp_path /tmp/scgi_temp;
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+server {
+ listen 8080;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "DENY" always;
+ add_header Referrer-Policy "no-referrer" always;
+ add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
+ add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=()" always;
+ # Defence-in-depth per SECURITY_THREAT_MODEL.md §4 ("HSTS when HTTPS
+ # deployment is stable"). TLS terminates at the external Nginx Proxy
+ # Manager (ADR-0010); this container only ever serves plain HTTP on
+ # 8080. Browsers ignore Strict-Transport-Security on a non-HTTPS
+ # response, so emitting it here unconditionally is safe even before/if
+ # this service is ever reached directly over HTTP, and it still reaches
+ # the browser once NPM proxies the HTTPS response through unless NPM is
+ # configured to strip it (verify during deployment smoke test, see
+ # docs/operations/DEPLOYMENT_UNRAID.md §7). `includeSubDomains` is set;
+ # `preload` is intentionally omitted because preload-list submission is
+ # effectively irreversible and should only be added once the HTTPS/DNS
+ # setup at the NPM edge has been stable in production for a while.
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ location = /healthz {
+ access_log off;
+ default_type text/plain;
+ return 200 "ok\n";
+ }
+ # Never let conventional internal-observability paths fall through to the
+ # SPA with a misleading HTTP 200. Pulse exposes bounded authenticated
+ # diagnostics below /api/v1/system/*; raw metrics/debug handlers are not a
+ # public web contract.
+ location = /metrics { access_log off; return 404; }
+ location ^~ /debug/ { access_log off; return 404; }
+ # Dependency-aware readiness must reach the API. Without this exact route,
+ # SPA fallback returns index.html with HTTP 200 and monitoring falsely sees
+ # a ready backend even when PostgreSQL or migrations are unavailable.
+ location = /readyz {
+ proxy_pass http://pulse-api:8081;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ location /api/ {
+ proxy_pass http://pulse-api:8081;
+ proxy_http_version 1.1;
+ # Preserve a non-default deployment port. The WebSocket library's
+ # same-origin check compares Origin against Host and correctly rejects
+ # a port-stripped Host header.
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_read_timeout 65s;
+ }
+ location ~ ^/(auth|session)/ {
+ proxy_pass http://pulse-api:8081;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ location / { try_files $uri $uri/ /index.html; }
+}
+}
diff --git a/deploy/postgres.Dockerfile b/deploy/postgres.Dockerfile
new file mode 100644
index 0000000..1132d56
--- /dev/null
+++ b/deploy/postgres.Dockerfile
@@ -0,0 +1,37 @@
+# Keep PostgreSQL on the official image, but rebuild its privilege-drop helper
+# with the repository's current Go toolchain so the static helper does not carry
+# stale Go standard-library vulnerabilities.
+FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS gosu-builder
+
+ARG TARGETARCH
+WORKDIR /src
+
+RUN apk add --no-cache git \
+ && git init \
+ && git remote add origin https://github.com/tianon/gosu.git \
+ && git fetch --depth=1 origin refs/tags/1.19 \
+ && git checkout --detach FETCH_HEAD
+
+RUN go get golang.org/x/sys@v0.44.0
+
+RUN case "${TARGETARCH}" in \
+ amd64) export GOARCH=amd64 ;; \
+ arm64) export GOARCH=arm64 ;; \
+ arm) export GOARCH=arm ;; \
+ 386) export GOARCH=386 ;; \
+ *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
+ esac \
+ && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gosu .
+
+FROM postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
+
+COPY --from=gosu-builder /out/gosu /usr/local/bin/gosu
+
+RUN apk upgrade --no-cache \
+ && chmod 0755 /usr/local/bin/gosu \
+ && gosu --version \
+ && gosu nobody true
+
+USER postgres
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD pg_isready -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-postgres}
diff --git a/deploy/pulse-entrypoint.sh b/deploy/pulse-entrypoint.sh
new file mode 100644
index 0000000..e3f5239
--- /dev/null
+++ b/deploy/pulse-entrypoint.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+# Minimal entrypoint wrapper used by pulse-worker and pulse-agent images.
+#
+# It records the container's process start time (as seen from inside the
+# container) before exec'ing the real binary, so
+# deploy/healthcheck-heartbeat.sh can grant a startup grace period without
+# depending on Docker's HEALTHCHECK `start_period` semantics, which only
+# suppress unhealthy *status* transitions and do not stop the HEALTHCHECK
+# CMD (including its self-restart action) from running during that window.
+#
+# Contract: this script does not replace or delay the real entrypoint. It
+# performs one tmpfs write and then immediately execs the given command,
+# replacing this shell process (PID 1 stays the real Go binary).
+set -eu
+
+date +%s > /tmp/.pulse-started-at
+
+exec "$@"
diff --git a/deploy/smoke-fixture.Dockerfile b/deploy/smoke-fixture.Dockerfile
new file mode 100644
index 0000000..756b0f8
--- /dev/null
+++ b/deploy/smoke-fixture.Dockerfile
@@ -0,0 +1,12 @@
+FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
+WORKDIR /src
+COPY go.mod go.sum go.work go.work.sum ./
+COPY tools/integrationfixture ./tools/integrationfixture
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pulse-integration-fixture ./tools/integrationfixture
+
+FROM scratch
+COPY --from=build /out/pulse-integration-fixture /pulse-integration-fixture
+USER 65532:65532
+EXPOSE 9090
+HEALTHCHECK --interval=2s --timeout=2s --retries=20 CMD ["/pulse-integration-fixture", "health", "http://127.0.0.1:9090/healthz"]
+ENTRYPOINT ["/pulse-integration-fixture"]
diff --git a/deploy/verify-image-digests.sh b/deploy/verify-image-digests.sh
new file mode 100644
index 0000000..fde2595
--- /dev/null
+++ b/deploy/verify-image-digests.sh
@@ -0,0 +1,84 @@
+#!/bin/sh
+# Blocking pre-flight gate: fail if any deploy/*.Dockerfile pulls an
+# external base image without an immutable `@sha256:` digest pin.
+#
+# Required by docs/operations/DEPLOYMENT_UNRAID.md §6 ("immutable
+# release/image digests recorded") and
+# docs/architecture/SECURITY_THREAT_MODEL.md §5 ("immutable image digest in
+# production record"). See deploy/IMAGE_DIGESTS.md for the current ledger
+# and the exact commands to resolve a real digest.
+#
+# This script intentionally does NOT contain any digest value itself. It
+# only checks that Dockerfiles reference one. It must be run (and must pass)
+# before any production image build/release; wire it into the release
+# pipeline (e.g. `make build`, `make compose-up`, or CI) as a blocking step,
+# alongside DEPLOYMENT_UNRAID.md §8 step 1 ("validate clean build and
+# images").
+#
+# Usage: deploy/verify-image-digests.sh
+# Exit status: 0 if every external base image is digest-pinned, 1 otherwise.
+
+set -eu
+
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+status=0
+
+for dockerfile in "$script_dir"/*.Dockerfile; do
+ [ -f "$dockerfile" ] || continue
+
+ # Stage names declared with `AS ` in this file: a later `FROM
+ # ` refers to a previous build stage, not a registry image, and
+ # must not be treated as something that needs a digest.
+ stage_names=$(grep -Eio '^FROM[[:space:]]+.*[[:space:]]AS[[:space:]]+[a-zA-Z0-9_.-]+' "$dockerfile" \
+ | awk '{print tolower($NF)}' || true)
+
+ grep -Ein '^FROM[[:space:]]' "$dockerfile" | while IFS= read -r line; do
+ lineno=${line%%:*}
+ rest=${line#*:}
+ # First token after FROM, ignoring an optional --platform=... flag.
+ image=$(printf '%s\n' "$rest" | awk '{
+ for (i = 1; i <= NF; i++) {
+ if (tolower($i) == "from") continue
+ if ($i ~ /^--platform=/) continue
+ print $i
+ break
+ }
+ }')
+ image_lc=$(printf '%s' "$image" | tr 'A-Z' 'a-z')
+
+ is_stage=0
+ for s in $stage_names; do
+ if [ "$s" = "$image_lc" ]; then
+ is_stage=1
+ break
+ fi
+ done
+ [ "$is_stage" -eq 1 ] && continue
+
+ case "$image_lc" in
+ scratch) : ;; # Docker's built-in empty rootfs; no registry manifest or digest exists.
+ *@sha256:*) : ;; # pinned, OK
+ *)
+ echo "UNPINNED: $(basename "$dockerfile"):$lineno: FROM $image" >&2
+ echo " action: resolve a real digest (see deploy/IMAGE_DIGESTS.md) and" >&2
+ echo " change this line to 'FROM $image@sha256:'." >&2
+ echo "unpinned" >> "$script_dir/.verify-image-digests.tmp"
+ ;;
+ esac
+ done
+done
+
+if [ -f "$script_dir/.verify-image-digests.tmp" ]; then
+ rm -f "$script_dir/.verify-image-digests.tmp"
+ status=1
+fi
+
+if [ "$status" -ne 0 ]; then
+ echo >&2
+ echo "deploy/verify-image-digests.sh: FAILED - one or more base images are not pinned by digest." >&2
+ echo "This blocks release per DEPLOYMENT_UNRAID.md §6 / SECURITY_THREAT_MODEL.md §5." >&2
+else
+ echo "deploy/verify-image-digests.sh: OK - all external base images are digest-pinned."
+fi
+
+exit "$status"
diff --git a/deploy/web.Dockerfile b/deploy/web.Dockerfile
new file mode 100644
index 0000000..485714e
--- /dev/null
+++ b/deploy/web.Dockerfile
@@ -0,0 +1,20 @@
+# Vite uses native lightningcss bindings during production minification. Use the
+# glibc builder variant so pnpm selects the reproducibly available linux-x64-gnu
+# optional package; the small nginx runtime remains Alpine-based.
+FROM node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build
+WORKDIR /src
+RUN npm install --global pnpm@10.33.0
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+COPY apps/web/package.json ./apps/web/package.json
+RUN pnpm install --frozen-lockfile
+COPY apps/web ./apps/web
+RUN pnpm --filter @itworx/pulse-web build
+
+FROM nginx:1.29-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de
+RUN apk upgrade --no-cache
+COPY deploy/nginx.conf /etc/nginx/nginx.conf
+COPY --from=build /src/apps/web/dist /usr/share/nginx/html
+RUN rm -rf /docker-entrypoint.d/*
+USER nginx
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["sh", "-c", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null"]
+EXPOSE 8080
diff --git a/deploy/worker.Dockerfile b/deploy/worker.Dockerfile
new file mode 100644
index 0000000..4c0a4ad
--- /dev/null
+++ b/deploy/worker.Dockerfile
@@ -0,0 +1,19 @@
+FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
+WORKDIR /src
+COPY go.mod go.sum go.work go.work.sum ./
+COPY cmd ./cmd
+COPY internal ./internal
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pulse-worker ./cmd/worker
+
+FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
+RUN addgroup -S -g 65532 pulse \
+ && adduser -S -D -H -u 65532 -G pulse pulse \
+ && apk add --no-cache ca-certificates tzdata
+COPY --from=build /out/pulse-worker /usr/local/bin/pulse-worker
+COPY deploy/pulse-entrypoint.sh /usr/local/bin/pulse-entrypoint.sh
+COPY deploy/healthcheck-heartbeat.sh /usr/local/bin/pulse-healthcheck.sh
+RUN chmod 0755 /usr/local/bin/pulse-entrypoint.sh /usr/local/bin/pulse-healthcheck.sh
+USER 65532:65532
+# Heartbeat contract: docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
+HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/pulse-healthcheck.sh"]
+ENTRYPOINT ["/usr/local/bin/pulse-entrypoint.sh", "/usr/local/bin/pulse-worker"]
diff --git a/docs/PUBLIC_DEPLOYMENT.md b/docs/PUBLIC_DEPLOYMENT.md
new file mode 100644
index 0000000..484b53a
--- /dev/null
+++ b/docs/PUBLIC_DEPLOYMENT.md
@@ -0,0 +1,46 @@
+# Public deployment guide
+
+This guide describes the portable deployment contract. It intentionally contains no private hostnames, addresses, paths, identity-provider groups, or production evidence.
+
+## Supported topology
+
+Run the base Compose file together with the production overlay:
+
+```bash
+docker compose -f deploy/compose.yaml -f deploy/compose.prod.yaml config
+docker compose -f deploy/compose.yaml -f deploy/compose.prod.yaml up -d
+```
+
+Only `pulse-web` is published. The API, PostgreSQL, worker, agent, and migration job stay on explicit internal networks. Put a TLS reverse proxy in front of the web edge.
+
+## Required configuration
+
+Copy `.env.example` to a protected runtime location outside Git and replace every example value. Production startup requires:
+
+- `PULSE_POSTGRES_PASSWORD` and `PULSE_DATABASE_URL` for a dedicated database;
+- `PULSE_PUBLIC_URL` with the external HTTPS origin;
+- OIDC issuer, client ID, client secret, redirect URL, groups claim, and explicit claim-to-role mapping;
+- a pre-created `PULSE_BACKUP_DIR` writable only by the API runtime identity;
+- `PULSE_UNRAID_URL`, a least-privilege read-only token, a public CA certificate, and matching `PULSE_UNRAID_HOST_NAME`/`PULSE_UNRAID_HOST_GATEWAY` when Unraid API collection is enabled;
+- an approved Prometheus-compatible endpoint when historical metrics are required.
+
+Never place secrets in Compose literals, command history, logs, screenshots, or repository evidence.
+
+## Preflight
+
+Before starting:
+
+1. confirm the selected host port is unused;
+2. verify project, network, volume, and backup paths do not overlap another application;
+3. render Compose and review every mount, published port, capability, user, and secret recipient;
+4. run `deploy/verify-image-digests.sh`;
+5. take checksummed backups of every external configuration that will change;
+6. record the current image, schema, proxy configuration, and rollback procedure.
+
+## Acceptance
+
+Require all services to become healthy, run migrations explicitly, verify `/healthz` and `/readyz`, complete an HTTPS/OIDC login, verify WebSocket reconnect, check datasource freshness, restart the stack, and repeat the smoke tests. Run `scripts/production-smoke.ps1` against the actual public origin to check headers, redirects, anonymous exposure, and readiness behavior.
+
+## Rollback
+
+Stop only the Pulse project being deployed. Restore the previous proxy configuration and exact image/configuration. Restore PostgreSQL only from a verified backup when forward recovery is not safe. Never prune Docker or delete unknown networks, volumes, containers, or host data.
diff --git a/docs/PUBLIC_SOURCE_BOUNDARY.md b/docs/PUBLIC_SOURCE_BOUNDARY.md
new file mode 100644
index 0000000..7edf140
--- /dev/null
+++ b/docs/PUBLIC_SOURCE_BOUNDARY.md
@@ -0,0 +1,19 @@
+# Public source boundary
+
+The canonical engineering repository combines product source with private planning, prompt, packaging, and operational evidence. Do not make that history public directly. Create a parentless candidate with `scripts/export-public-source.mjs` and validate that candidate before publication.
+
+A public source set may include reviewed code under `cmd/`, `internal/`, `apps/`, stable configuration schemas, synthetic fixtures, durable user documentation, and reproducible build scripts selected by `public-source.allowlist`.
+
+Exclude from public source/release archives unless individually reviewed:
+
+- `.agents/`, `.codex/`, prompt packs and operator-specific instructions;
+- `artifacts/`, generated evidence, checksums and package reports not produced by the tagged release job;
+- private deployment configuration, host inventories and telemetry;
+- transient planning/state documents and local test output;
+- credentials, `.env` files, private keys and production data.
+
+The exporter blocks when `LICENSE` is missing and scans the rendered candidate for private topology, machine-local paths, denied directories, symlinks, and oversized files. It writes a content manifest so the published tree can be reviewed independently of private Git history.
+
+First-party source code selected for the public source set is licensed under **AGPL-3.0-or-later**; see the root `LICENSE`. Third-party components and assets retain their own terms and must remain covered by the repository's dependency and notice documentation.
+
+Before any visibility change, also scan every private ref and object. A clean current export does not sanitize the canonical repository history.
diff --git a/docs/REPOSITORY_BOUNDARY.md b/docs/REPOSITORY_BOUNDARY.md
new file mode 100644
index 0000000..3a53b76
--- /dev/null
+++ b/docs/REPOSITORY_BOUNDARY.md
@@ -0,0 +1,19 @@
+# Repository and Release Boundary
+
+The canonical private repository contains both product source and intentional engineering context. These categories have different publication and packaging rules.
+
+## Product source
+
+Application code, contracts, migrations, deployment templates, tests and public product documentation may enter a reviewed source release when they contain only generic configuration and redistributable assets.
+
+## Development-only context
+
+`.agents/`, `.codex/`, `planning/`, `MASTER_PROMPT.txt`, detailed audit state and milestone evidence support private development. They remain tracked for continuity but are excluded from `git archive` release output. Machine-local Codex execution policy is not tracked.
+
+## Runtime/private data
+
+Telemetry, host inventories, dashboards, alerts, incidents, backups, credentials, OIDC state, local databases and generated heavy evidence must remain outside source control.
+
+## Licensing
+
+First-party source code in the repository is licensed under **AGPL-3.0-or-later**; see the root `LICENSE`. Third-party components and assets retain their own terms. A future public release must still use the reviewed parentless export and pass its publication gates; the canonical private history is not itself a publication artifact.
diff --git a/docs/architecture/ALERTING_AND_INCIDENTS.md b/docs/architecture/ALERTING_AND_INCIDENTS.md
new file mode 100644
index 0000000..455c23f
--- /dev/null
+++ b/docs/architecture/ALERTING_AND_INCIDENTS.md
@@ -0,0 +1,185 @@
+# Alerting and incident architecture
+
+## 1. Concepts
+
+- **Rule:** versioned definition of a condition.
+- **Alert instance:** stable rule + entity/label fingerprint.
+- **Occurrence:** immutable state transition/evaluation history.
+- **Incident:** grouped operational problem containing related alerts/entities/events.
+- **Silence:** user-defined temporary notification/state suppression.
+- **Maintenance window:** scheduled policy for selected entities.
+- **Inhibition/suppression:** dependency-aware prevention of downstream noise.
+
+## 2. State machine
+
+Primary states:
+
+```text
+inactive -> pending -> firing -> acknowledged -> resolved
+```
+
+Orthogonal/contextual states:
+- unknown;
+- silenced;
+- suppressed;
+- maintenance.
+
+Acknowledgement does not mean resolved. A firing alert may be acknowledged.
+
+## 3. Evaluation
+
+Every rule defines:
+- schedule/evaluation interval;
+- semantic query/event/status input;
+- scope selector;
+- comparator/condition;
+- pending duration;
+- recovery duration;
+- hysteresis;
+- severity;
+- message template;
+- grouping/suppression metadata;
+- unknown behavior.
+
+Evaluation is idempotent and transactional.
+
+## 4. Unknown behavior
+
+When required data is stale/unavailable:
+- do not auto-resolve a firing alert;
+- transition/display unknown according to rule policy;
+- retain last known value/time;
+- emit source-health context;
+- avoid repeated notifications for the same unknown episode.
+
+## 5. Hysteresis example
+
+Disk temperature:
+- enter pending above 50°C;
+- fire after 5 minutes;
+- remain firing until below 46°C for 5 minutes;
+- cooldown duplicate notifications.
+
+Both thresholds and durations are visible in rule UI.
+
+## 6. Deduplication/grouping
+
+Fingerprint is based on:
+- rule ID/version behavior;
+- stable entity ID;
+- bounded grouping labels.
+
+Notifications group by:
+- incident;
+- host/application;
+- severity/time window;
+- configured labels.
+
+## 7. Dependency suppression
+
+Examples:
+- host unreachable suppresses container/service unreachable alerts;
+- DNS failure suppresses dependent service resolution alerts;
+- reverse proxy outage groups external endpoint failures while internal probes remain separate;
+- Prometheus unavailable suppresses metric-rule noise and creates a monitoring-source incident.
+
+Suppressed alerts remain inspectable.
+
+Pulse uses bounded, deterministic cause keys for suppression (`host.unreachable`, `dns.failure`, `source.unavailable`). A downstream alert is suppressed only when the configured cause is active and confirmed or high-confidence; the downstream signal, fingerprint and suppression reason remain inspectable. Group keys use rule identity, severity and configured stable labels with bounded cardinality. Duplicate evaluation signals are collapsed by instance and evaluation key before grouping.
+
+## 8. Maintenance and silences
+
+Maintenance:
+- scheduled;
+- selector-based;
+- audited;
+- optionally changes visual status to maintenance;
+- prevents configured notifications without deleting evidence.
+
+Silence:
+- explicit reason;
+- owner;
+- expiry required;
+- matchers bounded/previewable;
+- audited.
+
+## 9. Incident correlation
+
+Deterministic rules first:
+- shared parent dependency;
+- same application/host;
+- close start time;
+- known causal hierarchy;
+- common event.
+
+Heuristic correlation may be added later, but must expose confidence and rationale.
+
+Incident severity is derived from:
+- highest unsuppressed alert;
+- criticality of affected entities;
+- duration/scope;
+- explicit operator override.
+
+## 10. Notifications
+
+Channels may include email/webhook/other selected integrations.
+
+Requirements:
+- encrypted secret references;
+- idempotency;
+- retry with bounded exponential backoff;
+- delivery audit;
+- templates with safe escaping;
+- grouping and cooldown;
+- recovery notification;
+- test action;
+- channel failure health.
+
+## 11. Default rules
+
+Provide conservative baseline rules for:
+- monitoring source stale/down;
+- host resource/temperature;
+- container stop/health/restart loop;
+- array/pool/disk/SMART/capacity;
+- service availability/latency/TLS;
+- UPS battery/on-battery when available.
+
+Defaults are versioned, visible and editable. Avoid hard thresholds where hardware-specific policy is needed; use sensible presets with onboarding confirmation.
+
+M8-06 implementation note: silences and maintenance windows use explicit bounded matchers (rule IDs, entity IDs, entity types, severities, and exact labels). Preview returns deterministic matching instance IDs. Expiry is mandatory, state transitions are retained, and revocation or expiry never deletes alert occurrences. Maintenance state is exposed as scheduled, active, expired, or revoked.
+
+M8-07 operation semantics: acknowledgement is an operator state annotation, not resolution. Acknowledgement and unacknowledgement are persisted as immutable occurrence events, use optimistic revisions, and are idempotent by evaluation key. Evaluator transitions remain authoritative for resolved state.
+
+### M8-08 delivery semantics
+
+The notification framework uses the alert lifecycle event type (`firing`, `recovery`, or `unknown`) as the outbox contract. Enqueue is idempotent by caller-supplied key. Workers claim due rows with a bounded lease and PostgreSQL row locking, write a `delivering` audit record, and complete the same attempt transactionally. Failures are redacted, retried with bounded exponential delay, and terminally marked failed after ten attempts. A recovery event is delivered through the same path.
+
+The production transport is a bounded HTTPS webhook configured with `PULSE_NOTIFICATION_WEBHOOK_URL`, `PULSE_NOTIFICATION_WEBHOOK_TOKEN`, and an optional 1–30 second `PULSE_NOTIFICATION_WEBHOOK_TIMEOUT`. The worker reconciles one system-owned webhook channel at startup. PostgreSQL stores only the URL, timeout, and an opaque runtime secret reference; the bearer credential never enters channel JSON, delivery audit, API responses, or logs. Requests contain the lifecycle payload and repeat the stable outbox idempotency key in both the body and `Idempotency-Key` header. Receivers must use that key to collapse a replay when delivery succeeded but acknowledgement or local completion was interrupted. Redirects, URL credentials, query strings, fragments, unbounded timeouts, and non-HTTPS production endpoints are rejected.
+
+Channel test actions are in-memory only, do not enqueue an outbox record, and are rate limited per actor/channel pair.
+### M8-09 correlation behavior
+
+Correlation starts with deterministic keys: a primary dependency outage groups the dependency alert and downstream alerts sharing its parent; otherwise stable application, host, or entity keys are used. Groups are sorted by alert ID and correlation output is stable across input ordering. Every candidate stores the method and confidence used; the model does not present heuristic correlation as proven causation. Manual alert association is persisted separately, audited at the API boundary, and protected from automatic overwrite.
+
+### M8-10 incident timeline and operator context
+
+Incident detail presents correlated alerts and operator notes in a deterministic UTC timeline. Ownership and notes are auditable metadata only. The UI explicitly states that correlation is an evidence-backed indication rather than proven causality and that confidence describes the correlation rule, not certainty of root cause. External workflow linking is a disabled placeholder; Pulse remains observational and does not remediate infrastructure.
+
+
+### M8-11 default rule seeding
+
+The implementation-owned embedded default bundle is schema-versioned and validated against the semantic metric catalog before startup reconciliation. Seeding is additive and idempotent: a missing default is inserted, while an existing rule ID—including a customized version—is preserved and counted as existing. Event-based restart-loop rules explicitly support `count` aggregation. Duplicate evaluation signals are collapsed before deterministic grouping; dependency causes remain inspectable while downstream notifications are suppressed.
+
+## Default storage-pool rule — evaluability fix (2026-08-17)
+
+The seeded default "Opslagpool bijna vol" originally bound `storage.pool.utilization`, whose query
+template requires a `{{pool}}` value; the alert evaluator issues one instant query per rule without a pool
+scope and does not fan out over `groupBy`, so the rule could never evaluate
+(`PROMQL_BINDING_VALUE_REQUIRED (pool)`). It now uses `storage.pool.utilization.maximum` (placeholder-free:
+the highest utilisation across all pools) so the default fires when any pool crosses the threshold; per-pool
+rules can still be created with a `poolId` scope. The seed refreshes implementation-owned defaults that are
+still at revision 1 (never edited by an operator) through the normal versioned update path
+(`alertdefaults.Seed` → `RuleUpdater`), and never touches edited rules. The `pulse_storage_pool_*` series must
+be provided by the environment — for Unraid via the recording rules in
+`deploy/prometheus.pulse-storage-pool.rules.yaml`.
diff --git a/docs/architecture/API_CONTRACT.md b/docs/architecture/API_CONTRACT.md
new file mode 100644
index 0000000..70f6d8b
--- /dev/null
+++ b/docs/architecture/API_CONTRACT.md
@@ -0,0 +1,371 @@
+# API contract
+
+## 1. General rules
+
+- Base path: `/api/v1`.
+- JSON request/response.
+- OpenAPI is generated/validated in CI.
+- UTC RFC3339 timestamps.
+- UUID resource IDs.
+- Cursor pagination for mutable/large collections.
+- `ETag` or explicit version for optimistic concurrency.
+- Problem details style errors with safe message, code and correlation ID.
+- Authentication required except health and OIDC bootstrap endpoints.
+- Authorization is server-side for every resource/action.
+- All list/query inputs are bounded.
+
+## 2. Error shape
+
+```json
+{
+ "type": "https://pulse.local/problems/query-limit",
+ "title": "Query limit exceeded",
+ "status": 422,
+ "code": "QUERY_POINT_LIMIT",
+ "detail": "Reduce the time range or increase the step.",
+ "correlationId": "01...",
+ "fields": {
+ "range": "..."
+ }
+}
+```
+
+No stack trace or secret-bearing upstream response.
+
+## 3. Authoritative route inventory
+
+`specs/api-routes.json` is the machine-readable source for this table. `python tools/check_api_contract.py` verifies that every router registration is represented here, every implementation file exists, and this table has neither missing nor invented method/path combinations. Paths outside `/api/v1` are limited to health, OIDC bootstrap, development-only mock login, and logout.
+
+| Route | Access | Implementation |
+|---|---|---|
+| `GET /healthz` | public | `internal/service/health.go` |
+| `GET /readyz` | public | `internal/service/health.go` |
+| `GET /auth/login` | public-oidc | `internal/authapi/handler.go` |
+| `GET /auth/callback` | public-oidc | `internal/authapi/handler.go` |
+| `GET /auth/test-login` | development-only | `cmd/api/main.go` |
+| `POST /session/logout` | session | `cmd/api/main.go` |
+| `GET /api/v1/system/status` | view | `internal/systemstatusapi/handler.go` |
+| `GET /api/v1/system/diagnostics` | operate | `internal/systemstatusapi/handler.go` |
+| `GET /api/v1/system/metrics` | operate | `internal/observability/metrics.go` |
+| `GET /api/v1/system/backups` | admin | `internal/backupapi/handler.go` |
+| `POST /api/v1/system/backups` | admin | `internal/backupapi/handler.go` |
+| `GET /api/v1/onboarding` | view | `internal/onboardingapi/handler.go` |
+| `POST /api/v1/onboarding` | admin | `internal/onboardingapi/handler.go` |
+| `GET /api/v1/widgets/catalog` | view | `internal/widgetapi/handler.go` |
+| `POST /api/v1/widgets/preview` | edit | `internal/widgetapi/handler.go` |
+| `GET /api/v1/metrics/catalog` | view | `internal/metricsapi/handler.go` |
+| `POST /api/v1/metrics/query` | view | `internal/metricquery/handler.go` |
+| `POST /api/v1/metrics/query-range` | view | `internal/metricquery/handler.go` |
+| `POST /api/v1/metrics/inspect` | operate | `internal/metricquery/handler.go` |
+| `GET /api/v1/live` | view-websocket | `internal/live/live.go` |
+| `GET /api/v1/dashboards` | view | `internal/dashboardapi/handler.go` |
+| `POST /api/v1/dashboards` | edit | `internal/dashboardapi/handler.go` |
+| `GET /api/v1/dashboards/{id}` | view | `internal/dashboardapi/handler.go` |
+| `PATCH /api/v1/dashboards/{id}` | edit | `internal/dashboardapi/handler.go` |
+| `DELETE /api/v1/dashboards/{id}` | edit | `internal/dashboardapi/handler.go` |
+| `PUT /api/v1/dashboards/{id}/document` | edit | `internal/dashboardapi/handler.go` |
+| `POST /api/v1/dashboards/{id}/preview` | edit | `internal/dashboardapi/handler.go` |
+| `POST /api/v1/dashboards/{id}/clone` | edit | `internal/dashboardapi/handler.go` |
+| `GET /api/v1/dashboards/{id}/versions` | view | `internal/dashboardapi/handler.go` |
+| `GET /api/v1/dashboards/{id}/versions/{version}` | view | `internal/dashboardapi/handler.go` |
+| `POST /api/v1/dashboards/{id}/restore` | edit | `internal/dashboardapi/handler.go` |
+| `POST /api/v1/dashboards/{id}/restore/{version}` | edit | `internal/dashboardapi/handler.go` |
+| `GET /api/v1/entities` | view | `internal/inventoryapi/handler.go` |
+| `GET /api/v1/entities/{id}` | view | `internal/inventoryapi/handler.go` |
+| `GET /api/v1/entities/{id}/relations` | view | `internal/inventoryapi/handler.go` |
+| `GET /api/v1/host` | view | `internal/hostapi/handler.go` |
+| `GET /api/v1/processes` | view | `internal/processapi/handler.go` |
+| `GET /api/v1/containers` | view | `internal/containerapi/handler.go` |
+| `GET /api/v1/containers/{id}` | view | `internal/containerapi/handler.go` |
+| `GET /api/v1/applications` | view | `internal/applicationapi/handler.go` |
+| `GET /api/v1/events` | view | `internal/eventapi/handler.go` |
+| `GET /api/v1/applications/{id}` | view | `internal/applicationapi/handler.go` |
+| `GET /api/v1/array` | view | `internal/arrayapi/handler.go` |
+| `GET /api/v1/disks` | view | `internal/diskapi/handler.go` |
+| `GET /api/v1/disks/{id}` | view | `internal/diskapi/handler.go` |
+| `GET /api/v1/pools` | view | `internal/poolapi/handler.go` |
+| `GET /api/v1/pools/{id}` | view | `internal/poolapi/handler.go` |
+| `GET /api/v1/shares` | view | `internal/shareapi/handler.go` |
+| `GET /api/v1/shares/{id}` | view | `internal/shareapi/handler.go` |
+| `GET /api/v1/forecasts` | view | `internal/forecastapi/handler.go` |
+| `GET /api/v1/services` | view | `internal/serviceapi/handler.go` |
+| `GET /api/v1/services/{id}` | view | `internal/serviceapi/handler.go` |
+| `GET /api/v1/services/{id}/history` | view | `internal/serviceapi/handler.go` |
+| `GET /api/v1/services/{id}/dependencies` | view | `internal/serviceapi/handler.go` |
+| `GET /api/v1/topology` | view | `internal/serviceapi/handler.go` |
+| `GET /api/v1/network` | view | `internal/networkapi/handler.go` |
+| `GET /api/v1/reverse-proxy` | view | `internal/reverseproxyapi/handler.go` |
+| `GET /api/v1/alert-rules` | view | `internal/alertapi/handler.go` |
+| `POST /api/v1/alert-rules` | edit | `internal/alertapi/handler.go` |
+| `GET /api/v1/alert-rules/{id}` | view | `internal/alertapi/handler.go` |
+| `PUT /api/v1/alert-rules/{id}` | edit | `internal/alertapi/handler.go` |
+| `GET /api/v1/alert-rules/{id}/versions` | view | `internal/alertapi/handler.go` |
+| `POST /api/v1/alert-rules/{id}/test` | edit | `internal/alertapi/handler.go` |
+| `POST /api/v1/alert-rules/{id}/enable` | edit | `internal/alertapi/handler.go` |
+| `POST /api/v1/alert-rules/{id}/disable` | edit | `internal/alertapi/handler.go` |
+| `GET /api/v1/alerts` | view | `internal/alertopsapi/handler.go` |
+| `GET /api/v1/alerts/{id}` | view | `internal/alertopsapi/handler.go` |
+| `POST /api/v1/alerts/{id}/acknowledge` | operate | `internal/alertopsapi/handler.go` |
+| `POST /api/v1/alerts/{id}/unacknowledge` | operate | `internal/alertopsapi/handler.go` |
+| `GET /api/v1/alert-silences` | view | `internal/alertcontrolapi/handler.go` |
+| `POST /api/v1/alert-silences` | operate | `internal/alertcontrolapi/handler.go` |
+| `POST /api/v1/alert-silences/preview` | view | `internal/alertcontrolapi/handler.go` |
+| `POST /api/v1/alert-silences/{id}/revoke` | operate | `internal/alertcontrolapi/handler.go` |
+| `GET /api/v1/maintenance-windows` | view | `internal/alertcontrolapi/handler.go` |
+| `POST /api/v1/maintenance-windows` | operate | `internal/alertcontrolapi/handler.go` |
+| `POST /api/v1/maintenance-windows/preview` | view | `internal/alertcontrolapi/handler.go` |
+| `POST /api/v1/maintenance-windows/{id}/revoke` | operate | `internal/alertcontrolapi/handler.go` |
+| `GET /api/v1/incidents` | view | `internal/incidentapi/handler.go` |
+| `GET /api/v1/incidents/{id}` | view | `internal/incidentapi/handler.go` |
+| `PATCH /api/v1/incidents/{id}` | operate | `internal/incidentapi/handler.go` |
+| `GET /api/v1/incidents/{id}/notes` | view | `internal/incidentapi/handler.go` |
+| `POST /api/v1/incidents/{id}/notes` | operate | `internal/incidentapi/handler.go` |
+| `POST /api/v1/incidents/{id}/alerts/{alertId}` | operate | `internal/incidentapi/handler.go` |
+| `DELETE /api/v1/incidents/{id}/alerts/{alertId}` | operate | `internal/incidentapi/handler.go` |
+
+## 4. Endpoint behavior
+
+### Session/user
+
+OIDC login and callback are the only public authentication routes. The browser session is server-side and intentionally has no introspection or preference endpoint in v1. Logout clears the opaque session cookie. Sessions use a bounded sliding idle lifetime: a successful authenticated request renews the HttpOnly cookie after half of the idle TTL has elapsed, while a separate absolute TTL always requires a new OIDC login. The defaults are eight hours idle and seven days absolute; production rejects an absolute TTL below 24 hours because an authenticated wallboard is a supported 24-hour workload. The mock test-login route returns `404` unless both the environment is non-production and mock authentication is configured.
+
+### Health
+
+Liveness is served at `/healthz`; dependency-aware readiness is served at `/readyz`. Detailed authenticated state, safe diagnostics, internal metrics and backup operations live below `/api/v1/system/*` with the permissions in the route inventory.
+
+### Dashboards
+
+Save accepts a complete validated dashboard document plus expected revision. Metadata update, archive, preview, clone, version reads and restore routes are listed above. Restore accepts the version in the path or request body and always creates a new current version. Import/export are UI-side validated document transfers in v1 and are not API routes. Partial widget edits may exist internally, but the user save is atomic.
+
+### Widget catalog
+
+The catalog is built from the same validated registry used by dashboard documents. Preview is edit-protected, bounded, does not persist data and applies the same widget validation as a saved document.
+
+### Metrics
+
+`POST /api/v1/metrics/inspect` accepts the same bounded semantic query request as the range endpoint, requires operate permission, and returns the resolved semantic metric, generated approved PromQL, estimated cost and applied series/point limits. It never executes the source query. The `inspector` field on a metric response is omitted for viewers and is safe/redacted for operators. Arbitrary label enumeration is deliberately absent in v1; callers use the allowlisted semantic catalog.
+
+Semantic query example:
+
+```json
+{
+ "metric": "container.cpu.utilization",
+ "scope": {"containerId": "uuid"},
+ "range": {"from": "...", "to": "...", "stepSeconds": 15},
+ "aggregation": "avg",
+ "groupBy": ["container"],
+ "maxSeries": 20,
+ "maxPoints": 4000
+}
+```
+
+### Host
+
+GET /host — authenticated viewer, bounded current host snapshot
+
+The response contains host identity, uptime/boot time, CPU aggregate and bounded per-core values, load averages, memory totals/percentages, bounded filesystems/inodes, network counters/errors/drops, time synchronization and source provenance. Optional hardware contains capability states, stable temperature/fan identities and bounded GPU values. Missing optional capabilities are disabled rather than errors. source.freshness=stale or unavailable telemetry makes status.state=unknown; the endpoint never returns fabricated healthy values.
+### Inventory
+
+Entity list, detail and relation reads expose the persisted inventory. Discovery scheduling and datasource configuration remain worker/runtime configuration, not public API operations in v1. No endpoint mutates host/container state.
+
+`GET /api/v1/entities` accepts bounded `limit` (1–100), opaque `after`, `q`, `type`, `status` and `order=asc|desc` parameters. The stable keyset is effective display name plus entity ID. Each summary exposes effective display/status values, fact/override/relation/source counts and stale-fact count; manual overrides therefore remain visible in both filtering and presentation.
+
+`GET /api/v1/entities/{id}` returns aliases, all source-owned facts, overrides, effective values and incoming/outgoing relation peers. Effective values select a manual/system override before the best non-stale discovered fact while retaining every fact and its source, observation time, confidence and validity. Stale facts, tombstoned peers and absent relations are explicit states. `GET /api/v1/entities/{id}/relations` returns the same deterministic relation projection only. All three endpoints are authenticated, read-only and return safe problem details without database errors.
+
+### Processes
+
+GET /processes?limit=25&sort=cpu&q=...&container=...&after=... — authenticated viewer, bounded read-only process page
+
+The response exposes only PID, normalized process name, state, runtime, CPU/memory counters and optional known container association. Command-line arguments, environment, working directory and control actions are not part of the contract. Supported sort modes are cpu and memory; bounded name/container filters are applied before the cursor so `total` and pagination remain truthful.
+### Containers
+
+GET /containers?limit=25&q=...&state=...&health=...&sort=name&after=... — authenticated viewer, bounded read-only container page
+GET /containers/{id} — authenticated viewer, bounded read-only container detail
+
+The response preserves runtime state separately from health, includes uptime, restart/exit counters, image and digest, resource counters, ports, volumes, networks, project and bounded labels where available. Search, runtime-state and health filters are applied before cursor pagination; supported deterministic sort modes are name, CPU, memory and state. intentionalStop is explicit and must not be inferred as healthy. Source freshness and provenance are retained; unavailable sources return an Unknown snapshot. There are no start, stop, restart, delete or exec endpoints.
+### Applications
+
+GET /applications — authenticated viewer, bounded application snapshot
+GET /applications/{id} — authenticated viewer, one application with components and contributing reasons
+
+Applications are derived from discovered components and persisted user overrides. Aggregation keeps critical and optional component roles explicit, treats a service-down component as unhealthy even when its container is running, and returns reason codes with component IDs. No application endpoint changes infrastructure state.
+### Events
+
+There is no standalone event route yet. Bounded events are projected in their owning domains (for example service history, alert occurrences and incident notes). The contract does not advertise an unimplemented aggregate event store.
+
+### Alerts
+
+```text
+GET /alerts
+GET /alerts/{id}
+POST /alerts/{id}/acknowledge
+POST /alerts/{id}/unacknowledge
+POST /alerts/{id}/silence
+DELETE /alerts/{id}/silence
+
+GET /alert-rules
+POST /alert-rules
+GET /alert-rules/{id}
+GET /alert-rules/{id}/versions
+PUT /alert-rules/{id}
+POST /alert-rules/{id}/test
+POST /alert-rules/{id}/enable
+POST /alert-rules/{id}/disable
+```
+
+### Alert-rule contract (M8-01)
+
+Alert-rule documents conform to `specs/alert-rule.schema.json`. The condition references only a semantic metric name from the server-side metric catalog; arbitrary PromQL, query templates, interpolation, and unbounded scope values are rejected. Reads require `view`; create, update, test, enable and disable require `edit`.
+
+`PUT /alert-rules/{id}` requires the current revision in `If-Match` (or the `revision` query parameter). A stale revision returns `409 REVISION_CONFLICT`. Every accepted document change creates an immutable version; `GET /alert-rules/{id}/versions` returns versions newest first. Enabling and disabling increment the revision and create an audit event.
+
+`POST /alert-rules/{id}/test` accepts an optional rule document and bounded sample value. It evaluates in memory and returns a preview only; it does not create versions, mutate enabled state, evaluate live sources, or write audit events. A rule condition may define a lower (for high-threshold rules) or higher (for low-threshold rules) `recoveryThreshold`; pending and resolve durations are evaluated at UTC observation timestamps. `cooldownSeconds` suppresses duplicate firing notifications after recovery without deleting occurrence history.
+
+### Incidents
+
+Incidents are created by the correlation pipeline in v1. The API exposes bounded list/detail, ownership update, note reads/writes and explicit alert association/disassociation. There is no manual incident-creation route.
+
+### Probes/services
+
+Service list, detail, history, dependency and topology routes are read-only. Probe configuration is owned by the worker/runtime boundary in v1: service detail may expose a bounded safe probe summary, but targets, credentials and mutation/test endpoints are deliberately absent. This prevents the observability API from becoming a general network-request primitive.
+
+### Maintenance
+
+Silences and maintenance windows support bounded list, create, preview and revision-checked revoke operations. Records are immutable evidence after creation; revoke replaces generic update/delete semantics.
+
+### Operations
+
+Administrative backup list/create is exposed at `/api/v1/system/backups`. Safe diagnostics and internal metrics use `/api/v1/system/diagnostics` and `/api/v1/system/metrics`. Restore, backup verification and general audit-log browsing remain CLI/operations procedures until their controlled workflows are implemented; they are not advertised API routes.
+
+## 5. WebSocket
+
+Endpoint:
+
+```text
+GET /api/v1/live
+```
+
+Requirements:
+- authenticated before upgrade;
+- same-origin/allowed-origin check;
+- role-aware subscription authorization;
+- message size/rate limits;
+- heartbeat and idle timeout;
+- per-session subscription/series limits;
+- sequence numbers and resync;
+- explicit unsubscribe;
+- no arbitrary backend URL/query.
+
+Message schemas are in `specs/live-message.schema.json`.
+
+## 6. Idempotency
+
+Use idempotency keys for:
+- acknowledgement;
+- incident note/create where retries matter;
+- notification delivery;
+- backup start;
+- manual discovery.
+
+## 7. Compatibility
+
+- Breaking changes require `/api/v2` or a documented migration.
+- Schema versions are explicit in dashboard/export/live messages.
+- Old dashboard versions are migrated through tested deterministic migrations.
+
+### Array and parity
+
+`GET /array` is an authenticated viewer endpoint for one bounded, read-only array snapshot. The response contains source freshness/provenance, operational/degraded/missing/unknown state, parity presence/state/errors, bounded data/parity members, current check progress/speed/errors and bounded check history. Stale or unavailable storage source data is `Unknown`; there are no array start, stop, check or correct endpoints.
+### Disks
+
+`GET /disks?limit=...` and `GET /disks/{id}` are authenticated viewer endpoints for bounded, read-only disk inventory and detail. Responses preserve source freshness/provenance and expose canonical stable source IDs, role, availability state, model, privacy-aware serial display, filesystem, size, used/free/utilization, separate `capacitySeverity` and `thermalSeverity`, and optional inode capacity. Missing-disk history remains visible; raw serials are never returned. Exact duplicate source observations are idempotently collapsed, while conflicting observations for one canonical ID fail closed. No format, mount, unmount, remove or repair endpoint exists.
+### SMART detail
+
+Disk detail may include a bounded SMART object with capability state (`available` or `unknown`), generic overall result, freshness timestamp, explicit critical attributes/reasons and self-test result/age. Pending, reallocated, offline-uncorrectable, CRC and wear signals remain separate attributes; a generic `passed` result does not suppress an attention reason. SMART has no write or self-test trigger endpoint.
+### Disk telemetry
+
+Disk detail may include bounded performance samples (read/write bytes per second, IOPS and latency), temperature state/value and optional spin capability. Unsupported latency or spin is explicit rather than fabricated; history is bounded and deterministically ordered. Temperature status applies warning/critical/recovery hysteresis, while live/history values remain read-only.
+
+### Pools
+
+`GET /pools?limit=...` and `GET /pools/{id}` are authenticated viewer endpoints for bounded, read-only cache/Btrfs/ZFS pool snapshots and detail. Responses preserve source freshness/provenance and expose filesystem, device-health state, usable/used/free capacity, separate `capacitySeverity`, profile/redundancy, deduplicated bounded members, filesystem errors, scrub state/history and explicit capability states. Capacity pressure never rewrites device health. Stale pool data maps every pool state and severity to `Unknown`; no scrub, repair, balance, mount or pool-control endpoint exists.
+### Shares
+
+`GET /shares?limit=...` and `GET /shares/{id}` are authenticated viewer endpoints for bounded, read-only share usage and growth. Responses expose configured storage policy, source-owned size observations, cache/pool placements, bounded growth history and scan-plan cost metadata. Size scans are rate-limited/cached through the normalized scan plan; stale or unavailable observations are `Unknown`. No path, filename, directory listing or file-content field is returned.
+
+### Storage map and heatmap views
+
+The storage view composes authenticated read-only array, pool and disk snapshots in the browser. Array membership and disk telemetry with the same canonical physical ID form one disk node; a pool aggregate remains a separate meaningful role. Every node states availability/device health, capacity and temperature independently, while the strongest trustworthy severity determines its visual accent. Map nodes retain entity links and text/icon state; the temperature heatmap is bounded and always includes a table/text alternative. No new control endpoint or raw source credential is exposed.
+### Capacity forecasts
+
+`GET /api/v1/forecasts` is an authenticated, read-only endpoint. Each bounded assessment exposes the entity identity, method, historical window, point count, confidence and reason. `qualifiedCount` counts only medium/high forecasts and is intentionally distinct from `items.length`, which may include insufficient or stale assessments. A projected date is only returned for qualified (`medium` or `high`) median-rate forecasts; disabled policy, insufficient data, bulk-import detection, irregular intervals and unknown/reached capacity remain explicit states without a false date. When no real capacity entity exists, `items` is empty and the top-level `reason` explains the Unknown state; the API never synthesizes a blank `none`/0-byte entity. The response contains no write or capacity-management operation.
+### M7 service and probe model
+
+Service and probe configuration is versioned and archive-oriented. Services, endpoints and probes expose revisions for optimistic concurrency; active names are unique per parent while archived configurations remain available for historical result retention. Probe results and certificate observations are append-only by probe/service and UTC observation time. Service dependencies retain source and confidence, and service permissions map existing Pulse roles to view/operate/edit/admin without storing probe credentials.
+### M7 probe target safety
+
+Probe target policy is evaluated server-side before every request and again for redirects. HTTP/HTTPS GET/HEAD are the default methods; request headers, response headers/body, timeout and redirect count are bounded. Loopback, link-local, metadata, multicast and unspecified addresses are blocked, and private/LAN targets require an explicit CIDR allowlist. DNS answers are revalidated at dial time so a later blocked answer cannot be used for DNS rebinding. Policy changes emit audit events with redacted bounded policy metadata.
+### M7 probe execution
+
+Probe execution supports HTTP/HTTPS, TCP, DNS and TLS within the server-side network policy. HTTP status and bounded keyword/JSON assertions are represented as `up`, `down`, `degraded` or `unknown`; redirect following is opt-in and each hop is revalidated. TLS results retain bounded expiry, issuer, subject and hostname-validity facts. ICMP is capability-aware and returns `Unknown` with an `unsupported` class when the runtime cannot provide it. Probe configuration may contain only a secret reference; credentials are never returned by the API or included in result errors.
+### M7 service status and history
+
+`GET /api/v1/services?limit=...` and `GET /api/v1/services/{id}` are authenticated, read-only service projections. `GET /api/v1/services/{id}/history?limit=...` returns bounded probe history. The projection derives service state from probe results independently of container state, preserves last success/failure and latency, calculates bounded availability, and maps absent or stale probe data to `unknown`. Results, histories and transition events are deterministically ordered and capped. Service detail may include a bounded, read-only probe configuration summary (id, name, type, interval, timeout, enabled state and safe TLS/redirect flags); target payloads, content assertions, secret references and credentials are never returned.
+
+Service snapshots expose separate `capabilityState` (`available`, `unavailable`, `unsupported`) and `configurationState` (`configured`, `not_configured`, `unknown`). A catalog without services is `available/not_configured`; an unreadable repository is `unavailable/unknown`. Per-service reasons distinguish no configured probe, disabled probes, pending results and stale results. These states are also propagated into topology and each network-health scope so an empty configuration is never presented as a generic source failure.
+
+
+`GET /api/v1/services/{id}/dependencies?limit=...` is an authenticated, read-only bounded relation projection. Dependencies are sorted deterministically and expose source, confidence and confirmed/inferred state; dependency writes remain server-side and audit-backed.
+
+`GET /api/v1/topology?limit=...` is an authenticated, read-only graph projection bounded to at most 100 nodes and 200 edges by default. Nodes expose service status and whether they are present in the current snapshot; missing nodes remain explicit `unknown`. Edges combine active service dependencies with active `depends_on`, `backs` and `exposes` inventory relations when both inventory entities map to active services. Every edge preserves relation type, source, confidence and confirmed/inferred state, is sorted deterministically, and never asserts causality. The endpoint has no write operation.
+
+`GET /api/v1/network` is an authenticated, read-only network health projection. It separates internal interface health, gateway, DNS and internet scopes; stale/unavailable host telemetry maps interfaces and internal health to `unknown`, while independent internet or DNS failures remain independently visible. Interface RX/TX bytes, errors and drops are bounded and certificates are exposed as safe observed facts without probe credentials.
+
+GET /api/v1/reverse-proxy is an authenticated, read-only projection of optional reverse-proxy host mappings. The connector reports a versioned capability and source ownership, returns only bounded host/URL-to-service mappings, and exposes a disabled or unavailable state when no connector is configured. Connector credentials remain external references and are never serialized. Duplicate host mappings with different targets are rejected unless a user-confirmed override explicitly selects the effective service; no proxy configuration mutation endpoint exists. Topology may represent enabled mappings as reverse_proxy nodes with exposes edges that retain source and confirmed/inferred provenance.
+
+### Alert silences and maintenance windows (M8-06)
+
+~~~text
+GET /alert-silences
+POST /alert-silences
+POST /alert-silences/preview
+POST /alert-silences/{id}/revoke
+GET /maintenance-windows
+POST /maintenance-windows
+POST /maintenance-windows/preview
+POST /maintenance-windows/{id}/revoke
+~~~
+
+Reads and matcher previews require view; create and revoke require operate. Silences and maintenance windows require a non-empty reason, bounded matcher and selector fields, and an expiry or end time. Revocation uses If-Match or the revision query parameter. Controls never delete alert history. Every create and revoke is audited; expiry is idempotently marked by the API expiry job.
+
+
+### Alert operations (M8-07)
+
+GET /alerts returns a bounded deterministic list of non-inactive alert instances; the state query filter is allow-listed. GET /alerts/{id} returns rule/entity metadata, current state, revision and bounded occurrence history.
+
+POST /alerts/{id}/acknowledge and POST /alerts/{id}/unacknowledge require operate permission, If-Match or a positive revision query parameter, and a bounded Idempotency-Key or evaluationKey. A replay with the same key is idempotent even when the original revision is stale. Acknowledge changes firing or pending to acknowledged; unacknowledge changes acknowledged back to firing. Resolved state remains a persisted evaluator result and is not rewritten by a read or duplicate operation. Accepted and idempotent operations are audited.
+
+### Notification delivery contract
+
+Notification delivery is an internal worker contract in M8-08. Public alert operations create no direct channel side effect: an event is first represented by a bounded outbox record with an idempotency key. Workers claim and complete records transactionally, and delivery history remains queryable for audit. Channel test actions are explicitly bounded and rate limited; no secret value is returned by the channel or delivery model.
+### M8-09 incident contract
+
+`GET /incidents` and `GET /incidents/{id}` are bounded viewer operations. `POST /incidents/{id}/alerts/{alertId}` and `DELETE /incidents/{id}/alerts/{alertId}` require operate permission and create audit events. Manual association requests require bounded rationale and confidence; the response exposes whether the association was idempotent. Incident operations remain observational and do not remediate alerts, services, or infrastructure.
+
+### M8-10 incident detail, ownership and notes
+
+`GET /incidents/{id}` returns bounded incident detail, associations and ordered notes. `PATCH /incidents/{id}` requires operate permission and updates only the owner user ID with the current positive revision and returns a conflict for stale writes. `GET /incidents/{id}/notes` is viewer-readable; `POST /incidents/{id}/notes` requires operate permission and stores the authenticated actor as author. Note bodies are bounded plain text: tag-shaped markup is removed, whitespace is normalized, and empty or oversized values are rejected. Incident UI communicates correlation confidence and rationale as uncertainty-aware evidence; Pulse provides no remediation action.
+
+## Onboarding
+
+`GET /api/v1/onboarding` is an authenticated viewer endpoint. It returns only bounded readiness states for the database, Prometheus, read-only Unraid source, authentication, alert defaults and the default dashboard. It never returns datasource URLs, API tokens, OIDC client secrets or other secret-bearing configuration.
+
+`POST /api/v1/onboarding` requires the administrator permission and accepts only `dashboard` and `rules` choices with values `default` or `skip`. Completion is additive and resumable: it records progress in `system_settings`, installs the validated default dashboard only when the stable slug is absent, and preserves existing dashboards and alert rules. It exposes no host, Docker, storage, database-control or remediation operation.
+
+## Authentication bootstrap
+
+`GET /auth/login` and `GET /auth/callback` are the only unauthenticated non-health endpoints. They are browser redirect endpoints outside `/api/v1`; they return `302` rather than JSON, except for a problem-details `405` on a non-`GET` method.
+
+`GET /auth/login` starts the Authentik OIDC authorization code flow with state, nonce and PKCE `S256`. State, nonce, PKCE verifier and the requested post-login path are stored server-side in a bounded, TTL-expired flow store; the browser receives only an opaque flow identifier in a short-lived `HttpOnly`, `SameSite=Lax` cookie that is `Secure` in production. The optional `redirect` query parameter is accepted only as an in-app path with a single leading slash: protocol-relative, absolute, backslash-prefixed, control-character and oversized values fall back to `/`. The response redirects to the provider authorization URL.
+
+`GET /auth/callback` consumes the flow identifier exactly once, deleting the stored flow before validating anything. It compares state in constant time, exchanges the code with the PKCE verifier, verifies the ID token issuer, audience, signature and nonce, maps the configured role claim to a Pulse role and only then issues the session cookie and redirects to the validated post-login path. An unknown, replayed or expired flow, a state or nonce mismatch, a provider `error` response, a failed exchange, a missing ID token, an unmapped role and a failed audit or session write all issue no session and redirect to a fixed in-app error route with one of a closed set of reason codes (`invalid_request`, `expired`, `denied`, `provider_unavailable`, `not_authorized`, `unavailable`). Provider-supplied error text, authorization codes, tokens and PKCE verifiers are never reflected into a response or logged.
diff --git a/docs/architecture/DATA_MODEL.md b/docs/architecture/DATA_MODEL.md
new file mode 100644
index 0000000..5dafeec
--- /dev/null
+++ b/docs/architecture/DATA_MODEL.md
@@ -0,0 +1,371 @@
+# Data model
+
+## 1. Principles
+
+- Stable internal UUIDs; external IDs are source-scoped aliases.
+- Source facts and user overrides are stored separately.
+- Soft deletion/tombstones preserve history and reconciliation.
+- Important configuration is versioned.
+- Alert state changes are transactional.
+- Time is stored in UTC; locale/time zone applied at presentation.
+- JSONB is used for bounded extensible attributes, not as a substitute for core relational structure.
+
+## 2. Identity and access
+
+### `users`
+- id
+- external_subject
+- display_name
+- email
+- status
+- created_at / updated_at / last_login_at
+
+### `roles`, `user_roles`
+Roles: viewer, operator, editor, administrator.
+
+### `sessions` or external session metadata
+Only if required by the chosen auth implementation. Avoid storing OIDC tokens unnecessarily.
+
+## 3. Datasources
+
+### `data_sources`
+- id
+- type
+- name
+- enabled
+- configuration reference
+- capability document
+- health_state
+- last_success_at
+- last_error_code/message_redacted
+- freshness policy
+- created/updated
+
+### `collectors`
+- id
+- datasource_id
+- kind
+- version
+- heartbeat
+- capabilities
+- status
+
+## 4. Inventory
+
+### `entities`
+- id UUID
+- entity_type
+- canonical_name
+- display_name
+- status
+- status_reasons JSONB
+- first_seen_at
+- last_seen_at
+- tombstoned_at
+- attributes JSONB
+
+### `entity_aliases`
+- entity_id
+- source_id
+- external_type
+- external_id
+- unique(source_id, external_type, external_id)
+
+### Container identity and recreation
+
+Container runtime IDs are source-scoped aliases, not logical identity by themselves. A live runtime ID always matches its exact source/runtime alias. When a runtime ID changes, the same logical entity may be reused only when both the source-scoped compose project and compose service are present and uniquely identify one prior active alias. A container name, image, or digest alone is insufficient evidence and creates a new logical entity. The old runtime alias remains in history as inactive, the new alias points to the same logical entity, and recreation events keep that logical entity as events.entity_id while retaining previous/current runtime IDs in bounded attributes.
+### `entity_facts`
+- entity_id
+- field_name
+- source_id
+- value JSONB
+- observed_at
+- confidence
+- valid_until
+
+### `entity_overrides`
+- entity_id
+- field_name
+- value JSONB
+- user_id
+- updated_at
+
+### `entity_relations`
+- id
+- source_entity_id
+- relation_type
+- target_entity_id
+- source_id
+- confidence
+- confirmed
+- first_seen/last_seen/tombstoned
+
+### applications
+
+- logical application ID and display name
+- discovered component references
+- critical/optional component policy
+- aggregate status and bounded reason list
+- user override metadata kept separate from discovery grouping
+## 5. Metrics catalog
+
+### `metric_definitions`
+- id
+- semantic_name unique
+- version
+- description
+- unit
+- value_kind
+- source_kind
+- query_template
+- label contract
+- limits
+- allowed visualizations
+- default transformations
+- status policy reference
+- enabled
+
+### `metric_bindings`
+Maps semantic metric to datasource/exporter/capability variants.
+
+Metric samples remain in Prometheus.
+
+## 6. Dashboards
+
+### `dashboards`
+- id
+- slug
+- name
+- description
+- owner_user_id nullable
+- scope personal/shared/system
+- archived_at
+- current_version_id
+- revision (optimistic concurrency token)
+- created/updated
+
+### `dashboard_versions`
+- id
+- dashboard_id
+- version_number
+- schema_version
+- document JSONB
+- change_summary
+- created_by
+- created_at
+
+Dashboard versions are immutable. Dashboard writes lock the current row, compare revision, and commit the new version/current pointer atomically. The API stores only bounded revision metadata in audit diffs; full dashboard documents are not copied into audit events.
+
+A dashboard version contains variables, widget instances, behavior and layout documents. Save as one atomic version to avoid partial layout/config updates.
+
+Optional normalized indexes may extract widget type/entity references for search/impact analysis.
+
+## 7. Events
+
+### `events`
+- id
+- event_type
+- severity
+- entity_id nullable
+- source_id
+- occurred_at
+- received_at
+- dedup_key
+- summary
+- attributes JSONB bounded/redacted
+- correlation_id
+- unique(source_id, dedup_key, occurred_at bucket) as appropriate
+
+## 8. Alerts
+
+### `alert_rules`
+- id
+- name
+- enabled
+- severity
+- evaluator type/config
+- scope selector
+- pending duration
+- resolve duration
+- cooldown duration for repeated firing notifications
+- hysteresis config
+- grouping labels
+- suppression/dependency policy
+- current_version_id
+- revision (optimistic concurrency token)
+- created/updated
+
+### `alert_rule_versions`
+Immutable rule documents and audit metadata.
+
+### `alert_instances`
+Stable entity/rule combination:
+- id
+- rule_id
+- fingerprint
+- entity_id
+- current_state
+- active_since
+- last_evaluated_at
+- last_value
+- reason
+- acknowledged_by/at
+- cooldown_until
+- silenced_until
+- version for optimistic concurrency
+
+### `alert_occurrences`
+Immutable transitions/evaluation outcomes relevant to history.
+
+## 9. Incidents
+
+### `incidents`
+- id
+- title
+- summary
+- severity
+- status
+- started_at
+- resolved_at
+- owner_user_id
+- correlation_method
+- confidence
+- created/updated
+- version
+
+### `incident_alerts`, `incident_entities`
+Many-to-many links with rationale.
+
+### `incident_notes`
+- incident_id
+- author
+- body sanitized
+- created_at
+
+## 10. Maintenance and notifications
+
+### `maintenance_windows`
+- id
+- name
+- selector
+- start/end or recurrence
+- suppress notifications/state policy
+- creator/audit
+
+### `notification_channels`
+Encrypted secret references and non-secret config.
+
+### `notification_deliveries`
+- occurrence/incident
+- channel
+- status
+- attempts
+- last_error_redacted
+- timestamps
+- idempotency_key
+
+## 11. Audit
+
+### `audit_events`
+- actor/user/service
+- action
+- resource type/id
+- result
+- occurred_at
+- correlation_id
+- source IP/session metadata where appropriate
+- before/after bounded diff with secret fields excluded
+
+## 12. Operations
+
+### `job_runs`
+- job type/key
+- scheduled/started/completed
+- status
+- counts
+- error code
+- correlation ID
+- lease owner and lease expiry for coordinated worker execution
+
+### `schema_migrations`
+Managed by migration tool.
+
+### `system_settings`
+Typed/versioned settings; no plaintext secrets.
+
+## 13. Concurrency
+
+Use optimistic concurrency for:
+- dashboard save;
+- alert acknowledgement/state;
+- incident edit;
+- settings.
+
+Return a conflict response with current version rather than silently overwriting.
+
+## 14. Retention
+
+Implement partitioning/cleanup when measurements justify it.
+
+Baseline:
+- inventory history/tombstones: enough for reconciliation and events;
+- events: 1 year configurable;
+- alert occurrences: 2 years configurable;
+- incidents: retained until explicit policy;
+- audit: at least 1 year configurable;
+- job runs: shorter operational retention;
+- dashboard versions: at least 180 days or fixed count plus protected versions.
+
+### arrays and parity checks
+
+An array snapshot preserves source-scoped provenance and UTC observed/received timestamps. Array members retain stable source IDs, role, state and bounded capacity/I/O counters. Parity state is separate from array state, and current/history parity checks retain progress, byte-per-second speed, error count and timestamps. Missing, disabled or emulated members are represented explicitly; stale source data maps to Unknown. Array transition events retain the array entity ID and bounded state/error attributes without exposing operational controls.
+### disks and capacity
+
+Disk identity is source-scoped and stable from the provider ID. A disk retains role, state, model, privacy-aware serial display, filesystem and bounded size/used/free/inode metrics. Used values above capacity are rejected; free space and utilization are derived from validated bytes, and inode utilization is derived separately. Missing disks remain in current/history projections instead of being dropped, while raw serials are excluded from API output.
+### SMART facts
+
+SMART is an optional disk capability with its own observed timestamp and freshness state. Normalization preserves generic overall result, bounded mapped attributes, critical/reason status and self-test result/age. Unavailable or stale SMART is Unknown and never Healthy; critical pending, reallocated, offline-uncorrectable, CRC or wear attributes remain visible even when the vendor overall result is passed.
+### disk telemetry facts
+
+Disk performance history is bounded and timestamped for live/history consumers. Temperature observations retain source time and policy-derived status; recovery does not clear attention until the configured recovery threshold is crossed. Latency/spin capabilities explicitly report unsupported when the source cannot provide them.
+
+### pools and scrub facts
+
+Pool identity is stable within its source and retains filesystem, usable/used/free bytes, profile/redundancy, bounded member state and filesystem error facts. Btrfs and ZFS capabilities are explicit and conditional; unsupported fields are never fabricated. Scrub current state and bounded history retain progress, bytes checked, errors and UTC timestamps. Stale source data maps pool state to Unknown while preserving provenance. Transition events identify degraded/faulted/recovered pools and scrub failures without exposing controls.
+### shares and growth facts
+
+Share identity is source-scoped and retains configured allocation/cache policy, source-owned used-size timestamp/state, bounded placement by pool and chronological growth points. Growth deltas and daily rates are derived from UTC observations, including negative changes, without exposing paths or file content. `ScanPlan` caps due size refreshes per run and records deferred work plus cache TTL so expensive scans remain bounded and observable.
+
+### storage visualization projections
+
+Storage map nodes are bounded projections of array members, pools and disks with entity links, kind, explicit state and detail text. Temperature heatmap points retain disk identity, UTC observed time, value and text status; visual cells never replace the accessible table alternative.
+### capacity forecasts
+
+A forecast is a bounded projection over UTC usage observations. Its policy records enabled state, maximum window, minimum points and method. Median daily growth is used for linear projection; bulk-import/outlier and irregular-interval safeguards lower confidence and suppress `daysToCapacity`/`projectedAt`. Method, window, point count, confidence and reason are always retained so the UI cannot present an unexplained precise date.
+
+`capacity_samples` preserves bounded historical capacity observations for shares, pools and disks without storing file contents. Agent snapshot writes and their samples commit in one PostgreSQL transaction. A six-hour UTC bucket and primary key on `(entity_kind, entity_id, source_id, sampled_at)` make repeated discovery runs idempotent; a delayed retry may not replace a newer observation in the same bucket. The history index `(entity_kind, entity_id, sampled_at DESC)` supports the actual bounded forecast query. Forecast reads use at most 512 points inside the configured window and combine persisted share usage with the current canonical pool capacity. Insufficient, stale or unavailable histories remain assessments with `confidence=none`; only medium/high results count as qualified forecasts.
+### services and probes
+
+`services`, `service_endpoints` and `probes` represent reachable capabilities independently of container state. Configuration rows use `revision`, `updated_at` and `archived_at`; active names are unique per parent, while archived rows remain to preserve history. `probe_results` stores immutable timestamped outcomes, and `service_certificates` stores observed certificate facts. `service_dependencies` preserves source, confidence and confirmation state. `service_permissions` maps database roles to service permissions; secret references are identifiers only and never plaintext credentials.
+### service status projections
+
+Service status is derived from immutable `probe_results`, not from container state. The bounded projection retains current state/reason, last result/success/failure timestamps, latest latency, availability sample counts and bounded history. A result older than the configured freshness policy becomes `unknown`; it is never silently treated as healthy. State transitions produce deterministic service events with service identity, from/to state and reason.
+M8-06 persistence adds alert_silences and maintenance_windows with bounded reason and name fields, JSON matcher or selector, UTC start and end timestamps, explicit active/expired/revoked status, creator and owner provenance, revocation and expiry timestamps, revision, expiry indexes, and deterministic listing indexes. These tables are additive and do not alter M1 tables or alert occurrence history.
+
+M8-07 extends alert_occurrences with an immutable unacknowledge event type and adds an acknowledged-state index. Alert list/detail projections join rule and optional entity metadata while preserving instance revision and occurrence history.
+
+### M8-08 notification persistence
+
+- `notification_channels` stores only a bounded non-secret configuration and a reference to an external secret provider; the secret value is never part of the domain object or query result.
+- `notification_outbox` has a unique idempotency key, bounded event content, a lease-aware status, bounded attempts and deterministic due ordering.
+- `notification_deliveries` records each claimed attempt with a unique `(outbox_id, attempt)` key. Claim, delivery audit creation, completion and retry state changes are transactionally coordinated.
+- Channel updates use optimistic revisions. Outbox rows retain recovery events and are not deleted after successful delivery, preserving auditability.
+### M8-09 incident persistence
+
+- `incidents` stores a bounded correlation key, title/summary, derived severity, lifecycle status, start/resolution timestamps, rationale method, confidence, owner and optimistic revision. Only one unresolved incident may exist for a correlation key.
+- `incident_alerts` preserves per-alert rationale/confidence and distinguishes deterministic correlation from manual association. A manual association is never overwritten by a later correlation upsert.
+- `incident_entities` keeps the affected entity set with bounded rationale/confidence and restricts entity deletion while incident evidence references it.
+
+### M8-10 incident notes and ownership
+
+`incident_notes` is bounded operator context attached to an incident. Bodies are normalized to plain text, stripped of tag-shaped markup and capped at 2,000 characters before persistence; notes are ordered by UTC creation time and stable ID. Notes cascade with their incident and never affect alert/entity evidence. Incident ownership is metadata updated with an optimistic revision, so stale UI writes become an explicit conflict.
diff --git a/docs/architecture/SECURITY_THREAT_MODEL.md b/docs/architecture/SECURITY_THREAT_MODEL.md
new file mode 100644
index 0000000..a20c4b2
--- /dev/null
+++ b/docs/architecture/SECURITY_THREAT_MODEL.md
@@ -0,0 +1,248 @@
+# Security threat model
+
+## 1. Assets
+
+- Unraid host and storage.
+- Docker/container metadata and internal topology.
+- Prometheus metrics and labels.
+- Service URLs and availability data.
+- OIDC identities/roles.
+- Pulse configuration, alerts, incidents and audit.
+- Notification/probe credentials.
+- Database backups.
+- Server access path used by Codex during deployment.
+
+## 2. Trust boundaries
+
+- Browser to Pulse.
+- Pulse to Authentik.
+- Pulse to PostgreSQL.
+- Pulse to Prometheus.
+- Pulse agent to host/Unraid/Docker.
+- Probe worker to network targets.
+- Notification worker to external channels.
+- Codex workspace to production server.
+
+## 3. Primary threats and controls
+
+### M0 discovered deployment posture
+
+The target host already runs Nginx Proxy Manager, Authentik, Grafana, and multiple Docker/Compose projects. Pulse treats all of them as external protected resources. Existing containers with broad privileges, including any Docker socket access, are not reused as a Pulse pattern. Pulse must be isolated on dedicated resources, integrate with the proxy and OIDC provider additively, and remain server-side for all Unraid/Prometheus access.
+
+M0 did not identify a local Prometheus service; this is an explicit datasource uncertainty, not permission to substitute an unreviewed source. Missing or stale telemetry must map to `Unknown`. No hostname, port, network, volume, or path is trusted until the deployment task rechecks ownership and conflicts.
+
+### Unrestricted Docker/host control
+
+Threat: compromise of web/API leads to host root-equivalent access.
+
+Controls:
+- no unrestricted socket in web/API;
+- separate agent/proxy;
+- endpoint allowlist;
+- read-only capability contract;
+- non-root API;
+- network separation;
+- architecture test of compose/mounts;
+- no mutation API in v1.
+
+### SSRF from service probes
+
+Threat: user config probes metadata, loopback, admin services or redirects.
+
+Controls:
+- role restriction;
+- scheme/port allowlist;
+- DNS resolution validation before and after redirect;
+- block metadata/link-local/unspecified by default;
+- configurable LAN allowlist;
+- response size/time limits;
+- no arbitrary methods/body;
+- redacted logging;
+- tests for DNS rebinding/redirect escape.
+
+### Query abuse
+
+Threat: expensive or injection-like Prometheus queries cause outage or expose labels.
+
+Controls:
+- semantic query templates;
+- bounded scope/range/series/points;
+- server-side parameterization/escaping;
+- timeout/concurrency/rate limit;
+- advanced raw query separate permission;
+- audit and query cost metrics.
+
+
+### Optional hardware capabilities
+
+Hardware sensors and GPU telemetry are optional read-only capabilities. The adapter accepts only normalized bounded snapshots from an approved source; absent support is disabled and unsupported support remains inspectable without being treated as a host failure. API/web never gains device, mount, namespace or Docker-socket access for these values.
+### Authentication/authorization bypass
+
+Controls:
+- standards-based OIDC validation;
+- issuer/audience/nonce/state/PKCE;
+- secure cookies;
+- server-side RBAC;
+- WebSocket auth/origin/subscription auth;
+- CSRF protection where cookies are used;
+- role matrix tests;
+- break-glass disabled by default.
+
+### XSS and dashboard import
+
+Controls:
+- no arbitrary HTML/JS widgets;
+- sanitize Markdown;
+- schema validation;
+- safe chart labels/tooltips;
+- CSP;
+- escaped event/upstream text;
+- import size and complexity limits.
+
+### Secret leakage
+
+Controls:
+- external secret injection;
+- encrypted-at-rest channel/probe references;
+- redaction middleware;
+- no env dumps;
+- evidence policy;
+- secret scan;
+- diagnostic bundle allowlist;
+- backups exclude plaintext or are encrypted/secured.
+
+### Supply chain
+
+Controls:
+- lockfiles;
+- minimal maintained dependencies;
+- provenance/SBOM where feasible;
+- vulnerability scanning;
+- pinned base images;
+- non-root runtime;
+- update policy;
+- build in CI/clean environment.
+
+### Database compromise/data integrity
+
+Controls:
+- isolated network;
+- dedicated credentials;
+- TLS when remote;
+- least privilege;
+- migrations/transactions;
+- backup/restore;
+- input validation;
+- audit;
+- no exposed database port unless controlled testing override.
+
+### Live/WebSocket abuse
+
+Controls:
+- authentication before upgrade;
+- opaque HttpOnly sessions with an eight-hour sliding idle limit and a finite,
+ operator-bounded absolute limit; renewal never exposes OIDC tokens to the
+ browser, while a revocable session context propagates through HTTP upgrades
+ so logout, absolute expiry and request/server cancellation also close an
+ already established socket and release its subscriptions;
+- origin policy;
+- message/rate/size limits;
+- max subscriptions/series;
+- idle timeout/heartbeat;
+- bounded send queue and slow-client eviction;
+- no secret data in messages.
+
+### Alert/notification abuse
+
+Controls:
+- RBAC and audit;
+- versioned rules;
+- safe templates;
+- channel test rate limits;
+- idempotency;
+- recipient allowlist/policy;
+- no secret values in notification body.
+
+### Deployment mistakes
+
+Controls:
+- discovery and port/network/volume conflict checks;
+- backup touched configs;
+- isolated compose project;
+- offline validation;
+- health/smoke tests;
+- rollback;
+- no prune/delete/unrelated modifications;
+- production evidence.
+
+## 4. Security headers
+
+At minimum:
+- Content-Security-Policy;
+- frame restrictions;
+- nosniff;
+- strict referrer policy;
+- permissions policy;
+- HSTS when HTTPS deployment is stable;
+- secure/same-site/httpOnly cookies.
+
+## 5. Container hardening
+
+Where compatible:
+- non-root;
+- read-only root filesystem;
+- tmpfs for temporary paths;
+- drop all capabilities, add only required;
+- no-new-privileges;
+- seccomp/default profile;
+- resource limits;
+- explicit networks;
+- no public database/collector ports;
+- healthchecks;
+- immutable image digest in production record.
+
+The agent may need narrow exceptions; document and test each.
+
+### Deployment hardening pass (2026-08-04)
+
+- Resource limits are set on all six `deploy/compose.yaml` services via the
+ non-swarm `cpus`/`mem_limit`/`mem_reservation`/`memswap_limit` keys (the
+ project runs plain `docker compose up`, not swarm); sizing rationale is
+ inline in that file against `docs/architecture/SYSTEM_ARCHITECTURE.md` §7.
+- `pulse-postgres` now runs `read_only: true` with tmpfs for `/tmp` and
+ `/var/run/postgresql`; all six services are now read-only-root. This
+ closes the previously undocumented exception; see
+ `docs/operations/DEPLOYMENT_UNRAID.md` §6/§8 for the required smoke test.
+- Immutable image digests are enforced by `deploy/verify-image-digests.sh`
+ and CI. Every external registry image in `deploy/*.Dockerfile` is pinned to
+ a verified digest; only Docker's built-in `scratch` rootfs is exempt because
+ it has no registry manifest. See `deploy/IMAGE_DIGESTS.md` for the ledger.
+- `pulse-worker`/`pulse-agent` healthchecks now verify a heartbeat file's
+ freshness instead of `kill -0 1`, and self-restart the container on
+ staleness (`docker compose up` does not restart on "unhealthy" status
+ alone). Contract for the Go runtime:
+ `docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md`.
+- `deploy/nginx.conf` now sends `Strict-Transport-Security` from
+ `pulse-web` as defence-in-depth (§4), verified not to conflict with
+ TLS terminating at Nginx Proxy Manager per ADR-0010.
+
+## 6. Security acceptance
+
+Required:
+- threat model review at M0 and M9;
+- SAST/dependency/image/secret scans;
+- auth/RBAC matrix tests;
+- SSRF suite;
+- WebSocket security suite;
+- dashboard import/XSS suite;
+- query limit/validation suite;
+- compose privilege/mount test;
+- backup secret inspection;
+- production exposure scan from permitted network.
+
+### M7-02 implementation
+
+The probe policy validates scheme, host and port before resolution, rejects loopback/link-local/metadata/multicast/unspecified addresses, and requires explicit CIDR permission for private LAN targets. The safe client repeats resolution at dial time and revalidates redirect destinations. Only bounded GET/HEAD requests with an allowlisted header set are accepted; responses are size/time limited. Policy changes are audit events containing no credentials or full request data.
+### M8-08 notification controls
+
+Notification channel persistence accepts only secret references and rejects sensitive configuration keys such as token, password, secret, and authorization. Delivery bodies and subjects are bounded. Sender errors are newline-normalized, length-bounded, and redacted before persistence; bearer credentials are removed as a complete value. Test sends are rate limited and never write delivery records.
diff --git a/docs/architecture/SYSTEM_ARCHITECTURE.md b/docs/architecture/SYSTEM_ARCHITECTURE.md
new file mode 100644
index 0000000..f3767b4
--- /dev/null
+++ b/docs/architecture/SYSTEM_ARCHITECTURE.md
@@ -0,0 +1,240 @@
+# System architecture
+
+## 1. Context
+
+ITWorx Pulse sits between the browser/operator and existing telemetry/inventory sources. It provides no operational write path to Unraid or Docker in v1.
+
+### M0 environment binding
+
+The discovered target is one Unraid 7.2.2 host with the native Unraid GraphQL API online, existing Nginx Proxy Manager and Authentik services, Grafana and host telemetry endpoints, and no local Prometheus container/listener identified during read-only discovery. Pulse therefore remains an additive, isolated Compose project. Host port, DNS name, appdata path, proxy record, and Docker network names are intentionally runtime discovery outputs and are not hardcoded in this architecture document.
+
+The API/web trust boundary is unchanged by discovery: neither service receives a Docker socket or host filesystem privilege. The agent uses an allowlisted read-only Unraid API capability path. A separately configured Prometheus-compatible endpoint is queried only server-side; if it is unavailable or stale, the datasource and dependent status are `Unknown`.
+
+```text
+Browser
+ |
+ | HTTPS / OIDC / REST / WebSocket
+ v
+Pulse Web + API
+ | \
+ | \ PostgreSQL
+ | config, inventory, events,
+ | alerts, incidents, audit
+ |
+ +--> Prometheus-compatible source
+ | historical metrics and range queries
+ |
+ +--> Pulse Worker
+ | discovery, reconciliation, alert evaluation,
+ | probes, retention, notifications
+ |
+ +--> Pulse Agent / constrained adapters
+ read-only Unraid, host, storage and container facts
+```
+
+## 2. Deployable units
+
+### `pulse-web`
+
+Preferred outcome: static React application served by a minimal web server or the API gateway. It has no secrets beyond public OIDC configuration and no direct infrastructure access.
+
+### `pulse-api`
+
+Responsibilities:
+- authenticated REST API;
+- WebSocket authentication and subscriptions;
+- dashboard/config CRUD;
+- inventory and event reads;
+- query planning and limits;
+- alert/incident user actions;
+- audit;
+- health/readiness.
+
+No Docker socket and no host filesystem privilege.
+
+### `pulse-worker`
+
+Responsibilities:
+- scheduled discovery/reconciliation;
+- alert rule evaluation;
+- service probes;
+- incident correlation;
+- notification delivery;
+- retention/cleanup;
+- periodic self-checks.
+
+Jobs are idempotent and database-coordinated.
+
+### `pulse-agent`
+
+Responsibilities:
+- host/Unraid/storage/container discovery that cannot be safely obtained through existing APIs/exporters;
+- normalized event/metric/status collection;
+- capability reporting.
+
+The agent has the minimum read-only access required. It does not expose a general shell or mutation endpoint.
+
+The API, worker and agent may share one Go module and image with separate commands while retaining runtime privilege separation.
+
+### PostgreSQL
+
+Stores:
+- users/external identities and roles;
+- settings and secrets references;
+- datasource metadata/health;
+- inventory and relationships;
+- dashboards/versions/widgets/layouts;
+- events;
+- alert rules/state/occurrences;
+- incidents/notes;
+- audit;
+- job coordination and migrations.
+
+It does not duplicate full Prometheus time-series data.
+
+### Prometheus-compatible source
+
+Provides:
+- metric metadata;
+- instant/range query;
+- historical retention;
+- existing exporter scrape state.
+
+Pulse queries it through a constrained server-side adapter. Browser access is forbidden.
+
+## 3. Logical modules
+
+```text
+identity
+authorization
+configuration
+datasources
+inventory
+metrics
+dashboards
+live
+events
+alerts
+incidents
+probes
+notifications
+audit
+operations
+```
+
+Each module defines domain types and interfaces. HTTP/database/Prometheus/Unraid implementations are adapters.
+
+## 4. Data flows
+
+### Dashboard load
+
+1. Browser requests dashboard definition.
+2. API authorizes user and returns validated config.
+3. Browser sends a batched semantic historical query.
+4. API query planner validates bounds, translates to PromQL and deduplicates.
+5. Prometheus returns series.
+6. API normalizes units/metadata and returns bounded points.
+7. Browser opens one WebSocket and subscribes to visible live streams.
+8. Live broker coalesces equivalent subscriptions.
+
+### Discovery
+
+1. Worker requests capability/inventory snapshots from adapters/agent.
+2. Payloads are validated and source-stamped.
+3. Reconciler maps stable identities and relationships.
+4. User overrides remain separate.
+5. Changes create normalized events.
+6. Datasource freshness/status is updated.
+
+### Alert evaluation
+
+1. Worker selects due rules with a coordination lock.
+2. Rule queries semantic metric/event/status inputs.
+3. State machine applies pending/hysteresis/cooldown.
+4. Occurrence/event/audit records persist transactionally.
+5. Suppression/grouping is calculated.
+6. Incident correlation and notifications run.
+7. UI receives a live state update.
+
+## 5. Trust boundaries
+
+1. Browser <-> web/API.
+2. Pulse runtime <-> OIDC provider.
+3. API/worker <-> PostgreSQL.
+4. API/worker <-> Prometheus.
+5. Agent/adapters <-> host/Unraid/Docker.
+6. Probe worker <-> configured network targets.
+7. Notification worker <-> external channels.
+
+Each boundary requires authentication/authorization, timeouts, validation, redaction and least privilege.
+
+## 6. Availability model
+
+Pulse may run as a single instance in v1. It must recover from restarts without losing configuration or alert history.
+
+- Web/API failure: external health check detects.
+- Worker failure: heartbeats and dead-man alert.
+- Agent failure: datasource unknown/stale; no false green.
+- Prometheus failure: historical/live metrics unknown; inventory remains available.
+- Database failure: API not ready; no in-memory claim of health.
+- OIDC failure: existing sessions follow policy; break-glass recovery remains controlled.
+
+## 7. Scaling limits
+
+Target:
+- one host;
+- 150 containers;
+- 40 disks;
+- 300 service probes;
+- 2,500 active dashboard series;
+- 10 concurrent users;
+- long-running wallboard.
+
+The architecture must bound:
+- Prometheus concurrency;
+- series/points;
+- browser ring buffers;
+- WebSocket subscriptions;
+- event payloads;
+- probe concurrency/response size;
+- inventory snapshots;
+- audit retention.
+
+## 8. Configuration
+
+Configuration layers:
+
+1. secure runtime environment/secrets;
+2. validated application config;
+3. database-managed settings;
+4. user/dashboard preferences.
+
+Startup fails clearly for invalid mandatory config. Optional integrations report disabled/unavailable capabilities.
+
+## 9. Extensibility
+
+Connectors implement a capability interface:
+
+```text
+discover
+health
+inventory
+metrics bindings
+events
+capabilities
+```
+
+No connector receives arbitrary access to core storage or bypasses authorization. Version connector contracts.
+
+## 10. Architecture fitness tests
+
+Automated tests must enforce:
+- no Docker socket mount on web/API service;
+- no mutation operation in public v1 API;
+- migrations present for schema changes;
+- OpenAPI/schema compatibility;
+- package/module dependency direction;
+- bounded query defaults;
+- stale -> unknown mapping;
+- non-root container configuration;
+- secrets absent from image/repo.
diff --git a/docs/architecture/TELEMETRY_AND_QUERY_ENGINE.md b/docs/architecture/TELEMETRY_AND_QUERY_ENGINE.md
new file mode 100644
index 0000000..74af54e
--- /dev/null
+++ b/docs/architecture/TELEMETRY_AND_QUERY_ENGINE.md
@@ -0,0 +1,168 @@
+# Telemetry and query engine
+
+## 1. Purpose
+
+Pulse exposes a stable semantic metric model while retaining Prometheus as the v1 time-series source.
+
+Users/widgets request:
+
+```text
+container.cpu.utilization
+storage.disk.temperature
+service.response_time
+```
+
+The backend maps these names to source-specific PromQL templates and label contracts.
+
+## 2. Metric definition
+
+A metric definition includes:
+
+- semantic name and version;
+- description and unit;
+- gauge/counter/state kind;
+- required capabilities;
+- query template;
+- allowed labels/grouping;
+- default aggregation;
+- valid transformations;
+- default interval;
+- max range/series/points;
+- freshness;
+- visualizations;
+- status/threshold hints;
+- cardinality budget.
+
+See `specs/metric-definition.schema.json`.
+
+## 3. Query planning
+
+Pipeline:
+
+1. authenticate and authorize;
+2. validate semantic metric and scope;
+3. resolve entity aliases/source binding;
+4. clamp/validate range, step, series and points;
+5. select binding based on source capabilities;
+6. generate parameterized PromQL from templates;
+7. deduplicate equivalent queries;
+8. execute with timeout/concurrency budget;
+9. normalize labels, units and missing values;
+10. downsample if needed;
+11. return provenance, warnings and freshness.
+
+Raw PromQL is an advanced feature, disabled by default, separately authorized and constrained.
+
+## 4. Query budgets
+
+Budgets are configurable but must exist:
+
+- max range;
+- max series;
+- max samples/points;
+- max query string/template expansion;
+- timeout;
+- per-user/per-dashboard concurrency;
+- global Prometheus concurrency;
+- cacheable result size;
+- live subscription count;
+- label enumeration limits.
+
+Return a clear limit error rather than overloading Prometheus.
+
+## 5. Caching
+
+Cache:
+- metric catalog/capabilities;
+- short historical range results;
+- label/metadata results;
+- repeated dashboard query plans.
+
+Do not cache:
+- authorization decisions beyond safe session scope;
+- live current status beyond its freshness policy;
+- secret-bearing errors.
+
+Cache keys include source, tenant/server scope, semantic query, normalized range and permissions where relevant.
+
+Redis is not required for a single instance. Use bounded in-memory cache and/or PostgreSQL only when appropriate.
+
+## 6. Live engine
+
+### Fast lane
+
+- visible high-frequency widgets subscribe at 1–5 seconds;
+- backend polls/queries compatible batches or receives collector samples;
+- equivalent subscriptions share upstream work;
+- samples are appended with sequence numbers;
+- browser retains a bounded ring buffer;
+- historical data is not fully refetched per sample.
+
+### Durable lane
+
+Prometheus scrape/history remains durable. Pulse does not persist every live sample into PostgreSQL.
+
+### Adaptive behavior
+
+- out-of-view widgets reduce frequency;
+- background tabs reduce frequency;
+- paused dashboards unsubscribe;
+- wallboards remain active with bounded buffers;
+- slow clients receive coalesced latest samples;
+- sequence gaps trigger bounded resync.
+
+## 7. Browser chart architecture
+
+- historical query initializes chart;
+- live samples append outside global React state where practical;
+- series count and point count are capped;
+- old points are evicted;
+- chart resources are disposed on unmount;
+- ResizeObserver/visibility changes are debounced;
+- tooltip/legend state does not duplicate large arrays;
+- long wallboard test measures heap and subscription count.
+
+## 8. Staleness and unknown
+
+Each response includes:
+- source timestamp;
+- received timestamp;
+- freshness state;
+- warnings.
+
+If a required source is stale/unavailable:
+- current value is marked stale or omitted;
+- status becomes unknown according to policy;
+- previous value may be displayed with age;
+- alerts can enter unknown rather than resolve.
+
+## 9. Host snapshot binding
+
+Host detail reads use the explicit read-only host contract. A source adapter returns bounded normalized values with UTC observed/received timestamps and capability version. The adapter sorts filesystem/interface collections deterministically, validates percentage/byte units and rejects payloads over configured collection limits. The API/web boundary has no host or Docker privilege; when no approved source is connected, the result is an explicit unknown snapshot. Load and memory status reasons are returned as text so high load is distinguishable from stale or unavailable telemetry.
+## 9. Transformations
+
+Supported through typed operations:
+- rate;
+- increase/delta;
+- average/min/max/sum;
+- quantile;
+- percentage;
+- top/bottom N;
+- unit conversion;
+- compare previous period;
+- fill policy;
+- status mapping.
+
+Transform order is explicit and validated. Avoid silently mixing counter rates and gauges.
+
+
+Optional hardware follows the same boundary: declared thermal, fan and GPU capabilities are independently enabled, unsupported, unavailable or disabled. Missing capability is not an adapter failure. Sensor IDs are source-scoped and stable from external ID/name, collections are bounded and sorted, and thermal reasons are returned as text. No API/web host privilege, device mount or unrestricted socket is needed.
+## 10. Cardinality controls
+
+- semantic definitions allow only known labels;
+- unbounded label values are excluded or normalized;
+- inventory IDs map to stable bounded labels;
+- dashboards cannot group by arbitrary label by default;
+- source cardinality health is monitored;
+- query inspector shows series count and limits.
+- query inspector is operate-permissioned, exposes only planner-approved generated PromQL and redacts sensitive-looking text; it does not execute an unrestricted query.
diff --git a/docs/architecture/adr/0001-read-only-v1.md b/docs/architecture/adr/0001-read-only-v1.md
new file mode 100644
index 0000000..0de75c1
--- /dev/null
+++ b/docs/architecture/adr/0001-read-only-v1.md
@@ -0,0 +1,19 @@
+# ADR 0001 — Pulse v1 is operationally read-only
+
+**Status:** Accepted baseline
+
+## Decision
+
+Pulse may observe, query, configure its own monitoring behavior and create user records such as acknowledgements. It may not mutate Unraid, Docker, storage, containers, host processes or network configuration.
+
+## Rationale
+
+Monitoring and management have different security and failure domains. A compromised dashboard must not become a host control plane. Remediation belongs in a separately controlled AppOps workflow.
+
+## Consequences
+
+- No start/stop/restart/delete APIs.
+- No process kill.
+- No array/pool operations.
+- Optional links may open an external management workflow.
+- Tests enforce absence of mutation routes and dangerous agent capabilities.
diff --git a/docs/architecture/adr/0002-go-react-stack.md b/docs/architecture/adr/0002-go-react-stack.md
new file mode 100644
index 0000000..9f6908f
--- /dev/null
+++ b/docs/architecture/adr/0002-go-react-stack.md
@@ -0,0 +1,17 @@
+# ADR 0002 — Go services and React/TypeScript web application
+
+**Status:** Accepted baseline
+
+## Decision
+
+Use Go for API, worker and agent commands. Use React + TypeScript for the web application, with Vite as the default build tool unless discovery proves a stronger need for server-rendered Next.js behavior.
+
+## Rationale
+
+The product is a highly interactive authenticated dashboard rather than a public content site. Go provides efficient long-lived connections, concurrency and small deployable binaries. React has mature dashboard/grid/chart ecosystems. A static frontend reduces production runtime complexity.
+
+## Consequences
+
+- Shared contracts are generated or validated from schemas.
+- API/worker/agent can share modules but deploy with separate privileges.
+- An ADR is required to add a Node server runtime to production.
diff --git a/docs/architecture/adr/0003-prometheus-v1-history.md b/docs/architecture/adr/0003-prometheus-v1-history.md
new file mode 100644
index 0000000..7712f02
--- /dev/null
+++ b/docs/architecture/adr/0003-prometheus-v1-history.md
@@ -0,0 +1,17 @@
+# ADR 0003 — Existing Prometheus-compatible source remains v1 metrics history
+
+**Status:** Accepted baseline
+
+## Decision
+
+Use the existing Prometheus-compatible endpoint for instant/range queries and retention in v1. Do not introduce VictoriaMetrics or another time-series database until measurements show a concrete retention, performance or reliability requirement.
+
+## Rationale
+
+Avoid duplicate infrastructure and migration risk. Pulse differentiates through semantic queries, UX, inventory, alerts and incidents.
+
+## Consequences
+
+- Query planner and limits protect the source.
+- Pulse stores no full metric history in PostgreSQL.
+- Long-term storage remains a future measured decision.
diff --git a/docs/architecture/adr/0004-postgresql-domain-state.md b/docs/architecture/adr/0004-postgresql-domain-state.md
new file mode 100644
index 0000000..d3f06fa
--- /dev/null
+++ b/docs/architecture/adr/0004-postgresql-domain-state.md
@@ -0,0 +1,18 @@
+# ADR 0004 — PostgreSQL stores Pulse domain state
+
+**Status:** Accepted baseline
+
+## Decision
+
+Use PostgreSQL for configuration, inventory, relationships, dashboards, events, alert state/history, incidents, audit and job coordination.
+
+## Rationale
+
+The data is relational, transactional and queryable. PostgreSQL supports JSONB for bounded extensibility and reliable migrations/backups.
+
+## Consequences
+
+- All schema changes use migrations.
+- Production database is private.
+- Backup/restore is a release gate.
+- Metric samples remain outside PostgreSQL.
diff --git a/docs/architecture/adr/0005-docker-access-boundary.md b/docs/architecture/adr/0005-docker-access-boundary.md
new file mode 100644
index 0000000..b106021
--- /dev/null
+++ b/docs/architecture/adr/0005-docker-access-boundary.md
@@ -0,0 +1,17 @@
+# ADR 0005 — No unrestricted Docker socket in API/web
+
+**Status:** Accepted baseline
+
+## Decision
+
+The web and API containers never mount the unrestricted Docker socket. Docker/Unraid facts come from the official Unraid API, existing exporters, Portainer read-only endpoints, or a separate constrained agent/socket proxy.
+
+## Rationale
+
+Docker daemon access is effectively host control. Read-only filesystem mount flags do not create a read-only Docker API.
+
+## Consequences
+
+- Collector capability endpoints are allowlisted.
+- Agent runtime is separated and audited.
+- Architecture tests inspect compose mounts and API routes.
diff --git a/docs/architecture/adr/0006-rest-websocket.md b/docs/architecture/adr/0006-rest-websocket.md
new file mode 100644
index 0000000..1fe3e3d
--- /dev/null
+++ b/docs/architecture/adr/0006-rest-websocket.md
@@ -0,0 +1,17 @@
+# ADR 0006 — REST plus WebSocket
+
+**Status:** Accepted baseline
+
+## Decision
+
+Use versioned REST for requests/configuration/history and one authenticated WebSocket per browser session for bounded live updates.
+
+## Rationale
+
+REST is clear for CRUD/query operations. WebSocket supports low-latency subscriptions and server-side coalescing without repeated polling of full datasets.
+
+## Consequences
+
+- Messages use explicit schema/version/sequence.
+- Reconnect and resync are required.
+- Subscription, rate, size and buffer limits are mandatory.
diff --git a/docs/architecture/adr/0007-localization.md b/docs/architecture/adr/0007-localization.md
new file mode 100644
index 0000000..5fa45ea
--- /dev/null
+++ b/docs/architecture/adr/0007-localization.md
@@ -0,0 +1,17 @@
+# ADR 0007 — Dutch default, localization-ready architecture
+
+**Status:** Accepted baseline
+
+## Decision
+
+Ship user-facing v1 flows in Dutch (`nl-BE`) and use localization keys/contracts so English can be added without rewriting components.
+
+## Rationale
+
+The primary user is Dutch-speaking, while code and technical contracts benefit from stable English identifiers.
+
+## Consequences
+
+- No hardcoded scattered UI copy.
+- Tests cover missing translation keys.
+- Dates/numbers use locale and Europe/Brussels display defaults while storage remains UTC.
diff --git a/docs/architecture/adr/0008-stale-is-unknown.md b/docs/architecture/adr/0008-stale-is-unknown.md
new file mode 100644
index 0000000..607da58
--- /dev/null
+++ b/docs/architecture/adr/0008-stale-is-unknown.md
@@ -0,0 +1,18 @@
+# ADR 0008 — Stale or missing required telemetry is Unknown
+
+**Status:** Accepted baseline
+
+## Decision
+
+When required telemetry is unavailable beyond its freshness policy, Pulse reports Unknown rather than retaining or inferring Healthy.
+
+## Rationale
+
+False green status is more dangerous than explicit uncertainty.
+
+## Consequences
+
+- Responses carry freshness metadata.
+- Alert rules define unknown behavior.
+- UI shows last known value only with age.
+- Tests inject stale/missing sources throughout the product.
diff --git a/docs/architecture/adr/0009-upstream-dependency-baseline.md b/docs/architecture/adr/0009-upstream-dependency-baseline.md
new file mode 100644
index 0000000..c92a62d
--- /dev/null
+++ b/docs/architecture/adr/0009-upstream-dependency-baseline.md
@@ -0,0 +1,42 @@
+# ADR 0009 — Upstream and dependency baseline after M0 research
+
+**Status:** Accepted for M1 planning; exact package versions remain pinned during M1 implementation.
+
+## Context
+
+M0 must verify current upstream capabilities and avoid selecting unsupported or abandoned dependencies. The target Unraid host is 7.2.2 with its native Unraid API online. The local development host has Node.js 24.18.1 and pnpm 10.33.0 but no Go toolchain.
+
+## Decision
+
+- Use the native Unraid GraphQL API as the first inventory adapter. Unraid 7.2+ includes the API in the OS, and programmatic access supports API keys, cookies, and SSO/OIDC. Pulse uses a least-privilege read-only API key or equivalent controlled identity; it must not use mutation capabilities.
+- Keep Prometheus-compatible history as an external server-side datasource. Pulse uses the stable `/api/v1/query` and `/api/v1/query_range` APIs through bounded semantic queries; the browser never contacts Prometheus directly.
+- Use Authentik OIDC/OAuth2 as the production identity integration. The server performs authorization-code exchange and token validation; public/browser flows use PKCE where applicable. Per-provider issuer/discovery is the default because authentik documents it as the recommended issuer mode.
+- Implement the frontend with React + TypeScript + Vite, using the current React documentation baseline (19.2) and Vite's supported Node requirement. Use GridStack as the dashboard-grid candidate; retain a measured-equivalent escape hatch. Do not commit exact package versions until M1 lockfile/bootstrap work.
+- Prefer Go's standard library for the initial backend transport and pin a currently supported Go release in M1. The official Go release policy supports a major release until two newer majors exist. The baseline is maintained at Go 1.26.6 after the M13 release image gate identified fixed standard-library findings in 1.26.5.
+
+## License and support record
+
+| Component | Upstream license observed | Support/compatibility note |
+|---|---|---|
+| Go toolchain | BSD-style (official Go distribution) | Use an official currently supported release; Go is not installed locally yet. |
+| React | MIT | Official React docs list 19.2 as latest major baseline. |
+| Vite | MIT | Official docs require Node 20.19+ or 22.12+; local Node 24.18.1 satisfies the documented floor. |
+| GridStack | MIT | Candidate only; verify package release and transitive dependencies during M1. |
+| Prometheus API/source | Apache 2.0 | External source; Pulse does not redistribute Prometheus in the application image. |
+| authentik integration | MIT core with documented directory/component exceptions | Pulse integrates with the deployed provider; it does not embed or redistribute authentik. |
+
+## Consequences
+
+- M1 must provision Go, generate lockfiles, run license/dependency checks, and pin exact versions from official release metadata.
+- Prometheus location remains unresolved in the actual Unraid environment: no local 9090 listener/container was found. Datasource onboarding must support an explicitly configured external endpoint and report Unknown when absent/stale.
+- The frontend runtime remains a static React/Vite artifact served behind the API/reverse proxy, preserving the no-infrastructure-access browser boundary.
+
+## Sources accessed 2026-08-01
+
+- [Unraid API overview](https://docs.unraid.net/API/) and [Unraid API usage](https://docs.unraid.net/API/how-to-use-the-api/)
+- [Go release history](https://go.dev/doc/devel/release)
+- [Prometheus HTTP API](https://prometheus.io/docs/prometheus/latest/querying/api/) and [PromQL basics](https://prometheus.io/docs/prometheus/latest/querying/basics/)
+- [authentik OAuth 2.0/OIDC provider](https://docs.goauthentik.io/add-secure-apps/providers/oauth2)
+- [React versions](https://react.dev/versions) and [React reference](https://react.dev/reference/react)
+- [Vite getting started and compatibility](https://vite.dev/guide/)
+- [React MIT license](https://raw.githubusercontent.com/react/react/main/LICENSE), [Vite MIT license](https://raw.githubusercontent.com/vitejs/vite/main/LICENSE), [GridStack MIT license](https://raw.githubusercontent.com/gridstack/gridstack.js/master/LICENSE), [Prometheus Apache 2.0 license](https://raw.githubusercontent.com/prometheus/prometheus/main/LICENSE), and [authentik license](https://raw.githubusercontent.com/goauthentik/authentik/main/LICENSE)
diff --git a/docs/engineering/BACKEND_STANDARDS.md b/docs/engineering/BACKEND_STANDARDS.md
new file mode 100644
index 0000000..0874f91
--- /dev/null
+++ b/docs/engineering/BACKEND_STANDARDS.md
@@ -0,0 +1,95 @@
+# Backend and worker standards
+
+## Domain boundaries
+
+Core packages define:
+- entities and reconciliation;
+- metrics query model;
+- dashboard validation/versioning;
+- events;
+- alert state machine;
+- incidents;
+- authorization policies.
+
+Adapters implement:
+- PostgreSQL;
+- Prometheus;
+- Unraid;
+- Docker/constrained collector;
+- OIDC;
+- probes;
+- notifications.
+
+Domain packages must not import HTTP handlers or concrete adapters.
+
+## HTTP
+
+- Route groups map to modules.
+- Middleware order is explicit.
+- Request body, query and path validation.
+- Body size limits.
+- Timeouts and cancellation.
+- Stable error mapping.
+- Structured request log with redaction.
+- Health/live and readiness separated.
+- No mutation route for host/Docker/storage.
+
+## Worker
+
+- Jobs have stable keys and schedules.
+- Database lock/lease prevents duplicate execution.
+- Work is idempotent.
+- Retries are bounded with jitter.
+- Poison/repeated failures become visible system status.
+- Shutdown waits for bounded graceful completion.
+- Job result counts and duration recorded.
+
+## Prometheus adapter
+
+- Server-side only.
+- Timeout and concurrency semaphore.
+- Semantic templates only by default.
+- Parse upstream warnings and staleness.
+- Protect metadata/label endpoints.
+- Normalize errors to stable codes.
+- Instrument query duration/series/points/cache.
+
+## Inventory reconciliation
+
+- Stable source alias mapping.
+- Snapshot can be repeated safely.
+- Missing item becomes tombstoned after source-specific policy, not immediately deleted.
+- User overrides remain separate.
+- Relation source/confidence retained.
+- Changes create deduplicated events.
+- Source failure does not tombstone all entities.
+
+## Alerts
+
+- Deterministic state machine.
+- Evaluation transaction/locking.
+- Clock abstracted in tests.
+- Unknown data explicit.
+- Rule versions immutable.
+- Notification side effects use outbox/idempotency pattern or equivalent.
+- Acknowledgement does not mutate underlying firing condition.
+
+## Database
+
+- Context-aware queries.
+- Parameterized SQL.
+- Transaction boundaries documented.
+- Pool limits.
+- Repository methods return domain types/errors.
+- Integration tests against real PostgreSQL.
+- No production reliance on SQLite semantics.
+
+## Agent
+
+- Read-only capability list.
+- No generic command execution endpoint.
+- Authenticated/mutually trusted channel if remote.
+- Bounded payloads.
+- Version/capability negotiation.
+- Local cache only where safe.
+- Every elevated mount/capability justified and tested.
diff --git a/docs/engineering/CI_PIPELINE.md b/docs/engineering/CI_PIPELINE.md
new file mode 100644
index 0000000..d5c9a04
--- /dev/null
+++ b/docs/engineering/CI_PIPELINE.md
@@ -0,0 +1,36 @@
+# CI pipeline target
+
+Codex must implement CI-equivalent commands locally even when no hosted CI is connected.
+
+Recommended stages:
+
+1. repository/planning validation;
+2. formatting;
+3. lint/static analysis;
+4. generated contract drift;
+5. wiring/reachability gate (`python tools/check_wiring.py`): every production package must have at least one non-test importer reachable from a binary (`cmd/api`, `cmd/worker`, `cmd/agent`, `cmd/migrate`), or be listed in `tools/wiring_allowlist.json` with a reason and a tracking task id; a task may not be marked done while its deliverable is unreachable;
+6. frontend type/unit (`pnpm test` runs Vitest + Testing Library, including ADR-0008 status invariants);
+7. Go unit/race where suitable;
+8. integration with PostgreSQL/fake sources;
+9. frontend build;
+10. API/OpenAPI/schema compatibility;
+11. Playwright smoke (`pnpm test:e2e`, desktop/mobile/wallboard Chromium projects);
+12. accessibility (axe-core in the Playwright smoke; serious or critical violations fail);
+13. dependency/license/secret scan;
+14. container build and image scan;
+15. compose/config validation;
+16. evidence summary.
+
+The executable Gitea Actions definition is `.gitea/workflows/ci.yml`. The local
+equivalent is `scripts/verify.ps1`; install Chromium once with
+`pnpm exec playwright install chromium` before its browser stage. Browser tests
+mock only the versioned API boundary and exercise the real built React routes.
+
+Release pipeline additionally:
+- clean checkout;
+- full E2E;
+- performance subset;
+- migration/backup/restore;
+- SBOM/provenance where feasible;
+- immutable image digest record;
+- deployment smoke and rollback proof.
diff --git a/docs/engineering/DEPENDENCIES.md b/docs/engineering/DEPENDENCIES.md
new file mode 100644
index 0000000..f49c800
--- /dev/null
+++ b/docs/engineering/DEPENDENCIES.md
@@ -0,0 +1,35 @@
+# M1 dependency record
+
+Research and install date: 2026-08-01
+
+These exact versions are pinned in `package.json`/`apps/web/package.json` and `pnpm-lock.yaml`. Go is installed in the developer's user-local toolchain cache at Go 1.26.6 and is declared in `go.mod`/`go.work`; it is not vendored into the repository.
+
+| Dependency | Version | Purpose | License | Alternative/decision |
+|---|---|---|---|---|
+| Go toolchain | 1.26.6 | API, worker, agent | BSD-style | Official current supported patch; required by the M13 release image gate to remove fixed Go standard-library High findings. |
+| React | 19.2.8 | Web UI | MIT | React is required by the architecture; framework/server rendering is unnecessary for the static dashboard shell. |
+| React DOM | 19.2.8 | Browser renderer | MIT | Pinned with React. |
+| Vite | 8.2.0 | TypeScript web build/dev server | MIT | Chosen over a heavier framework because the architecture calls for a static React app and Vite supports the available Node runtime. |
+| `@vitejs/plugin-react` | 6.0.5 | Vite React transform | MIT | Official Vite ecosystem plugin; pinned with Vite 8. |
+| TypeScript | 7.0.2 | Strict web type checking | Apache-2.0 | Required for the React + TypeScript architecture. |
+| React type declarations | 19.2.18 / 19.2.4 | Compile-time types | MIT | Pinned to the installed React major. |
+| `github.com/jackc/pgx/v5` | 5.10.0 | PostgreSQL connection pool and parameterized access | MIT | Selected for native context-aware pooling and PostgreSQL support; pinned after module/license review. |
+| `github.com/coreos/go-oidc/v3` | 3.20.0 | OIDC discovery, issuer/audience/JWK-backed ID-token verification | Apache-2.0 | Uses maintained standards-oriented verifier; server-side only, pinned after module/license review. |
+| `golang.org/x/oauth2` | 0.36.0 | Authorization-code exchange and PKCE request parameters | BSD-style | Official Go OAuth2 client primitives; pinned and kept behind the auth adapter. |
+
+No charting, grid, or HTTP-router dependency is added yet. Those material choices require the relevant task's primary-source/security/license review and measurement. `pnpm-lock.yaml` records registry integrity data; Go module checksums are recorded in `go.sum`.
+
+## Verification record
+
+- `go version`: `go1.26.6 windows/amd64`.
+- `pnpm install --frozen-lockfile`: pass with pnpm 10.33.0.
+- Vite's official compatibility floor is Node 20.19+ or 22.12+; the local Node 24.18.1 satisfies it.
+- `go test ./...`, `go vet ./...`, TypeScript typecheck, Vite build, and repository bootstrap/test/lint scripts pass.
+- `go mod verify`: pass; pgx v5.10.0 and transitive modules are checksum-verified.
+- pgx v5.10.0 module metadata points to the upstream `github.com/jackc/pgx` repository; the cached module includes an MIT license.
+- go-oidc v3.20.0 and oauth2 v0.36.0 are checksum-verified; the cached go-oidc module includes an Apache-2.0 license and oauth2 is maintained under the Go project license.
+- Releasegate 2026-08-21 pins transitieve builddependency `nanoid` op 3.3.18 via een beperkte pnpm-override. Dit sluit GHSA-2v37-7h3g-55p8 in Vite -> PostCSS; `pnpm audit --audit-level high` en Trivy met developmentdependencies rapporteren daarna nul High/Critical-bevindingen. Nanoid blijft uitsluitend onderdeel van de MIT-gelicentieerde buildketen en wordt niet aan de browserruntime toegevoegd.
+
+## Upgrade/removal path
+
+Update package manifests and lockfile together, rerun the foundation scripts plus the affected milestone gate, review changelogs/security advisories, and record any compatibility or bundle/runtime impact. Removing Vite/React is an architectural change requiring an ADR; removing a foundation tool requires replacement commands and clean-room evidence.
diff --git a/docs/engineering/DEPENDENCY_POLICY.md b/docs/engineering/DEPENDENCY_POLICY.md
new file mode 100644
index 0000000..3915e70
--- /dev/null
+++ b/docs/engineering/DEPENDENCY_POLICY.md
@@ -0,0 +1,59 @@
+# Dependency policy
+
+A production dependency is accepted only when it:
+
+- solves a real requirement better than a small maintained implementation;
+- is actively maintained;
+- has a compatible license;
+- has no unresolved unacceptable security issue;
+- supports the selected runtime/browser versions;
+- has clear upgrade and removal paths;
+- does not require excessive privilege or bundle size.
+
+## Selection record
+
+For material dependencies record:
+- package and version range;
+- purpose;
+- alternatives considered;
+- maintenance/release activity;
+- license;
+- security check;
+- bundle/image/runtime impact;
+- locking strategy.
+
+This may be an ADR or a dependency manifest note.
+
+## Default choices to validate during M0/M1
+
+- React + TypeScript + Vite.
+- GridStack for layout.
+- uPlot for high-volume time series.
+- ECharts for complex visualizations.
+- TanStack Query for server state.
+- Accessible UI primitives/component library.
+- Go HTTP/router, OIDC/JWT and PostgreSQL libraries selected from maintained options.
+- PostgreSQL migration tool with explicit CLI and rollback strategy.
+- Playwright and automated accessibility tooling.
+- Testcontainers for integration tests.
+
+These are defaults, not permission to install blindly. Verify current supported versions and compatibility.
+
+## Rules
+
+- Commit lockfiles.
+- Prefer exact image tags/digests in production records.
+- Avoid duplicate libraries for the same concern.
+- Do not use abandonware because an example already uses it.
+- Do not add Redis, Kafka, Elasticsearch or a second metrics database without measured need and ADR.
+- Remove unused dependencies immediately.
+- Run dependency/license/vulnerability checks at milestones and release.
+
+
+## M4-06 WebSocket selection
+
+- Package/version: github.com/coder/websocket v1.8.15, pinned in go.mod and go.sum.
+- Purpose: RFC6455 server upgrade, context-aware reads/writes, ping/pong and bounded frame reads for the authenticated live endpoint.
+- Alternatives considered: hand-rolled RFC6455 handling was rejected because it increases protocol and security risk; gorilla/websocket was not needed for this narrow API; golang.org/x/net/websocket is deprecated.
+- Maintenance/security: current upstream release was resolved locally on 2026-08-01; the module has zero transitive dependencies and the source license is permissive MIT.
+- Runtime impact: server-only dependency, no browser bundle or privilege change; SetReadLimit and write deadlines enforce the endpoint budget.
diff --git a/docs/engineering/ENGINEERING_STANDARDS.md b/docs/engineering/ENGINEERING_STANDARDS.md
new file mode 100644
index 0000000..923b976
--- /dev/null
+++ b/docs/engineering/ENGINEERING_STANDARDS.md
@@ -0,0 +1,125 @@
+# Engineering standards
+
+## Repository shape
+
+Target:
+
+```text
+apps/
+ web/
+ api/
+ worker/
+ agent/
+packages/
+ contracts/
+ ui/
+ test-fixtures/
+internal/
+ domain modules or Go internal packages
+config/
+deploy/
+tests/
+docs/
+artifacts/evidence/
+```
+
+Codex may refine the shape through an ADR, but privilege boundaries and clear ownership must remain.
+
+## General
+
+- Optimize for correctness, observability and maintainability.
+- Keep changes vertically complete.
+- Validate external inputs at boundaries.
+- Avoid global mutable state.
+- Use deterministic IDs/fingerprints where required.
+- Use UTC internally.
+- Add correlation IDs to request/job/event paths.
+- Preserve error causes and add context.
+- Never log secrets.
+- Use feature flags only when they have an owner, default and removal plan.
+
+## Git and commits
+
+- Focused commits aligned to task IDs.
+- Commit message format: `: `.
+- Do not rewrite shared history.
+- No force push or destructive reset.
+- Keep generated evidence out of commits only when too large; summaries remain.
+- Tag releases only after final acceptance.
+
+## Go
+
+- Current supported stable Go version selected during M0 and recorded.
+- `go fmt`, `go vet`, static analysis and tests required.
+- Context propagated through I/O boundaries.
+- Errors wrapped with operation context.
+- Interfaces defined near consumers; avoid interface proliferation.
+- Goroutines have ownership, cancellation and bounded lifetime.
+- Worker jobs are idempotent.
+- SQL is parameterized; transactions explicit.
+- Migrations are forward/recovery tested.
+- HTTP handlers contain no core domain logic.
+
+## TypeScript/React
+
+- Strict TypeScript.
+- No `any` except narrow justified boundary adapters.
+- Runtime validation for external JSON.
+- Components separate data orchestration from presentation.
+- Server state uses a deliberate query/cache layer.
+- Live chart buffers do not live in broad global state.
+- Effects are cancellable and cleanup subscriptions.
+- Accessible semantic HTML first.
+- All user copy goes through localization.
+- Avoid giant components and prop drilling; centralize domain-specific hooks appropriately.
+
+## API and contracts
+
+- OpenAPI/JSON Schema is validated in CI.
+- Breaking changes are versioned.
+- Generated types are reproducible.
+- Error codes are stable.
+- Pagination, filtering and sorting are bounded.
+- Every endpoint has authz tests.
+- WebSocket messages are schema validated.
+
+## Database
+
+- Explicit migrations, no startup auto-mutation outside migration command.
+- Indexes justified by access path.
+- Constraints enforce invariants where practical.
+- JSONB payloads have size/schema limits.
+- Optimistic concurrency for user-edited versioned resources.
+- Timeouts and connection pool limits.
+- Test upgrade, restart, backup and restore.
+
+## Configuration
+
+- `.env.example` documents non-secret values.
+- Startup validates configuration and reports all invalid fields.
+- Secrets use secret files/runtime injection when possible.
+- No environment-specific values embedded in images.
+- Production and test compose overrides are separate.
+- Feature capability detection is visible in UI/system status.
+
+## Observability
+
+Pulse emits:
+- structured logs;
+- internal metrics;
+- health/readiness;
+- job status;
+- trace/correlation IDs;
+- redacted upstream error classes.
+
+Avoid recursive monitoring dependence: an external dead-man check must detect total Pulse failure.
+
+## Documentation
+
+Behavioral changes update:
+- relevant specification;
+- API/schema;
+- runbook if operational;
+- evidence;
+- current state;
+- ADR when architectural.
diff --git a/docs/engineering/FRONTEND_STANDARDS.md b/docs/engineering/FRONTEND_STANDARDS.md
new file mode 100644
index 0000000..b2bf0b4
--- /dev/null
+++ b/docs/engineering/FRONTEND_STANDARDS.md
@@ -0,0 +1,92 @@
+# Frontend standards
+
+## Architecture
+
+Recommended modules:
+
+```text
+app-shell
+auth
+routing
+i18n
+design-system
+dashboards
+widgets
+metrics
+inventory
+alerts
+incidents
+events
+settings
+operations
+```
+
+Feature modules own routes, queries, views and tests. Shared UI remains domain-neutral.
+
+## State
+
+- URL state for shareable filters/time range when appropriate.
+- Server state via TanStack Query or measured equivalent.
+- Editor draft state isolated from saved dashboard state.
+- WebSocket subscriptions managed by one client/service.
+- Chart samples in bounded local stores/ring buffers.
+- Avoid duplicating server state across stores.
+
+## Dashboard editor
+
+- Save complete version atomically.
+- Use explicit edit session/draft.
+- Detect optimistic concurrency conflict.
+- Undo/redo operates on normalized editor commands or bounded snapshots.
+- Breakpoint layouts validated before save.
+- Keyboard move/resize and screen-reader labels.
+- Prevent accidental drag from chart interactions.
+
+## Charts
+
+- Lazy-load heavy chart implementations.
+- Initialize history once per query.
+- Append live data efficiently.
+- Cap points/series.
+- Dispose observers/listeners/instances.
+- Pause offscreen/background work.
+- Provide textual summary/table alternative.
+- Use consistent units, timestamps, legend and tooltip behavior.
+- Status thresholds do not overwrite data meaning.
+
+## Tables/lists
+
+- Cursor/server pagination for large datasets.
+- Virtualization for large rendered collections.
+- Stable row keys.
+- Accessible sorting/filtering labels.
+- Preserve filter state sensibly.
+- Loading and empty states are distinct.
+
+## Error handling
+
+- Route-level boundary.
+- Component/query errors show safe, actionable messages.
+- Correlation ID exposed for diagnostics.
+- Retry only when safe and bounded.
+- Authentication expiry has a clean flow.
+- Partial datasource failure does not blank the entire app.
+
+## Styling
+
+- Use design tokens; no scattered literal colors/spacing.
+- Consistent card padding and grid gaps.
+- Limited elevation.
+- Status colors only for status.
+- Respect reduced motion.
+- Avoid oversized decorative headers that reduce information space.
+- Desktop and mobile screenshots/visual regression for key routes.
+
+## Testing
+
+- Unit tests for formatting/transforms/editor reducers.
+- Component tests for states and accessibility.
+- Playwright for user journeys.
+- Axe or equivalent on all core routes and viewports.
+- Real browser verification for drag/resize, WebSocket reconnect and wallboard.
+- Leak/soak instrumentation for chart/subscription lifecycle.
diff --git a/docs/engineering/PERFORMANCE_BUDGETS.md b/docs/engineering/PERFORMANCE_BUDGETS.md
new file mode 100644
index 0000000..34d4f81
--- /dev/null
+++ b/docs/engineering/PERFORMANCE_BUDGETS.md
@@ -0,0 +1,73 @@
+# Performance budgets
+
+Budgets are acceptance targets measured in the documented test environment.
+
+## Target scale
+
+- 1 Unraid host;
+- 150 containers;
+- 40 disks;
+- 300 service probes;
+- 2,500 active series across an intensive dashboard set;
+- 10 concurrent authenticated users;
+- 1 wallboard open for at least 24 hours.
+
+Container ingestion retains bounded headroom up to 250 records so a host that
+briefly grows beyond the 150-container performance target remains observable.
+The 150-container fixture remains the required latency and UI acceptance scale;
+the additional headroom is a safety boundary, not a higher performance claim.
+
+## Browser
+
+| Metric | Target |
+|---|---:|
+| First meaningful overview on LAN, warm service | < 2.0 s |
+| Main route interaction ready | < 3.0 s |
+| Live sample visual delay | < 2.5 s at 2 s interval |
+| Drag/resize frame behavior | no sustained visible jank |
+| 24 h wallboard heap | bounded; no monotonic leak |
+| Active subscriptions after navigation | returns to expected baseline |
+| Large table scroll | responsive with virtualization |
+
+Record browser, hardware and network.
+
+## API
+
+| Metric | Target |
+|---|---:|
+| P95 cached/config API | < 250 ms |
+| P95 24 h bounded range query | < 750 ms excluding unavailable upstream |
+| P95 inventory list | < 500 ms at target scale |
+| WebSocket reconnect | automatic within 10 s under normal recovery |
+| Error response | bounded and correlated |
+
+## Resource envelope
+
+Initial production goals, to validate:
+- API/worker/agent combined idle memory should remain reasonable for Unraid;
+- CPU near idle outside query/evaluation bursts;
+- database growth predictable under retention;
+- no unbounded goroutines, queues, caches or event payloads.
+
+Do not invent a pass. Record actual values and refine budgets through an ADR if hardware/source constraints provide evidence.
+
+## Query limits
+
+- max series and points per request;
+- max concurrent upstream requests;
+- step adjusted to viewport/time range;
+- heavy query rejection with guidance;
+- metadata/label enumeration bounded.
+
+## Tests
+
+- frontend bundle analysis;
+- Lighthouse or equivalent where meaningful;
+- scripted dashboard load;
+- WebSocket fan-out/load;
+- Prometheus slow/error injection;
+- real wallboard soak of at least 17 hours under the explicit M10-14
+ product-owner duration decision;
+- worker/probe concurrency;
+- database query plans for large lists;
+- restart/recovery under load.
diff --git a/docs/engineering/QUALITY_GATES.md b/docs/engineering/QUALITY_GATES.md
new file mode 100644
index 0000000..586c25d
--- /dev/null
+++ b/docs/engineering/QUALITY_GATES.md
@@ -0,0 +1,121 @@
+# Quality gates
+
+## Per task
+
+- Deliverables exist.
+- Acceptance checks pass.
+- Formatting/lint/type checks for changed code pass.
+- Relevant unit/integration/browser tests pass.
+- Diff review complete.
+- Docs/contracts updated.
+- Evidence summary complete.
+- No introduced secret or critical security issue.
+- A production Go package has at least one non-test importer reachable from a binary (`cmd/api`, `cmd/worker`, `cmd/agent`, `cmd/migrate`), verified by `python tools/check_wiring.py`; a task may not be marked done while its deliverable is unreachable, unless it is allowlisted in `tools/wiring_allowlist.json` with a reason and a tracking task id.
+- State updated via `projectctl`.
+
+## Per milestone
+
+- Every milestone task done.
+- Full milestone test set passes.
+- Architecture drift review.
+- Dependency/security/license check.
+- TODO/FIXME/skipped-test/debug scan.
+- Wiring/reachability scan (`python tools/check_wiring.py`): no package this milestone claims to deliver is unreachable and unallowlisted.
+- Migration/restart behavior where relevant.
+- UX/accessibility check for user-visible milestones.
+- Performance check for hot paths.
+- Milestone evidence index.
+- `python tools/projectctl.py gate ` passes.
+
+## M0 gate
+
+- Repository/tooling/server discovery recorded.
+- Existing services/ports/networks/volumes/monitoring sources inventoried.
+- Backups/rollback plan for touched configs.
+- Current versions/capabilities verified from primary sources.
+- Architecture/security baseline reviewed.
+- M1-M9 plan adjusted to facts.
+- No destructive production change.
+
+## M1 gate
+
+- Clean local build.
+- API/web/worker/database start and health.
+- Migrations empty/restart/repeat.
+- Auth/RBAC skeleton and audit.
+- CI-equivalent checks.
+- No secrets.
+- Compose isolation/hardening baseline.
+
+## M2 gate
+
+- Prometheus and Unraid/mock adapters.
+- Inventory entities/relations/source ownership.
+- Discovery idempotency and source failure safety.
+- Datasource health/freshness.
+- API and UI inventory.
+- Target-scale reconciliation test.
+
+## M3 gate
+
+- Full dashboard CRUD/versioning/import/export.
+- Grid edit and per-breakpoint layouts.
+- Widget catalog/config states.
+- Undo/redo/restore/concurrency.
+- Desktop/mobile accessibility.
+- Browser persistence/reload proof.
+
+## M4 gate
+
+- Semantic metrics and bounded query planner.
+- Historical/live charts.
+- WebSocket auth, dedup, backpressure, reconnect.
+- Stale/unknown.
+- Performance/load and leak baseline.
+- Query security tests.
+
+## M5 gate
+
+- Host/process/container/application coverage.
+- Restart loop and application aggregation.
+- Events/detail pages/top-N/status.
+- Failure scenarios and scale.
+
+## M6 gate
+
+- Array/disks/SMART/pools/shares/capacity.
+- Read-only safety.
+- Storage stale/unknown and alerts inputs.
+- Simulated degradation.
+- No real destructive test.
+
+## M7 gate
+
+- Probe engine and SSRF controls.
+- TLS/DNS/network/service history.
+- Dependencies/topology.
+- Container-running/service-down detection.
+- Suppression inputs.
+
+## M8 gate
+
+- Rule versions/state machine/hysteresis.
+- Unknown/silence/maintenance/suppression.
+- Notifications audit.
+- Incident grouping/timeline/notes.
+- Alert storm scenario produces expected grouping.
+- Concurrency/restart tests.
+
+## M9/final gate
+
+- Complete Dutch UX, mobile and wallboard.
+- Accessibility and performance budgets.
+- Security hardening/scans.
+- Real wallboard soak of at least 17 hours. This supersedes the original
+ 24-hour duration only through the explicit product-owner decision recorded
+ for M10-14 on 2026-08-11; all other continuity and performance budgets remain.
+- Backup/restore and upgrade/rollback.
+- Clean-room install.
+- Production deployment, restart and smoke.
+- Final requirement/evidence matrix.
+- Runbook and current state accurate.
diff --git a/docs/engineering/TEST_STRATEGY.md b/docs/engineering/TEST_STRATEGY.md
new file mode 100644
index 0000000..3699ace
--- /dev/null
+++ b/docs/engineering/TEST_STRATEGY.md
@@ -0,0 +1,137 @@
+# Test strategy
+
+## Test pyramid and evidence
+
+Tests prove behavior at the cheapest reliable level, but critical workflows require end-to-end proof.
+
+## 1. Unit tests
+
+Required for:
+- status aggregation;
+- freshness/staleness;
+- metric transformations and units;
+- query limit calculations;
+- inventory identity/reconciliation;
+- dashboard validation/migration/editor reducer;
+- alert state machine, hysteresis and suppression;
+- incident correlation rules;
+- authorization policy;
+- probe target validation/SSRF rules;
+- formatting/localization.
+
+Use deterministic clocks and fixtures.
+
+## 2. Contract tests
+
+For:
+- Prometheus responses/errors/warnings;
+- Unraid API capabilities and payload variants;
+- agent protocol;
+- OIDC claims;
+- notification connectors;
+- the isolated real-stack gate (`scripts/integration-smoke.ps1`) for collector → PostgreSQL → API → UI and alert → webhook delivery;
+- dashboard/live/event schemas.
+
+Captured fixtures must be redacted and versioned.
+
+## 3. Integration tests
+
+Use real PostgreSQL through Testcontainers or equivalent.
+
+Cover:
+- migrations from empty and prior versions;
+- transaction/concurrency;
+- optimistic locking;
+- discovery/reconciliation;
+- alert evaluation/outbox;
+- backup/restore;
+- API authorization;
+- WebSocket persistence/reconnect interactions where practical.
+
+Prometheus and Unraid can use deterministic simulators/fake servers, plus optional non-destructive live contract checks.
+
+## 4. End-to-end browser tests
+
+Playwright core flows:
+- login/session;
+- overview healthy/degraded/unknown;
+- dashboard create/edit/drag/resize/config/save/reload;
+- version restore and conflict;
+- time range and cross-filter;
+- entity drill-down;
+- alert acknowledge/silence;
+- incident view/note;
+- mobile navigation;
+- wallboard reconnect;
+- permission differences;
+- source failure and recovery.
+
+Run desktop and mobile viewports. Add a wallboard viewport.
+
+## 5. Accessibility
+
+Automated checks on every core route/state:
+- desktop and mobile;
+- keyboard flow;
+- focus after modal/drawer/drag alternative;
+- status without color;
+- chart summary/alternative;
+- reduced motion.
+
+Manual spot checks for screen-reader naming and dashboard editor keyboard behavior.
+
+## 6. Performance/load/soak
+
+- API benchmarks and P95 load tests.
+- WebSocket clients/subscriptions/fan-out.
+- Query dedup/cache behavior.
+- 24-hour wallboard heap/subscription/resource soak.
+- 150 container/40 disk/300 probe fixture scale.
+- slow Prometheus/database/agent recovery.
+- frontend rendering with maximum supported widgets.
+
+## 7. Security
+
+- RBAC matrix per endpoint and WebSocket message.
+- OIDC state/nonce/issuer/audience.
+- CSRF/cookie/origin.
+- SSRF, redirect, DNS rebinding, metadata targets.
+- XSS through names/events/import/Markdown.
+- query template and raw query limits.
+- rate/body/message limits.
+- secret scan and diagnostic redaction.
+- dependency/image/static scan.
+- compose mounts/capabilities/network exposure.
+- backup contents.
+
+## 8. Failure simulation
+
+Use `fixtures/scenarios/`:
+- stale Prometheus;
+- source disconnect;
+- CPU saturation;
+- memory pressure/OOM;
+- container restart loop;
+- service down while container runs;
+- disk temperature;
+- SMART warning;
+- cache/pool pressure;
+- array degradation fixture;
+- DNS/gateway failure;
+- UPS on battery;
+- WebSocket slow client/reconnect;
+- database restart.
+
+Never induce destructive real faults.
+
+## 9. Gate behavior
+
+A failing required test:
+- keeps task/milestone incomplete;
+- is diagnosed and repaired;
+- may be quarantined only for a proven external nondeterministic issue, with owner, expiry and alternate evidence;
+- is never simply deleted or skipped.
+
+## 10. Clean-room
+
+Final release is built/deployed from a clean checkout using documented inputs, no developer `.env`, caches or untracked files. Migrations, seed/default dashboards, auth config, health, smoke and restart are verified.
diff --git a/docs/operations/BACKUP_RESTORE.md b/docs/operations/BACKUP_RESTORE.md
new file mode 100644
index 0000000..3e8b9ef
--- /dev/null
+++ b/docs/operations/BACKUP_RESTORE.md
@@ -0,0 +1,128 @@
+# Backup and restore
+
+## Backup contents
+
+- PostgreSQL logical backup or selected physical method with documented compatibility;
+- Pulse configuration;
+- dashboard versions;
+- alert rules and maintenance windows;
+- inventory/events/incidents/audit according to policy;
+- non-secret datasource/channel configuration;
+- schema/application version;
+- image digests;
+- checksums and manifest.
+
+Secrets:
+- are backed up only through an explicitly secured secret-store procedure;
+- never appear plaintext in portable exports;
+- are documented as required reattachment steps if excluded.
+
+## Backup behavior
+
+- on-demand administrator action;
+- scheduled optional job;
+- unique immutable backup ID;
+- temporary file + atomic finalize;
+- checksum;
+- size and duration;
+- success/failure audit;
+- retention;
+- destination health;
+- no backup only inside the source database volume.
+
+## Verification
+
+A backup is not trusted until:
+- checksum passes;
+- archive/format opens;
+- manifest/schema version valid;
+- secret scan/redaction policy passes;
+- restore test has succeeded for the release.
+
+## Restore test
+
+Use an empty isolated environment:
+
+1. deploy compatible database/application version;
+2. restore backup;
+3. run migrations if expected;
+4. start services;
+5. authenticate;
+6. verify dashboards/rules/inventory/events/incidents;
+7. query metrics datasource through restored config;
+8. run smoke tests;
+9. compare key counts/checksums;
+10. document credential reattachment.
+
+Do not overwrite production during routine validation.
+
+## Upgrade rollback
+
+Before schema upgrade:
+- create verified backup;
+- record current migration version and image digests;
+- test upgrade from prior release fixture;
+- test supported downgrade or forward-recovery strategy;
+- document compatibility window.
+
+If down migration is unsafe, use restore/forward-fix and state this explicitly.
+
+### Supported M9-10 recovery path
+
+Pulse migrations are forward-only. Do not manually remove rows from
+`schema_migrations`, run ad-hoc down SQL, or point an older image at a schema
+whose compatibility has not been proven. The supported recovery path after a
+failed upgrade is:
+
+1. Freeze only the Pulse compose project and preserve the migration/API logs.
+2. Record the image digests, `schema_migrations` rows and the verified backup
+ ID before making another attempt.
+3. Correct the release/configuration fault, use the same or a newer compatible
+ Pulse image, and re-run `cmd/migrate`. Each migration is committed with its
+ schema-migration record in one transaction and repeat runs are safe.
+4. Restart the Pulse API/worker/agent, then verify health, schema migration
+ count, dashboard/rule current-version links and the operational smoke
+ checks.
+5. If forward recovery cannot be completed, build a new empty isolated target,
+ restore the last verified portable backup, reattach excluded secrets, run
+ compatible migrations and validate it before any production cutover.
+
+The old image/configuration is a rollback point only when its documented
+schema compatibility is satisfied. M9-10 verified the baseline `61cb2a4` to
+`11e3496` transition, a repeat migration and a PostgreSQL transaction fault
+rollback. It does not claim that arbitrary future down-migrations are safe.
+
+## Recovery objectives
+
+Codex must measure and document practical RPO/RTO for the environment. Do not invent guarantees.
+
+Baseline intent:
+- configuration/incident loss limited by backup schedule;
+- restore procedure executable by the operator;
+- no dependency on the failed Pulse API to access backup instructions.
+
+## M9-09 portable backup
+
+Configure a protected, operator-owned destination with `PULSE_BACKUP_DIR` and an optional retention count with `PULSE_BACKUP_RETENTION` (default 5, maximum 100). The API exposes the following administrator-only endpoint:
+
+- `GET /api/v1/system/backups` lists verified backup archives.
+- `POST /api/v1/system/backups` creates a temporary archive, writes it atomically, emits a sidecar SHA-256 checksum and applies retention.
+
+Format version 2 of the archive is a ZIP containing a manifest and deterministic JSONL entries for the approved non-secret persistence tables. Durable discovery identity in `container_aliases` is included so a restored worker does not assign new entity identities to unchanged containers. Runtime configuration, `system_settings`, notification channel configuration, secret references, notification delivery state and bounded `agent_snapshots` and `capacity_samples` runtime telemetry are excluded and must be reattached or republished through the approved procedure. The exporter rejects sensitive JSON keys rather than writing them. The manifest records the migration count, table row counts and per-entry SHA-256 values. Restore first verifies the archive and sidecar, then only restores into a database where every backed-up table is empty, inside one transaction. Current dashboard/rule version foreign keys are restored after their version rows exist. Version 1 archives predate durable container-alias coverage and are deliberately rejected instead of being accepted as complete recovery points.
+
+The PostgreSQL clean-room test classifies every migrated application table as
+either portable or deliberately excluded and validates every configured export
+and restore column against the migrated schema. CI sets
+`PULSE_REQUIRE_BACKUP_INTEGRATION=true` and provisions separate source and
+restore databases, so a missing DSN or a newly unclassified table fails rather
+than silently skipping this release gate.
+
+A reproducible DSN-gated test is:
+
+```text
+$env:PULSE_TEST_DATABASE_URL = 'postgresql://...'
+$env:PULSE_TEST_RESTORE_DATABASE_URL = 'postgresql://...'
+go test ./internal/backup -run TestPostgreSQLBackupRestoreCleanRoom -count=1 -v
+```
+
+Do not put actual DSNs, passwords, secret-store values or backup bytes in the repository, evidence or shell history. The restore test uses an isolated empty environment and never overwrites production.
diff --git a/docs/operations/DEVELOPMENT_SETUP.md b/docs/operations/DEVELOPMENT_SETUP.md
new file mode 100644
index 0000000..a87e677
--- /dev/null
+++ b/docs/operations/DEVELOPMENT_SETUP.md
@@ -0,0 +1,61 @@
+# Development setup
+
+## Prerequisites
+
+- Git;
+- Go 1.26.6;
+- Node.js 24 or newer and pnpm 10.33 or newer;
+- Python 3 with the packages in `requirements-dev.txt`;
+- PowerShell 7;
+- Docker Engine and Docker Compose;
+- Chromium for Playwright end-to-end tests.
+
+## Bootstrap
+
+From a clean checkout:
+
+```powershell
+Copy-Item .env.example .env
+pwsh -NoProfile -File scripts/bootstrap.ps1
+```
+
+The example environment uses mock authentication and local-only credentials. Never reuse production credentials, backups, dashboards, telemetry, or host inventories.
+
+## Run the development stack
+
+```powershell
+docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up --build
+```
+
+The development override publishes the web, API, and PostgreSQL ports for local testing. Treat those ports as development-only and use a host firewall when working on an untrusted network.
+
+Stop only this stack with:
+
+```powershell
+docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml down --volumes
+```
+
+## Validation
+
+For a public source checkout:
+
+```powershell
+pwsh -NoProfile -File scripts/public-verify.ps1
+```
+
+The optional real source-to-browser smoke uses an isolated Compose project and tears it down after the run:
+
+```powershell
+pwsh -NoProfile -File scripts/integration-smoke.ps1
+```
+
+PostgreSQL integration tests use explicit process-local test DSNs. Never point them at a database that contains data you need to keep.
+
+## Production separation
+
+- production uses OIDC and rejects mock authentication;
+- the base/production profiles do not publish PostgreSQL or the API;
+- runtime secrets remain outside Git;
+- fixtures are synthetic;
+- the agent remains read-only and receives no Docker socket;
+- production changes follow [`../PUBLIC_DEPLOYMENT.md`](../PUBLIC_DEPLOYMENT.md), not the development procedure.
diff --git a/docs/operations/OBSERVABILITY_OF_PULSE.md b/docs/operations/OBSERVABILITY_OF_PULSE.md
new file mode 100644
index 0000000..be1560d
--- /dev/null
+++ b/docs/operations/OBSERVABILITY_OF_PULSE.md
@@ -0,0 +1,87 @@
+# Observability of Pulse
+
+Pulse must expose its own health without creating a circular single point of truth.
+
+## Internal metrics
+
+At minimum:
+- API request count/duration/error by bounded route code;
+- active WebSocket clients/subscriptions;
+- dropped/coalesced live samples;
+- Prometheus query duration/errors/series/points/cache;
+- discovery runs and reconciliation counts;
+- worker job duration/failure/lag;
+- alert evaluation duration/state transitions;
+- notification attempts/failures;
+- probe queue/concurrency/results;
+- database pool/queries/migration;
+- agent heartbeat/capabilities/errors;
+- backup success/age;
+- process CPU/memory/goroutines.
+
+Avoid high-cardinality path/user/entity labels.
+
+## Health endpoints
+
+- liveness: process loop alive, no dependency checks that cause restart storms;
+- readiness: mandatory dependencies usable;
+- detailed system status: authenticated, shows component state/freshness.
+
+## Logs
+
+Structured production logs:
+- timestamp;
+- level;
+- service/version;
+- correlation/request/job ID;
+- operation;
+- safe entity/resource IDs;
+- error code and wrapped message;
+- duration/result.
+
+Redact:
+- tokens/cookies/authorization;
+- passwords/connection secrets;
+- sensitive headers;
+- full upstream bodies;
+- private keys.
+
+## External dead-man
+
+An independent existing monitor or simple external check should detect:
+- Pulse HTTPS unavailable;
+- Pulse heartbeat absent;
+- backup too old if supported.
+
+Do not rely solely on Pulse to alert that Pulse is completely down.
+
+## System status page
+
+Shows:
+- build/version/commit;
+- database;
+- Prometheus;
+- Unraid/agent;
+- OIDC;
+- worker heartbeats;
+- query health;
+- probes;
+- notifications;
+- backup age;
+- active clients;
+- storage use;
+- last successful critical jobs.
+
+No secret/config values.
+
+## M9-08 operating contract
+
+The public liveness contract is GET /healthz and returns exactly ok with HTTP 200 when the Pulse process is serving requests. It deliberately does not query PostgreSQL or upstream datasources, so an independent monitor can distinguish a dead Pulse process from a dependency outage. GET /readyz is the dependency-aware readiness contract and returns HTTP 503 while the database is unavailable.
+
+The authenticated status surface is GET /api/v1/system/status and is available to viewers. It reports bounded component state, explicit Unknown/Disabled reasons, backup age (Unknown until a verified backup exists), and source lag (Unknown until a source reports an observed timestamp). It never reports unsampled sources as Healthy. Operators with the operate permission can read /api/v1/system/diagnostics and /api/v1/system/metrics; these routes are not public monitoring endpoints.
+
+Configure an independent monitor outside Pulse to run every 60 seconds with two consecutive failures required for alerting:
+
+ python tools/deadman_check.py https://pulse.example.invalid/healthz --timeout 5
+
+The script uses only the Python standard library, refuses embedded credentials, bounds the timeout to 1-30 seconds, accepts only HTTP 200 with the exact ok body, and exits non-zero for transport, timeout, redirect-result, or HTTP failures. Keep the monitor and its alerting path independent of the Pulse API and database.
\ No newline at end of file
diff --git a/docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md b/docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
new file mode 100644
index 0000000..ada0b97
--- /dev/null
+++ b/docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
@@ -0,0 +1,118 @@
+# Worker/agent healthcheck contract
+
+Applies to `pulse-worker` (`cmd/worker`) and `pulse-agent` (`cmd/agent`). Both
+are currently foundation stubs (`cmd/worker/main.go`, `cmd/agent/main.go`)
+that will gain real scheduling loops. This document is the contract the Go
+implementation must satisfy so the compose-side healthcheck already wired in
+`deploy/compose.yaml` reports true liveness instead of "process exists".
+
+## Why not an HTTP `/healthz`
+
+`pulse-api` and `pulse-web` already expose a network port, so an HTTP
+`/healthz` costs nothing extra there. `pulse-worker` and `pulse-agent` sit on
+`pulse-internal` only and have no other reason to bind a port; opening one
+solely for a healthcheck would be an unnecessary internal attack surface
+(see `apps/agent/AGENTS.md`: "No generic shell/exec/file-write endpoint" and
+the general least-privilege posture of ADR-0005/ADR-0010). A heartbeat file
+on the already-mounted `tmpfs` `/tmp` needs no new port, no new capability,
+and no new listener.
+
+## What the compose/image side already does
+
+- `deploy/pulse-entrypoint.sh` is the container `ENTRYPOINT`. It writes the
+ current unix time to `/tmp/.pulse-started-at` and then `exec`s the real
+ binary (PID 1 becomes the Go process). This exists only so the healthcheck
+ script can grant a startup grace period independent of Docker's
+ `start_period`, which suppresses unhealthy *status* but does not stop the
+ `HEALTHCHECK CMD` (or its side effects) from running during that window.
+- `deploy/healthcheck-heartbeat.sh` is the `HEALTHCHECK CMD` (also wired
+ explicitly in `deploy/compose.yaml`, `interval=15s timeout=5s
+ start_period=20s retries=3`). It checks the mtime of the heartbeat file
+ described below. If the file is missing for longer than
+ `PULSE_HEARTBEAT_MAX_AGE_SECONDS` (default **45s**) after container start,
+ or exists but its mtime is older than that same threshold, the script logs
+ why and sends `SIGKILL` to PID 1. `docker compose up` (non-swarm) does not
+ restart a container merely because it is reported "unhealthy" — killing
+ PID 1 turns that into a real container exit, which `restart:
+ unless-stopped` then recovers automatically. Same-UID `SIGKILL` needs no
+ Linux capability, so this works under `cap_drop: [ALL]`.
+
+You do not need to change either script or the Dockerfiles to implement this
+contract — only the points below, inside `cmd/worker` / `cmd/agent` and
+whatever internal packages they call.
+
+## What the Go side must implement
+
+1. **Path.** Write the heartbeat to the file named by the
+ `PULSE_HEARTBEAT_FILE` environment variable if set, otherwise
+ `/tmp/healthy`. That path is on the container's `tmpfs` `/tmp` mount
+ (already present in `deploy/compose.yaml` for both services), so no new
+ volume or mount is needed.
+2. **Cadence tied to real progress, not a free-running ticker.** Update the
+ heartbeat only after the main scheduling loop completes an iteration (a
+ scheduler tick, a due-job scan, a lease renewal, a completed
+ discovery/capability-collection pass — whatever the loop's unit of work
+ is for that binary). Do **not** heartbeat from an independent goroutine
+ that ticks on a timer regardless of whether the main loop is stuck — that
+ would silently defeat the whole point of this contract (the exact failure
+ mode `kill -0 1` had).
+3. **Interval.** The loop must complete an iteration, and therefore heartbeat,
+ at least every **10 seconds** even when there is no work to do (an empty
+ poll is still a completed iteration). This gives a comfortable margin
+ under the 45s staleness threshold enforced by
+ `deploy/healthcheck-heartbeat.sh`, tolerating a couple of missed/slow
+ cycles before the container is killed.
+4. **First heartbeat.** Write one heartbeat immediately after startup
+ (config loaded, DB reachable) and before blocking on the first real unit
+ of work, so a slow-but-healthy cold start is not mistaken for a hang.
+5. **Bounded blocking calls.** Every blocking call inside the loop iteration
+ (DB queries, Unraid/agent-protocol calls, probe dials, notification
+ sends) must use a `context` with a timeout well under 10s, and
+ long-running job execution must happen in a separate goroutine so the
+ scheduler loop itself stays responsive. This is also required by
+ `apps/worker/AGENTS.md` ("Every job is idempotent, cancellable, bounded
+ and observable") independent of this contract — the two requirements
+ reinforce each other: if a call isn't bounded, the loop stalls, the
+ heartbeat goes stale, and the container is killed and restarted,
+ correctly surfacing the hang instead of hiding it.
+6. **Write semantics.** A simple truncate-and-write (or an `os.Chtimes` touch
+ if content does not need to change) is sufficient; the healthcheck reads
+ only the file's mtime, not its contents, so no locking or atomic
+ rename is required for correctness. For operator debuggability, write the
+ current UTC time in RFC 3339 (e.g. `2026-08-04T12:00:03Z\n`) as the file
+ content so `docker exec cat /tmp/healthy` is meaningful.
+7. **Failure handling.** If writing the heartbeat file itself fails (e.g.
+ tmpfs write error), log it at error level and continue the loop — do not
+ crash the process for a heartbeat I/O failure alone. A sustained write
+ failure will naturally show up as staleness and get caught by the
+ healthcheck.
+8. **Shutdown.** No special handling is required on `SIGTERM`/graceful
+ shutdown; leaving the heartbeat file in place is fine since the container
+ is stopping anyway (`service.WaitForStop` already handles the
+ signal-driven shutdown path in both `cmd/worker/main.go` and
+ `cmd/agent/main.go`).
+
+## Tunables
+
+| Env var | Default | Set where | Meaning |
+|---|---|---|---|
+| `PULSE_HEARTBEAT_FILE` | `/tmp/healthy` | worker/agent process env (compose) | Path the Go process writes and the healthcheck script reads. |
+| `PULSE_HEARTBEAT_MAX_AGE_SECONDS` | `45` | healthcheck script env (compose, if overridden) | Staleness threshold before the healthcheck force-restarts the container. |
+| `PULSE_STARTED_AT_FILE` | `/tmp/.pulse-started-at` | entrypoint/healthcheck script env (compose, if overridden) | Where the entrypoint records container start time, used only for startup grace. |
+
+None of these are currently set as explicit environment variables in
+`deploy/compose.yaml` (the defaults baked into the scripts are used); add
+them there if the recommended values above ever need to change per
+deployment.
+
+## Reviewer double-check
+
+Self-terminating a container from inside its own healthcheck is an unusual
+pattern. It was chosen because `docker compose up` (non-swarm, confirmed by
+`docs/operations/DEVELOPMENT_SETUP.md`) does not auto-restart merely
+"unhealthy" containers, and an external auto-heal watcher would need Docker
+socket access, which ADR-0005 forbids host-wide. If that trade-off is
+unacceptable, the alternative is to drop the `kill -9 1` and rely on the
+Unraid dashboard surfacing "unhealthy" status for manual operator action —
+but that leaves the original "never restarts" finding only partially fixed
+(visible, not self-healing).
diff --git a/docs/product/MONITORING_REQUIREMENTS.md b/docs/product/MONITORING_REQUIREMENTS.md
new file mode 100644
index 0000000..390c8ff
--- /dev/null
+++ b/docs/product/MONITORING_REQUIREMENTS.md
@@ -0,0 +1,94 @@
+# Host, container and application monitoring requirements
+
+## Host
+
+Metrics/state:
+- uptime and boot time;
+- CPU total/per core, load, frequency where available;
+- iowait, interrupts and context switches;
+- memory used/available/cache/swap and pressure;
+- filesystem usage/inodes;
+- network per interface, errors/drops;
+- process count and OOM events;
+- temperatures/fans/sensors where supported;
+- GPU usage/memory/temperature where supported;
+- time synchronization health.
+
+Host status must distinguish:
+- high but expected load;
+- sustained saturation;
+- missing collector;
+- stale metrics;
+- thermal risk;
+- resource exhaustion.
+
+## Process explorer
+
+Read-only, bounded view:
+- top CPU;
+- top memory;
+- PID, name, state, runtime;
+- container association when known;
+- process tree on demand.
+
+No process termination. Process command-line arguments, environment and working directory are not shown by default; process rows are bounded and read-only.
+
+## Containers
+
+Inventory:
+- ID/name;
+- image/tag/digest when available;
+- state and health;
+- uptime/start/stop;
+- restart count and exit code;
+- CPU/memory;
+- network;
+- block I/O;
+- ports;
+- volumes/networks;
+- stack/project and labels.
+
+Behavior:
+- preserve runtime state separately from health;
+- expose intentional stop explicitly so a stopped container is not silently treated as healthy;
+- retain source freshness and provenance;
+- expose metric and lifecycle availability explicitly; absent collector fields render as Unknown rather than numeric zero;
+- bound container count and detail collections for predictable response size;
+- detect restart loops over a rolling window;
+- distinguish intentionally stopped/disabled;
+- preserve history across container recreation through stable identity mapping;
+- never infer application health solely from Docker `running`.
+
+### Container identity and recreation
+
+Runtime IDs are source-scoped aliases. A changed runtime ID maps to the same logical container only when the source, compose project and compose service form a unique stable key. Reusing a name without that evidence creates a new logical entity; ambiguous stable keys also refuse to merge. Historical aliases remain visible to reconciliation, and lifecycle events retain the logical entity ID while recording the prior and current runtime IDs.
+## Lifecycle events and instability
+
+Container transitions are normalized into bounded lifecycle events for state, health, restart and intentional-stop changes. Duplicate source/dedup/time events are discarded. A restart loop requires the current container state to be running and at least the configured number of restarts inside the bounded window; a stopped container is not labeled as looping. Ranked resource consumers use deterministic ID/label tie-breaks, and status grids retain text reasons alongside status indicators.
+## Applications
+
+Applications group containers and services into a policy-driven status. Critical component failures degrade the application; critical unknown state remains Unknown. Optional component failures are included in the reasons list and degrade the aggregate without being labeled critical. A service-down state degrades the component even when its container runtime is still running. User overrides for application names and component criticality are applied after discovery and survive repeated discovery projections.
+
+Applications group one or more containers and services.
+
+Fields:
+- friendly name;
+- critical/optional components;
+- dependencies;
+- public/internal URLs;
+- owner/category/tags;
+- aggregate status reasons.
+
+Aggregate status is policy-driven and tested. An optional background worker may fail without making the complete application critical, while a database or main endpoint failure normally degrades it.
+
+## Events
+
+Generate normalized events for:
+- container start/stop/restart/die/health change;
+- image or configuration change;
+- application status change;
+- datasource loss/recovery;
+- host reboot;
+- OOM and thermal events.
+
+Deduplicate noisy repeated events.
diff --git a/docs/product/PRODUCT_REQUIREMENTS.md b/docs/product/PRODUCT_REQUIREMENTS.md
new file mode 100644
index 0000000..5143b93
--- /dev/null
+++ b/docs/product/PRODUCT_REQUIREMENTS.md
@@ -0,0 +1,238 @@
+# ITWorx Pulse — Product requirements
+
+## 1. Product statement
+
+ITWorx Pulse is a self-hosted operational observability platform for a personal/professional Unraid environment. It combines infrastructure, container, storage, network and service health in a configurable dashboard that is easier to understand and operate than a collection of raw monitoring tools.
+
+Pulse is not a thin Grafana theme. It owns:
+
+- inventory and relationships;
+- semantic metrics;
+- dashboard composition;
+- status reasoning;
+- alert lifecycle;
+- incident grouping;
+- user experience;
+- operational documentation and audit.
+
+Prometheus-compatible systems remain the primary source of time-series history in v1.
+
+## 2. Primary user
+
+The primary user is a technically experienced server owner/operator who:
+
+- manages an Unraid host with many Docker applications;
+- wants one reliable status surface;
+- needs detailed drill-down without constant PromQL work;
+- uses desktop, mobile and a possible wallboard;
+- values safe automation and read-only monitoring;
+- may later link incidents to a separate management platform.
+
+The architecture supports additional viewers/operators but v1 does not need multi-tenant SaaS behavior.
+
+## 3. Goals
+
+1. Determine within seconds whether the environment is healthy.
+2. Explain every degraded/critical/unknown state.
+3. Allow dashboards to be composed without code.
+4. Show smooth live behavior and useful history.
+5. Detect container, storage and service failure independently.
+6. Prevent alert storms and group related failures.
+7. Remain safe when telemetry is stale or unavailable.
+8. Deploy without disrupting existing Unraid services.
+9. Provide reproducible evidence for operation, backup and recovery.
+10. Be portfolio-quality in visual, technical and operational terms.
+
+## 4. Non-goals for v1
+
+- General log aggregation/search platform.
+- Kubernetes monitoring.
+- Multi-organization SaaS.
+- Automatic remediation.
+- Container lifecycle management.
+- Array/storage write operations.
+- Public unauthenticated monitoring.
+- AI-required diagnosis.
+- Unlimited third-party plugin marketplace.
+- Replacing Prometheus or Grafana without measured need.
+- Monitoring arbitrary internet targets without an allowlist.
+
+## 5. Functional capabilities
+
+### 5.1 Authentication and access
+
+- Authentik-compatible OIDC login.
+- Viewer, Operator, Editor and Administrator roles.
+- Disabled-by-default local break-glass recovery.
+- Session expiry and logout.
+- Audit for security/configuration changes.
+- Wallboard uses a constrained read-only mode or authenticated session.
+
+### 5.2 Overview
+
+The default overview shows:
+
+- global status and explanation;
+- active incidents/alerts;
+- uptime, CPU, memory and load;
+- array and pool status/capacity;
+- network traffic;
+- container/application health;
+- service reachability and latency;
+- UPS when available;
+- recent events;
+- freshness of each source.
+
+### 5.3 Dashboards
+
+Users can:
+
+- create, clone, rename, archive and delete dashboards;
+- add widgets from a catalog;
+- drag, resize, lock, duplicate, hide and remove widgets;
+- configure data, visualization, thresholds and behavior;
+- preview and edit desktop/tablet/mobile/wallboard layouts;
+- use variables and cross-filtering;
+- undo/redo during editing;
+- save versioned revisions and restore previous versions;
+- import/export validated JSON;
+- use system templates;
+- open widgets fullscreen and drill into entities.
+
+### 5.4 Inventory and relationships
+
+Pulse discovers and maintains:
+
+- host;
+- hardware and interfaces;
+- array, pools, disks, filesystems and shares;
+- containers, images, stacks, volumes and networks;
+- applications and services;
+- endpoints and certificates;
+- data sources, collectors and probes;
+- dependency relationships.
+
+Every reconciled field retains source ownership and optional user override.
+
+### 5.5 Metrics and charts
+
+- Historical range queries.
+- Live updates with bounded memory.
+- Semantic metric catalog.
+- Units, transformations and aggregation.
+- Zoom, hover, pause, time range and compare.
+- Stale/unknown representation.
+- Query limits and authorization.
+- CSV/image export where appropriate.
+- Event annotations.
+
+### 5.6 Alerts and incidents
+
+- Versioned alert rules.
+- Pending, firing, acknowledged and resolved lifecycle.
+- Unknown, silenced, suppressed and maintenance states.
+- Hysteresis, cooldown, grouping and dependency suppression.
+- Notification channels with delivery audit.
+- Incident creation/grouping from related alerts.
+- Timeline, notes, ownership and status.
+- Suggested relationships labeled as uncertain unless proven.
+
+### 5.7 Operations
+
+- Datasource health page.
+- Self-monitoring page.
+- Backup/restore.
+- Config export.
+- Safe deployment and rollback.
+- Maintenance windows.
+- Diagnostic bundle with secret redaction.
+- Clear runbook.
+
+## 6. Core user journeys
+
+### Journey A — Morning health check
+
+1. User opens Pulse.
+2. Global state loads within the performance budget.
+3. The page shows Operational or a specific explained deviation.
+4. User opens an affected component from the status explanation.
+5. Related graph/event context is visible without constructing a query.
+
+### Journey B — Customize overview
+
+1. User enters Edit mode.
+2. Adds a network chart.
+3. Resizes and positions it.
+4. Configures the interface and time range.
+5. Previews mobile layout.
+6. Saves.
+7. Reload preserves all layouts and version history.
+
+### Journey C — Diagnose service failure
+
+1. Application container remains running.
+2. HTTP probe returns failure.
+3. Service becomes Degraded.
+4. Alert transitions pending -> firing after its duration.
+5. Related container/network events appear.
+6. User acknowledges the incident.
+7. Recovery resolves the alert and records duration.
+
+### Journey D — Storage risk
+
+1. SMART/temperature or pool capacity crosses policy.
+2. Pulse explains which disk/pool and why.
+3. User sees current values and history.
+4. No destructive storage action is offered.
+5. Optional link opens an external operational workflow later.
+
+### Journey E — Mobile incident view
+
+1. User opens Pulse on mobile.
+2. Active incident is immediately accessible.
+3. Summary, affected entities, latest values and acknowledgement fit the mobile flow.
+4. Complex dashboard editing is not required.
+
+## 7. Status model
+
+Top-level and entity status:
+
+- `operational`
+- `attention`
+- `degraded`
+- `critical`
+- `unknown`
+- `maintenance`
+- `disabled`
+
+Rules:
+
+- `unknown` outranks a false `operational` claim when required data is stale.
+- Top-level status includes a list of contributing reasons.
+- Optional/non-critical component failures do not automatically become critical.
+- Status aggregation is deterministic and tested.
+- Color is never the only representation.
+
+## 8. Acceptance-level non-functional requirements
+
+- Clean build/deploy from a new checkout.
+- Responsive at target scale.
+- At least 17 hours of real wallboard soak without unbounded growth; the
+ product owner explicitly replaced the original 24-hour duration for M10-14
+ on 2026-08-11 while retaining every other performance budget.
+- Graceful source disconnect/reconnect.
+- No unrestricted Docker socket in API/web.
+- Least privilege and non-root runtime.
+- Automated accessibility checks plus keyboard flows.
+- Backup/restore and upgrade/rollback.
+- Secrets absent from repository, logs and evidence.
+- Traceable requirement-to-test-to-evidence mapping.
+
+## 9. Success metrics
+
+- Time to first meaningful overview: under the defined performance budget.
+- Time to identify a simulated primary fault: less than two minutes in usability validation.
+- Zero false-green results in stale-source tests.
+- All baseline failure scenarios detected with expected status and alert behavior.
+- Dashboard configuration persists and restores across browser/server restart.
+- Production deployment survives planned restart with healthy state and preserved configuration.
diff --git a/docs/product/REQUIREMENTS_INDEX.md b/docs/product/REQUIREMENTS_INDEX.md
new file mode 100644
index 0000000..2933252
--- /dev/null
+++ b/docs/product/REQUIREMENTS_INDEX.md
@@ -0,0 +1,44 @@
+# Requirements index
+
+The machine-readable/portable traceability matrix is `planning/requirements-matrix.csv`.
+
+- **PRD-001** — Global health is visible and explained — tasks: `M2-09;M5-07;M6-09;M7-09;M8-10;M9-13` — verification: E2E + final acceptance
+- **PRD-002** — Stale required telemetry is Unknown, never Healthy — tasks: `M2-07;M4-11;M5-09;M6-09;M8-03;M9-13` — verification: Unit + scenario + E2E
+- **PRD-003** — Dashboard CRUD and version history — tasks: `M3-01;M3-03;M3-11` — verification: API + integration + E2E
+- **PRD-004** — Drag, resize, lock, duplicate, hide and remove widgets — tasks: `M3-05;M3-11` — verification: Playwright + accessibility
+- **PRD-005** — Per-viewport desktop/tablet/mobile/wallboard layouts — tasks: `M3-07;M9-03;M9-04` — verification: E2E + visual
+- **PRD-006** — Undo/redo, atomic save and conflict recovery — tasks: `M3-08;M3-11` — verification: Unit + integration + E2E
+- **PRD-007** — Dashboard variables and cross-filtering — tasks: `M3-09;M3-11` — verification: E2E
+- **PRD-008** — Validated dashboard import/export/templates — tasks: `M3-10;M3-11` — verification: Schema/security/E2E
+- **PRD-009** — Semantic bounded metrics query layer — tasks: `M4-01;M4-02;M4-03;M4-04` — verification: Unit + contract + load
+- **PRD-010** — Live charts use bounded efficient subscriptions — tasks: `M4-06;M4-07;M4-08;M4-09;M4-12` — verification: Protocol + load + soak
+- **PRD-011** — Host compute/memory/network monitoring — tasks: `M5-01;M5-07;M5-09` — verification: Contract + E2E
+- **PRD-012** — Optional sensors/GPU degrade gracefully — tasks: `M5-02;M5-09` — verification: Capability tests
+- **PRD-013** — Read-only bounded process explorer — tasks: `M5-03;M5-09` — verification: Security + UI
+- **PRD-014** — Container inventory/resource/health monitoring — tasks: `M5-04;M5-05;M5-07;M5-09` — verification: Scale + E2E
+- **PRD-015** — Application grouping independent of container running — tasks: `M5-06;M5-08;M7-05` — verification: Scenario + E2E
+- **PRD-016** — Array/parity monitoring without controls — tasks: `M6-01;M6-09` — verification: Scenario + architecture test
+- **PRD-017** — Disk capacity, temperature, performance and SMART — tasks: `M6-02;M6-03;M6-04;M6-09` — verification: Fixtures + E2E
+- **PRD-018** — Pool/filesystem/scrub monitoring — tasks: `M6-05;M6-09` — verification: Fixtures + contract
+- **PRD-019** — Shares and capacity forecasting — tasks: `M6-06;M6-08;M6-10` — verification: Unit + performance + UI
+- **PRD-020** — Service probes and availability/latency — tasks: `M7-01;M7-03;M7-04;M7-05;M7-06` — verification: Security + load + E2E
+- **PRD-021** — Probe SSRF and target safety — tasks: `M7-02;M7-11;M9-07` — verification: Negative security tests
+- **PRD-022** — Dependencies and topology with confidence — tasks: `M7-07;M7-08;M7-11` — verification: Unit + UI
+- **PRD-023** — Network/DNS/gateway/TLS health are distinct — tasks: `M7-09;M7-11` — verification: Scenarios + E2E
+- **PRD-024** — Alert state lifecycle and persistence — tasks: `M8-01;M8-02;M8-03;M8-04;M8-11` — verification: State machine + restart
+- **PRD-025** — Alert grouping, suppression, silence and maintenance — tasks: `M8-05;M8-06;M8-11` — verification: Storm scenarios + E2E
+- **PRD-026** — Acknowledgement and audit — tasks: `M8-07;M8-12` — verification: RBAC + concurrency
+- **PRD-027** — Notification delivery is idempotent and audited — tasks: `M8-08;M8-11` — verification: Integration + restart
+- **PRD-028** — Incidents group related alerts with rationale/confidence — tasks: `M8-09;M8-10;M8-11` — verification: Scenario + E2E
+- **PRD-029** — Authentik OIDC and RBAC — tasks: `M1-06;M9-07;M9-12` — verification: Auth matrix + production smoke
+- **PRD-030** — Pulse v1 has no host/Docker/storage mutation path — tasks: `M0-06;M1-08;M5-09;M6-09;M9-07` — verification: Architecture/security scan
+- **PRD-031** — Backup, restore, upgrade and rollback — tasks: `M9-09;M9-10;M9-13` — verification: Clean restore + migration
+- **PRD-032** — Clean-room build and deployment — tasks: `M9-11;M9-12;M9-13` — verification: Clean checkout + production smoke
+- **PRD-033** — Dutch default UI and accessible core workflows — tasks: `M1-03;M9-02;M9-05` — verification: Localization + axe + keyboard
+- **PRD-034** — Mobile and wallboard are first-class — tasks: `M3-07;M9-03;M9-04;M9-06` — verification: E2E + 24h soak
+- **PRD-035** — Pulse self-observability and independent failure detection — tasks: `M9-08;M9-13` — verification: System status + external smoke
+- **NFR-001** — Target scale 150 containers, 40 disks, 300 probes — tasks: `M2-10;M5-09;M6-10;M7-11;M9-06` — verification: Load/scale
+- **NFR-002** — No unbounded browser/server resource growth — tasks: `M4-12;M9-06` — verification: Load + 24h soak
+- **NFR-003** — Secrets absent from repo/logs/evidence/images — tasks: `M1-05;M1-07;M9-07;M9-09` — verification: Secret scans
+- **NFR-004** — Non-root least-privilege container deployment — tasks: `M1-08;M9-07;M9-12` — verification: Compose/image inspection
+- **NFR-005** — Requirement-to-test-to-evidence traceability — tasks: `M0-08;M1-10;M2-10;M3-11;M4-12;M5-10;M6-10;M7-11;M8-12;M9-13` — verification: Evidence indexes + final matrix
diff --git a/docs/product/SERVICE_MONITORING.md b/docs/product/SERVICE_MONITORING.md
new file mode 100644
index 0000000..e4b95f8
--- /dev/null
+++ b/docs/product/SERVICE_MONITORING.md
@@ -0,0 +1,83 @@
+# Service and network monitoring requirements
+
+## Service model
+
+A service represents a reachable capability independently of container state.
+
+Types:
+- HTTP/HTTPS;
+- TCP;
+- DNS;
+- ICMP where permitted;
+- TLS certificate;
+- JSON response;
+- keyword/content assertion.
+
+A service belongs to an application/entity and may declare dependencies.
+
+## Probe configuration
+
+- target URL/host/port;
+- interval and timeout;
+- expected status codes;
+- redirect policy;
+- TLS verification;
+- optional JSONPath/keyword assertion;
+- allowed source/agent;
+- maintenance schedule;
+- labels/category;
+- secret reference for authenticated probes.
+
+Never store plaintext probe credentials in database exports or evidence.
+
+## SSRF and target safety
+
+The probe engine must:
+- validate scheme and port;
+- resolve and re-check DNS;
+- block link-local, metadata and unspecified addresses;
+- apply an explicit network/target allowlist;
+- prevent redirect escape to disallowed targets;
+- limit response size;
+- avoid executing returned content;
+- restrict custom headers and methods;
+- log redacted destinations.
+
+LAN/private targets may be intentionally allowed through administrator configuration.
+
+## Results
+
+Store/derive:
+- current state;
+- response time;
+- status/error class;
+- availability windows;
+- last success/failure;
+- incident history;
+- TLS expiry/issuer/hostname validity;
+- DNS resolution timing when relevant.
+
+## Network
+
+Monitor:
+- interface throughput;
+- packet errors/drops;
+- gateway reachability;
+- DNS latency/failure;
+- internet reachability using configured targets;
+- internal service dependency failures;
+- certificate expiry.
+
+Avoid conflating internet failure with server failure. Dependency suppression should group downstream service alerts.
+
+## Topology
+
+Relationships may come from:
+- container labels;
+- compose project;
+- reverse proxy connector;
+- service configuration;
+- observed dependency declarations;
+- user confirmation.
+
+Source/confidence must be retained. Inferred edges are visibly different from confirmed edges.
diff --git a/docs/product/STORAGE_MONITORING.md b/docs/product/STORAGE_MONITORING.md
new file mode 100644
index 0000000..2be1b4a
--- /dev/null
+++ b/docs/product/STORAGE_MONITORING.md
@@ -0,0 +1,95 @@
+# Storage monitoring requirements
+
+Storage is a first-class domain with strict read-only behavior.
+
+## Array
+
+Display:
+- current array state;
+- parity presence/state;
+- data disk membership;
+- missing/disabled/emulated disks;
+- read/write activity;
+- parity check state, progress, speed, errors and history;
+- last/next check when available.
+
+No start/stop/check/correct action is offered in v1.
+
+## Disks
+
+For every disk:
+- stable identity and role;
+- model/serial with privacy-aware display;
+- size and filesystem;
+- used/free/inodes;
+- temperature and trend;
+- read/write throughput, IOPS and latency when available;
+- spin state when available;
+- SMART overall and selected attributes;
+- self-test age/result;
+- reallocated, pending and offline-uncorrectable sectors;
+- CRC/interface errors;
+- SSD wear/percentage used where relevant.
+
+Rules must avoid declaring a disk healthy when SMART data is unavailable/stale.
+
+## Pools
+
+Support cache and ZFS/Btrfs pool concepts discovered in the environment:
+
+- members;
+- usable/used/free;
+- profile/redundancy;
+- degraded/faulted/offline member state;
+- scrub status/results;
+- filesystem errors;
+- write/read performance;
+- SSD wear;
+- mover-related signals when available.
+
+Do not assume all pools use the same filesystem.
+
+## Shares
+
+- name and configured policy;
+- used size and growth;
+- participating storage;
+- cache/pool relationship;
+- recent growth;
+- forecast to thresholds;
+- unavailable/stale state.
+
+Potentially expensive size calculation must be rate-limited/cached and optional.
+
+## Forecasting
+
+Capacity forecast:
+- uses configurable historical window;
+- reports method and confidence/uncertainty;
+- handles irregular bulk imports;
+- never presents a precise date without qualification;
+- can be disabled when insufficient history exists.
+
+## Baseline alert classes
+
+- array degraded/missing disk;
+- parity errors;
+- SMART critical attribute;
+- temperature sustained above policy;
+- pool degraded/faulted;
+- capacity thresholds;
+- read-only filesystem;
+- scrub/check errors;
+- stale storage data.
+
+Availability, device health, capacity pressure and thermal pressure are separate
+signals. An online device can therefore be capacity-critical or thermally
+critical without being presented as offline, and a stale observation makes all
+current signal severities Unknown. Pulse uses the stable source device ID as
+the canonical physical identity. Repeated identical observations are
+idempotent; conflicting roles or facts for one physical ID fail closed instead
+of producing duplicate or contradictory topology nodes.
+
+## Safety tests
+
+Storage fault behavior is tested through fixtures/simulator or read-only captured data. Never trigger real SMART damage, pool degradation, disk removal or array operations.
diff --git a/docs/product/UX_SPEC.md b/docs/product/UX_SPEC.md
new file mode 100644
index 0000000..6814155
--- /dev/null
+++ b/docs/product/UX_SPEC.md
@@ -0,0 +1,308 @@
+# UX specification
+
+## 1. Experience principles
+
+- **Explain before exposing raw detail.**
+- **Dense, not cramped.**
+- **Live, not distracting.**
+- **Configurable, not chaotic.**
+- **Safe, not action-heavy.**
+- **Unknown is visible.**
+- **Desktop, mobile and wallboard are designed separately.**
+
+## 2. Information architecture
+
+```text
+Overview
+Dashboards
+
+Infrastructure
+ Host
+ Hardware
+ Storage
+ Network
+ UPS
+
+Containers
+Applications
+Services
+
+Alerts
+Incidents
+Events
+
+Explore
+ Metrics
+ Compare
+ History
+
+Wallboards
+
+Settings
+ Data sources
+ Discovery
+ Alerting
+ Notifications
+ Retention
+ Users & access
+ Backups
+ System
+```
+
+Navigation may collapse responsively. Route names and labels are localized.
+
+## 3. Global shell
+
+### Header
+
+- current server/environment;
+- global time range;
+- live/pause control;
+- refresh/freshness state;
+- active filters;
+- alert count;
+- user menu.
+
+### Source freshness
+
+A compact indicator exposes:
+
+- healthy;
+- delayed;
+- stale;
+- unavailable.
+
+Opening it lists each datasource and last successful sample.
+
+### Global time control
+
+Presets:
+
+- Live;
+- 15 minutes;
+- 1 hour;
+- 6 hours;
+- 24 hours;
+- 7 days;
+- 30 days;
+- custom.
+
+A widget may override the dashboard range, but the override is clearly indicated.
+
+## 4. Overview
+
+### Healthy state
+
+Prioritize trends, capacity and service summary.
+
+### Degraded state
+
+A problem summary appears before normal cards:
+
+```text
+Degraded
+Cachepool is 91% full.
+Plex restarted 4 times in 10 minutes.
+Prometheus storage metrics are 3 minutes stale.
+```
+
+Each reason links to its entity and relevant timeframe.
+
+### Layout
+
+The system template must remain useful before any editing:
+
+- status summary;
+- active incidents;
+- CPU/load;
+- memory;
+- array/pools;
+- network;
+- container/application state;
+- service matrix;
+- recent events.
+
+## 5. Dashboard composer
+
+### Modes
+
+**View mode**
+- no drag handles;
+- widgets fixed;
+- normal chart interactions;
+- fast rendering.
+
+**Edit mode**
+- visible grid and selection outlines;
+- drag/resize handles;
+- widget library;
+- undo/redo;
+- save/exit;
+- breakpoint preview.
+
+Unsaved changes are clearly indicated. Navigation away prompts only when changes would be lost.
+
+### Grid
+
+Recommended logical columns:
+
+- large desktop/wallboard: 24;
+- desktop: 18;
+- tablet: 8;
+- mobile: 1.
+
+Widgets define minimum and preferred sizes. Collision handling and snapping are predictable.
+
+### Widget configuration drawer
+
+Tabs:
+
+1. Data
+2. Visualization
+3. Thresholds
+4. Behavior
+5. Links
+6. Advanced
+
+Changes preview live but persist only through the editor save transaction.
+
+### Editing interactions
+
+- keyboard move/resize alternative;
+- duplicate;
+- lock/unlock;
+- hide per viewport;
+- copy to another dashboard;
+- reset to template;
+- restore version;
+- inspect validation errors before save.
+
+## 6. Entity pages
+
+Common structure:
+
+1. identity, status and reason;
+2. key values and freshness;
+3. primary charts;
+4. relationships/dependencies;
+5. events;
+6. alerts/incidents;
+7. technical metadata.
+
+Deep technical payloads are collapsed by default.
+
+## 7. Alerts and incidents
+
+### Alert list
+
+Supports filters for:
+- state;
+- severity;
+- entity;
+- source;
+- acknowledged;
+- maintenance;
+- time.
+
+Each row shows:
+- concise rule name;
+- affected entity;
+- current value/reason;
+- duration;
+- state;
+- incident relationship.
+
+### Incident page
+
+- plain-language summary;
+- severity and lifecycle;
+- affected entities;
+- contributing alerts;
+- timeline;
+- selected charts;
+- acknowledgement/notes;
+- uncertain relationship labels;
+- external workflow link when configured.
+
+## 8. Mobile
+
+Primary mobile navigation:
+
+- Overview;
+- Incidents;
+- Containers;
+- Storage;
+- Services;
+- More.
+
+Mobile priorities:
+- global status;
+- active incident;
+- acknowledgement;
+- essential entity facts;
+- readable charts with reduced series;
+- touch-sized controls.
+
+Dashboard editing on mobile:
+- reorder;
+- show/hide;
+- simple config;
+- no precision freeform grid placement.
+
+## 9. Wallboard
+
+- fullscreen;
+- no standard navigation;
+- large readable status;
+- page rotation;
+- reconnect indicator;
+- last update/freshness;
+- optional kiosk-safe token/session;
+- no edit controls;
+- subtle movement strategy to reduce static burn-in risk;
+- survives days without reload.
+
+## 10. Loading, empty and failure states
+
+Every data surface defines:
+
+- initial loading;
+- incremental live update;
+- empty because no entities exist;
+- empty because filters exclude all;
+- stale;
+- unavailable;
+- unauthorized;
+- query limit exceeded;
+- partial data.
+
+Skeletons must not imply exact values. Previous values may remain visible only with an explicit stale age.
+
+## 11. Copy
+
+Default user-facing copy is natural Dutch:
+
+Good:
+- `Geen recente gegevens`
+- `Container herstart herhaaldelijk`
+- `Cachepool bijna vol`
+- `Laatst succesvol bijgewerkt om 13:42`
+
+Avoid:
+- raw metric names;
+- unexplained `NaN`;
+- only `Error 500`;
+- invented root-cause certainty;
+- alarmist language for attention-level conditions.
+
+## 12. Accessibility
+
+- WCAG 2.2 AA target for core workflows.
+- Full keyboard navigation.
+- Visible focus.
+- Text/icon plus color for status.
+- Reduced-motion support.
+- Charts have accessible summaries/table alternatives.
+- Drag interactions have non-pointer alternatives.
+- Contrast is validated in all themes/states.
+- Live regions announce important state changes without constant noise.
diff --git a/docs/product/WIDGET_CATALOG.md b/docs/product/WIDGET_CATALOG.md
new file mode 100644
index 0000000..97c7c24
--- /dev/null
+++ b/docs/product/WIDGET_CATALOG.md
@@ -0,0 +1,179 @@
+# Widget catalog
+
+Every widget implements a common contract:
+
+- identity and type;
+- data query;
+- transformations;
+- visualization settings;
+- thresholds/status mapping;
+- behavior and links;
+- layout per breakpoint;
+- loading/empty/error/stale states;
+- permission requirements;
+- export capability.
+
+## 1. Stat card
+
+Use for one current value.
+
+Features:
+- label and value;
+- unit/format;
+- state;
+- sparkline;
+- trend against prior period;
+- min/max/average tooltip;
+- freshness.
+
+Examples: CPU, RAM, uptime, active containers, free capacity.
+
+## 2. Time series
+
+Features:
+- one or more bounded series;
+- line/area;
+- hover crosshair;
+- zoom/pan;
+- pause/live;
+- event annotations;
+- min/max/average;
+- legend and series toggles;
+- downsampling;
+- compare period;
+- export.
+
+## 3. Gauge
+
+Use only where a meaningful min/max and thresholds exist.
+
+Examples:
+- capacity;
+- temperature;
+- UPS charge;
+- memory limit.
+
+Avoid gauges for values with no stable range.
+
+## 4. Ranked list
+
+- top/bottom N;
+- current value;
+- mini trend;
+- click-to-filter;
+- deterministic ID/label tie handling;
+- tie handling;
+- "other" aggregation where appropriate.
+
+Examples: top container CPU, disk latency, service response time.
+
+## 5. Status grid
+
+The status grid renders bounded text/icon states and supports keyboard click-to-filter for the selected entity.
+
+Compact tiles for many entities.
+
+Required:
+- status icon/text;
+- entity name;
+- optional key value;
+- age/freshness;
+- grouping and filtering;
+- accessible list alternative.
+
+## 6. Table
+
+- server-side pagination/filtering where large;
+- sort and column selector;
+- pinned identifying columns;
+- export;
+- row drill-down;
+- virtualized rendering;
+- explicit stale fields.
+
+## 7. Heatmap
+
+Examples:
+- CPU by hour/day;
+- disk temperature history;
+- service latency distribution.
+
+Must provide a textual/table summary.
+
+## 8. Event timeline
+
+- chronological events;
+- severity/type filters;
+- entity grouping;
+- chart annotation synchronization;
+- expandable attributes;
+- no secret/raw sensitive payloads.
+
+## 9. Storage map
+
+Visual model of:
+- parity;
+- array disks;
+- pools;
+- members;
+- filesystems;
+- capacity;
+- temperature;
+- SMART state.
+
+It must remain usable without relying on physical slot colors alone.
+
+## 10. Topology
+
+Nodes:
+- applications;
+- containers;
+- services;
+- endpoints;
+- dependencies;
+- reverse proxy;
+- database/storage.
+
+Edges may show status, latency or traffic when reliable. Unknown relationships are visually distinct from discovered/confirmed ones.
+
+## 11. Service matrix
+
+For endpoints/services:
+- status;
+- latency;
+- uptime percentage;
+- last incident;
+- TLS expiry;
+- maintenance.
+
+## 12. Alert/incident summary
+
+- active counts by severity/state;
+- newest/highest priority;
+- acknowledgement state;
+- drill-down;
+- no hidden critical item due to pagination.
+
+## 13. Text/annotation
+
+Markdown subset with sanitization.
+
+Use for:
+- dashboard instructions;
+- maintenance note;
+- runbook link;
+- scope explanation.
+
+No arbitrary script/HTML.
+
+## 14. Query inspector
+
+Advanced and permission-controlled:
+- semantic query;
+- generated PromQL;
+- execution timing;
+- returned series/points;
+- limits applied;
+- copy for troubleshooting.
+
+It is not enabled as an unrestricted public query console.
diff --git a/fixtures/README.md b/fixtures/README.md
new file mode 100644
index 0000000..b2bad43
--- /dev/null
+++ b/fixtures/README.md
@@ -0,0 +1,16 @@
+# Deterministic fixtures and scenarios
+
+`fixtures/scenarios/` defines failure and recovery behavior without touching the real server.
+
+Codex must build a telemetry simulator/fake sources capable of:
+- serving Prometheus-compatible query/metadata responses or a suitable deterministic adapter;
+- exposing Unraid/inventory snapshots;
+- emitting normalized events;
+- changing source freshness;
+- simulating service probe results;
+- controlling time for alert tests;
+- supporting browser end-to-end scenarios.
+
+Every scenario must validate against `specs/simulator-scenario.schema.json`.
+
+Production fault tests must use these fixtures or captured redacted read-only data. Never remove disks, degrade arrays, crash real containers or disrupt the network solely for testing.
diff --git a/fixtures/scenarios/array-degraded.json b/fixtures/scenarios/array-degraded.json
new file mode 100644
index 0000000..d6f4f30
--- /dev/null
+++ b/fixtures/scenarios/array-degraded.json
@@ -0,0 +1,36 @@
+{
+ "schemaVersion": 1,
+ "id": "array-degraded",
+ "name": "Array degraded fixture",
+ "description": "Read-only captured/simulated array membership fault.",
+ "initialState": {
+ "array": {
+ "status": "operational",
+ "missingDisks": 0
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "set-entity-status",
+ "payload": {
+ "entity": "fixture-array",
+ "status": "critical",
+ "facts": {
+ "missingDisks": 1,
+ "emulatedDisk": "disk3"
+ }
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 60,
+ "assertion": "Array and global status become critical with the missing/emulated disk reason."
+ },
+ {
+ "bySeconds": 60,
+ "assertion": "No repair/start/stop/correct action is offered."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/capacity-forecast.json b/fixtures/scenarios/capacity-forecast.json
new file mode 100644
index 0000000..c3c9929
--- /dev/null
+++ b/fixtures/scenarios/capacity-forecast.json
@@ -0,0 +1,22 @@
+{
+ "schemaVersion": 1,
+ "id": "capacity-forecast",
+ "name": "Capacity forecast confidence states",
+ "description": "A bounded usage history yields a qualified projection while bulk imports, irregular intervals and disabled policy remain explicit without false precision.",
+ "initialState": {
+ "forecasts": {
+ "windowSeconds": 2592000,
+ "maxPoints": 128,
+ "method": "linear_median_rate"
+ }
+ },
+ "timeline": [
+ {"atSeconds": 604800, "action": "set-metric", "payload": {"metric": "storage.share.used_bytes", "entity": "fixture-media", "value": 400000000000}},
+ {"atSeconds": 1209600, "action": "set-metric", "payload": {"metric": "storage.share.used_bytes", "entity": "fixture-media", "value": 500000000000}}
+ ],
+ "expectedOutcomes": [
+ {"bySeconds": 1209600, "assertion": "Forecast response always exposes method, historical window, point count and confidence."},
+ {"bySeconds": 1209600, "assertion": "Bulk-import, irregular-history and insufficient-data states do not expose a projected capacity date."},
+ {"bySeconds": 1209600, "assertion": "Disabled forecasting is rendered as an explicit read-only state rather than as a healthy forecast."}
+ ]
+}
diff --git a/fixtures/scenarios/container-restart-loop.json b/fixtures/scenarios/container-restart-loop.json
new file mode 100644
index 0000000..baab295
--- /dev/null
+++ b/fixtures/scenarios/container-restart-loop.json
@@ -0,0 +1,65 @@
+{
+ "schemaVersion": 1,
+ "id": "container-restart-loop",
+ "name": "Container restart loop",
+ "description": "A container repeatedly restarts while returning to running state.",
+ "initialState": {
+ "container": {
+ "id": "fixture-plex",
+ "name": "Plex",
+ "status": "running",
+ "restartCount": 0
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 10,
+ "action": "emit-event",
+ "payload": {
+ "eventType": "container.restart",
+ "entity": "fixture-plex",
+ "exitCode": 137
+ }
+ },
+ {
+ "atSeconds": 120,
+ "action": "emit-event",
+ "payload": {
+ "eventType": "container.restart",
+ "entity": "fixture-plex",
+ "exitCode": 137
+ }
+ },
+ {
+ "atSeconds": 240,
+ "action": "emit-event",
+ "payload": {
+ "eventType": "container.restart",
+ "entity": "fixture-plex",
+ "exitCode": 137
+ }
+ },
+ {
+ "atSeconds": 360,
+ "action": "set-entity-status",
+ "payload": {
+ "entity": "fixture-plex",
+ "status": "operational"
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 300,
+ "assertion": "Container is degraded due to instability even when current runtime state is running."
+ },
+ {
+ "bySeconds": 330,
+ "assertion": "Restart-loop alert is firing and grouped under the application."
+ },
+ {
+ "bySeconds": 1500,
+ "assertion": "Alert resolves only after the configured stable recovery window."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/database-restart.json b/fixtures/scenarios/database-restart.json
new file mode 100644
index 0000000..808a9e2
--- /dev/null
+++ b/fixtures/scenarios/database-restart.json
@@ -0,0 +1,37 @@
+{
+ "schemaVersion": 1,
+ "id": "database-restart",
+ "name": "Database restart recovery",
+ "description": "Database temporarily restarts while API/worker are active.",
+ "initialState": {
+ "database": {
+ "status": "healthy"
+ },
+ "alerts": {
+ "active": 2
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "restart-database",
+ "payload": {
+ "downtimeSeconds": 20
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 40,
+ "assertion": "API readiness fails while liveness remains appropriate."
+ },
+ {
+ "bySeconds": 90,
+ "assertion": "Connections recover without duplicate alert transitions or notifications."
+ },
+ {
+ "bySeconds": 120,
+ "assertion": "Persisted dashboards and alert state are intact."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/disk-temperature.json b/fixtures/scenarios/disk-temperature.json
new file mode 100644
index 0000000..3f054f4
--- /dev/null
+++ b/fixtures/scenarios/disk-temperature.json
@@ -0,0 +1,60 @@
+{
+ "schemaVersion": 1,
+ "id": "disk-temperature",
+ "name": "Sustained disk temperature",
+ "description": "Tests pending, firing, hysteresis and recovery.",
+ "initialState": {
+ "disk": {
+ "id": "fixture-disk4",
+ "temperatureC": 38,
+ "smart": "healthy"
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "set-metric",
+ "payload": {
+ "metric": "storage.disk.temperature",
+ "entity": "fixture-disk4",
+ "value": 52
+ }
+ },
+ {
+ "atSeconds": 420,
+ "action": "set-metric",
+ "payload": {
+ "metric": "storage.disk.temperature",
+ "entity": "fixture-disk4",
+ "value": 48
+ }
+ },
+ {
+ "atSeconds": 600,
+ "action": "set-metric",
+ "payload": {
+ "metric": "storage.disk.temperature",
+ "entity": "fixture-disk4",
+ "value": 44
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 120,
+ "assertion": "Temperature rule is pending, not firing."
+ },
+ {
+ "bySeconds": 360,
+ "assertion": "Temperature alert is firing."
+ },
+ {
+ "bySeconds": 500,
+ "assertion": "Alert remains firing at 48 C because recovery threshold has not been crossed."
+ },
+ {
+ "bySeconds": 930,
+ "assertion": "Alert resolves only after temperature stays below recovery threshold for the configured duration."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/dns-outage-suppression.json b/fixtures/scenarios/dns-outage-suppression.json
new file mode 100644
index 0000000..5c43b7e
--- /dev/null
+++ b/fixtures/scenarios/dns-outage-suppression.json
@@ -0,0 +1,44 @@
+{
+ "schemaVersion": 1,
+ "id": "dns-outage-suppression",
+ "name": "DNS outage with downstream suppression",
+ "description": "Many services fail name resolution due to one DNS dependency.",
+ "initialState": {
+ "dns": {
+ "status": "up"
+ },
+ "services": {
+ "up": 20
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "set-probe-result",
+ "payload": {
+ "probe": "dns-primary",
+ "success": false,
+ "error": "timeout"
+ }
+ },
+ {
+ "atSeconds": 40,
+ "action": "set-probe-result",
+ "payload": {
+ "probeGroup": "dependent-http",
+ "success": false,
+ "error": "dns"
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 180,
+ "assertion": "One primary DNS/network incident is created."
+ },
+ {
+ "bySeconds": 180,
+ "assertion": "Downstream service alerts are visible but suppressed/grouped rather than individually notified."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/healthy-baseline.json b/fixtures/scenarios/healthy-baseline.json
new file mode 100644
index 0000000..27c1063
--- /dev/null
+++ b/fixtures/scenarios/healthy-baseline.json
@@ -0,0 +1,60 @@
+{
+ "schemaVersion": 1,
+ "id": "healthy-baseline",
+ "name": "Healthy baseline",
+ "description": "Stable host, containers, storage and services used as the default deterministic fixture.",
+ "initialState": {
+ "host": {
+ "status": "operational",
+ "cpuPercent": 18,
+ "memoryPercent": 42
+ },
+ "containers": {
+ "running": 70,
+ "stoppedIntentional": 1,
+ "unhealthy": 0
+ },
+ "storage": {
+ "arrayStatus": "operational",
+ "poolUtilizationPercent": 55,
+ "maxDiskTemperatureC": 38
+ },
+ "services": {
+ "up": 25,
+ "down": 0
+ },
+ "sources": {
+ "prometheus": "healthy",
+ "unraid": "healthy",
+ "agent": "healthy"
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 0,
+ "action": "set-metric",
+ "payload": {
+ "metric": "host.cpu.utilization",
+ "value": 18
+ }
+ },
+ {
+ "atSeconds": 0,
+ "action": "set-metric",
+ "payload": {
+ "metric": "host.memory.utilization",
+ "value": 42
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 10,
+ "assertion": "Global status is operational and contains no active problem reason."
+ },
+ {
+ "bySeconds": 10,
+ "assertion": "All required datasource freshness indicators are fresh."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/pool-capacity-pressure.json b/fixtures/scenarios/pool-capacity-pressure.json
new file mode 100644
index 0000000..3d93e96
--- /dev/null
+++ b/fixtures/scenarios/pool-capacity-pressure.json
@@ -0,0 +1,42 @@
+{
+ "schemaVersion": 1,
+ "id": "pool-capacity-pressure",
+ "name": "Pool capacity pressure",
+ "description": "Cache/pool utilization crosses warning and critical thresholds.",
+ "initialState": {
+ "pool": {
+ "id": "fixture-cache",
+ "utilizationPercent": 70
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "set-metric",
+ "payload": {
+ "metric": "storage.pool.utilization",
+ "entity": "fixture-cache",
+ "value": 91
+ }
+ },
+ {
+ "atSeconds": 720,
+ "action": "set-metric",
+ "payload": {
+ "metric": "storage.pool.utilization",
+ "entity": "fixture-cache",
+ "value": 98
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 700,
+ "assertion": "Pool is degraded with a capacity reason after sustained 91 percent."
+ },
+ {
+ "bySeconds": 1100,
+ "assertion": "Pool reaches critical according to policy at sustained 98 percent."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/pool-degraded-scrub.json b/fixtures/scenarios/pool-degraded-scrub.json
new file mode 100644
index 0000000..6296ae3
--- /dev/null
+++ b/fixtures/scenarios/pool-degraded-scrub.json
@@ -0,0 +1,21 @@
+{
+ "schemaVersion": 1,
+ "id": "pool-degraded-scrub",
+ "name": "Btrfs pool degraded scrub",
+ "description": "A Btrfs pool keeps its identity and redundancy metadata while one member is missing and scrub reports errors.",
+ "initialState": {
+ "pool": {
+ "id": "fixture-cache",
+ "filesystem": "btrfs",
+ "state": "degraded",
+ "profile": "raid1",
+ "memberStates": ["online", "missing"],
+ "scrub": {"state": "failed", "errors": 3}
+ }
+ },
+ "timeline": [{"atSeconds": 30, "action": "set-metric", "payload": {"metric": "storage.pool.utilization", "entity": "fixture-cache", "value": 91}}],
+ "expectedOutcomes": [
+ {"bySeconds": 0, "assertion": "Pool remains degraded and the missing member is visible."},
+ {"bySeconds": 0, "assertion": "Scrub errors are visible as a warning/critical reason without exposing a scrub control."}
+ ]
+}
diff --git a/fixtures/scenarios/probe-executor-cases.json b/fixtures/scenarios/probe-executor-cases.json
new file mode 100644
index 0000000..0e48ce7
--- /dev/null
+++ b/fixtures/scenarios/probe-executor-cases.json
@@ -0,0 +1,33 @@
+{
+ "schemaVersion": 1,
+ "id": "probe-executor-cases",
+ "name": "Bounded service probe executor cases",
+ "description": "Synthetic cases for successful, failed, timed out, redirected, TLS and unsupported probe execution without real credentials or remote resources.",
+ "initialState": {
+ "networkPolicy": {
+ "allowedNetworks": ["10.0.0.0/8", "127.0.0.0/8"],
+ "maxResponseBytes": 1048576,
+ "maxRedirects": 5
+ },
+ "cases": [
+ {"id": "http-success-json-keyword", "type": "http", "expectedState": "up"},
+ {"id": "http-status-failure", "type": "http", "expectedState": "down"},
+ {"id": "http-timeout", "type": "http", "expectedState": "unknown", "errorClass": "transport_error"},
+ {"id": "http-redirect-policy", "type": "http", "expectedState": "up"},
+ {"id": "http-response-body-limit", "type": "http", "expectedState": "unknown", "errorClass": "response_too_large"},
+ {"id": "tls-certificate-facts", "type": "tls", "expectedState": "up"},
+ {"id": "dns-resolution", "type": "dns", "expectedState": "up"},
+ {"id": "tcp-connect", "type": "tcp", "expectedState": "up"},
+ {"id": "icmp-capability-fallback", "type": "icmp", "expectedState": "unknown", "errorClass": "unsupported"}
+ ],
+ "credentialExpectation": "secret references are configuration identifiers only; no plaintext credential is sent, stored or included in result errors"
+ },
+ "timeline": [
+ {"atSeconds": 1, "action": "set-probe-result", "payload": {"resolver": "deterministic", "http": "httptest", "tcp": "net-pipe", "tls": "self-signed-net-pipe"}}
+ ],
+ "expectedOutcomes": [
+ {"bySeconds": 2, "assertion": "HTTP success, status failure, bounded response and per-probe timeout produce the documented states without leaking secret references."},
+ {"bySeconds": 2, "assertion": "Redirects are bounded and revalidated; TLS captures certificate expiry, issuer, subject and hostname validity."},
+ {"bySeconds": 2, "assertion": "TCP and DNS succeed with allowlisted synthetic addresses; ICMP returns Unknown/unsupported when capability is unavailable."}
+ ]
+}
\ No newline at end of file
diff --git a/fixtures/scenarios/probe-scale-300.json b/fixtures/scenarios/probe-scale-300.json
new file mode 100644
index 0000000..6317c9f
--- /dev/null
+++ b/fixtures/scenarios/probe-scale-300.json
@@ -0,0 +1,15 @@
+{
+ "schemaVersion": 1,
+ "id": "probe-scale-300",
+ "name": "Three hundred bounded probes",
+ "description": "A synthetic 300-probe set is scheduled with bounded concurrency, duplicate suppression, cancellation and deterministic result ordering.",
+ "initialState": {"probes": {"count": 300, "maxConcurrent": 16, "maxAttempts": 2}},
+ "timeline": [
+ {"atSeconds": 1, "action": "set-probe-result", "payload": {"probeId": "probe-000", "state": "up", "responseTimeMs": 8}},
+ {"atSeconds": 2, "action": "set-probe-result", "payload": {"probeId": "probe-299", "state": "unknown", "errorClass": "timeout"}}
+ ],
+ "expectedOutcomes": [
+ {"bySeconds": 2, "assertion": "All 300 probes are bounded by the worker pool and results are returned in deterministic probe ID order."},
+ {"bySeconds": 2, "assertion": "A duplicate in-flight probe is skipped rather than overlapping, and timeout/retry attempts remain bounded."}
+ ]
+}
diff --git a/fixtures/scenarios/prometheus-stale.json b/fixtures/scenarios/prometheus-stale.json
new file mode 100644
index 0000000..f978205
--- /dev/null
+++ b/fixtures/scenarios/prometheus-stale.json
@@ -0,0 +1,50 @@
+{
+ "schemaVersion": 1,
+ "id": "prometheus-stale",
+ "name": "Prometheus becomes stale",
+ "description": "Tests that missing current metrics become Unknown rather than remaining healthy.",
+ "initialState": {
+ "sources": {
+ "prometheus": "healthy"
+ },
+ "host": {
+ "status": "operational",
+ "cpuPercent": 20
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 0,
+ "action": "set-metric",
+ "payload": {
+ "metric": "host.cpu.utilization",
+ "value": 20
+ }
+ },
+ {
+ "atSeconds": 30,
+ "action": "disconnect",
+ "payload": {
+ "source": "prometheus"
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 90,
+ "assertion": "Prometheus datasource is delayed or stale according to policy."
+ },
+ {
+ "bySeconds": 180,
+ "assertion": "Required metric-derived host state is unknown, not operational."
+ },
+ {
+ "bySeconds": 180,
+ "assertion": "A datasource alert exists without resolving unrelated existing firing alerts."
+ },
+ {
+ "bySeconds": 190,
+ "assertion": "Previous values, if shown, include their age."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/service-down-container-running.json b/fixtures/scenarios/service-down-container-running.json
new file mode 100644
index 0000000..803b5a0
--- /dev/null
+++ b/fixtures/scenarios/service-down-container-running.json
@@ -0,0 +1,53 @@
+{
+ "schemaVersion": 1,
+ "id": "service-down-container-running",
+ "name": "Service down while container runs",
+ "description": "Proves application health is independent from Docker running state.",
+ "initialState": {
+ "container": {
+ "id": "fixture-app",
+ "status": "running"
+ },
+ "service": {
+ "id": "fixture-http",
+ "status": "up",
+ "latencyMs": 80
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "set-probe-result",
+ "payload": {
+ "probe": "fixture-http",
+ "success": false,
+ "statusCode": 503,
+ "latencyMs": 150
+ }
+ },
+ {
+ "atSeconds": 240,
+ "action": "set-probe-result",
+ "payload": {
+ "probe": "fixture-http",
+ "success": true,
+ "statusCode": 200,
+ "latencyMs": 90
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 150,
+ "assertion": "Service and parent application are degraded while the container remains running."
+ },
+ {
+ "bySeconds": 150,
+ "assertion": "Service alert fires after pending duration."
+ },
+ {
+ "bySeconds": 330,
+ "assertion": "Alert and incident resolve after successful recovery duration."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/share-growth.json b/fixtures/scenarios/share-growth.json
new file mode 100644
index 0000000..1b72cf0
--- /dev/null
+++ b/fixtures/scenarios/share-growth.json
@@ -0,0 +1,21 @@
+{
+ "schemaVersion": 1,
+ "id": "share-growth",
+ "name": "Share growth and cache relation",
+ "description": "A bounded share-size history grows across array and cache placement without enumerating file content.",
+ "initialState": {
+ "share": {
+ "id": "fixture-media",
+ "usedBytes": 500000000000,
+ "cachePool": "cache",
+ "primaryPool": "array"
+ }
+ },
+ "timeline": [
+ {"atSeconds": 86400, "action": "set-metric", "payload": {"metric": "storage.share.used_bytes", "entity": "fixture-media", "value": 700000000000}}
+ ],
+ "expectedOutcomes": [
+ {"bySeconds": 86400, "assertion": "Growth history reports a positive 200000000000 byte change per day."},
+ {"bySeconds": 86400, "assertion": "Cache/pool relation remains visible and no file names or content are returned."}
+ ]
+}
diff --git a/fixtures/scenarios/smart-warning.json b/fixtures/scenarios/smart-warning.json
new file mode 100644
index 0000000..f3e0b01
--- /dev/null
+++ b/fixtures/scenarios/smart-warning.json
@@ -0,0 +1,37 @@
+{
+ "schemaVersion": 1,
+ "id": "smart-warning",
+ "name": "SMART warning",
+ "description": "A disk reports pending sectors while overall generic status may still be ambiguous.",
+ "initialState": {
+ "disk": {
+ "id": "fixture-disk5",
+ "smartOverall": "passed",
+ "pendingSectors": 0
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "set-entity-status",
+ "payload": {
+ "entity": "fixture-disk5",
+ "status": "attention",
+ "facts": {
+ "pendingSectors": 2,
+ "smartOverall": "passed"
+ }
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 90,
+ "assertion": "Disk status is attention or degraded with an explicit pending-sector reason."
+ },
+ {
+ "bySeconds": 90,
+ "assertion": "UI does not reduce the state to generic SMART passed."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/storage-map-heatmap.json b/fixtures/scenarios/storage-map-heatmap.json
new file mode 100644
index 0000000..3bdf8c4
--- /dev/null
+++ b/fixtures/scenarios/storage-map-heatmap.json
@@ -0,0 +1,12 @@
+{
+ "schemaVersion": 1,
+ "id": "storage-map-heatmap",
+ "name": "Storage map and temperature heatmap",
+ "description": "A 40-disk storage view remains readable with explicit text states and a table alternative.",
+ "initialState": {"disks": {"count": 40, "temperaturePoints": 40}},
+ "timeline": [{"atSeconds": 60, "action": "set-metric", "payload": {"metric": "storage.disk.temperature", "entity": "disk-7", "value": 52}}],
+ "expectedOutcomes": [
+ {"bySeconds": 60, "assertion": "All 40 disk temperature points remain bounded and the elevated disk has a textual attention state."},
+ {"bySeconds": 60, "assertion": "Storage map and heatmap retain table/text alternatives and drill-down links."}
+ ]
+}
diff --git a/fixtures/scenarios/ups-on-battery.json b/fixtures/scenarios/ups-on-battery.json
new file mode 100644
index 0000000..49446bb
--- /dev/null
+++ b/fixtures/scenarios/ups-on-battery.json
@@ -0,0 +1,52 @@
+{
+ "schemaVersion": 1,
+ "id": "ups-on-battery",
+ "name": "UPS on battery",
+ "description": "Optional UPS source changes power state.",
+ "initialState": {
+ "ups": {
+ "available": true,
+ "status": "online",
+ "chargePercent": 100,
+ "runtimeSeconds": 3600
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "set-entity-status",
+ "payload": {
+ "entity": "fixture-ups",
+ "status": "attention",
+ "facts": {
+ "powerState": "on-battery",
+ "chargePercent": 96,
+ "runtimeSeconds": 3300
+ }
+ }
+ },
+ {
+ "atSeconds": 300,
+ "action": "set-entity-status",
+ "payload": {
+ "entity": "fixture-ups",
+ "status": "operational",
+ "facts": {
+ "powerState": "online",
+ "chargePercent": 95,
+ "runtimeSeconds": 3500
+ }
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 60,
+ "assertion": "UPS is attention with on-battery reason and runtime shown."
+ },
+ {
+ "bySeconds": 360,
+ "assertion": "Recovery is recorded as an event."
+ }
+ ]
+}
diff --git a/fixtures/scenarios/websocket-slow-client.json b/fixtures/scenarios/websocket-slow-client.json
new file mode 100644
index 0000000..5199863
--- /dev/null
+++ b/fixtures/scenarios/websocket-slow-client.json
@@ -0,0 +1,43 @@
+{
+ "schemaVersion": 1,
+ "id": "websocket-slow-client",
+ "name": "Slow live client",
+ "description": "A client cannot consume every live sample.",
+ "initialState": {
+ "live": {
+ "subscriptions": 50,
+ "intervalSeconds": 1
+ }
+ },
+ "timeline": [
+ {
+ "atSeconds": 30,
+ "action": "slow-client",
+ "payload": {
+ "client": "fixture-wallboard",
+ "consumeDelayMs": 5000
+ }
+ },
+ {
+ "atSeconds": 120,
+ "action": "reconnect",
+ "payload": {
+ "client": "fixture-wallboard"
+ }
+ }
+ ],
+ "expectedOutcomes": [
+ {
+ "bySeconds": 60,
+ "assertion": "Server coalesces/drops intermediate non-critical samples without unbounded queue growth."
+ },
+ {
+ "bySeconds": 140,
+ "assertion": "Client resynchronizes using sequence/status protocol."
+ },
+ {
+ "bySeconds": 180,
+ "assertion": "Subscription count returns to expected value with no duplicate streams."
+ }
+ ]
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..424ef86
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,19 @@
+module github.com/itworx/pulse
+
+go 1.26.6
+
+require (
+ github.com/coder/websocket v1.8.15
+ github.com/coreos/go-oidc/v3 v3.20.0
+ github.com/jackc/pgx/v5 v5.10.0
+ golang.org/x/oauth2 v0.36.0
+)
+
+require (
+ github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/sync v0.21.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..cca30f4
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,34 @@
+github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
+github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
+github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
+github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..72eaf6f
--- /dev/null
+++ b/go.work
@@ -0,0 +1,3 @@
+go 1.26.6
+
+use .
diff --git a/go.work.sum b/go.work.sum
new file mode 100644
index 0000000..db21805
--- /dev/null
+++ b/go.work.sum
@@ -0,0 +1,7 @@
+cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/internal/agentprotocol/protocol.go b/internal/agentprotocol/protocol.go
new file mode 100644
index 0000000..6f75bc4
--- /dev/null
+++ b/internal/agentprotocol/protocol.go
@@ -0,0 +1,42 @@
+package agentprotocol
+
+import (
+ "errors"
+ "strings"
+ "time"
+)
+
+const Version = "v1"
+
+type Capability struct {
+ ID string `json:"id"`
+ Version string `json:"version"`
+ ReadOnly bool `json:"readOnly"`
+}
+type Hello struct {
+ Protocol string `json:"protocol"`
+ AgentID string `json:"agentId"`
+ ObservedAt time.Time `json:"observedAt"`
+ Capabilities []Capability `json:"capabilities"`
+}
+
+func (h Hello) Validate(now time.Time) error {
+ if h.Protocol != Version {
+ return errors.New("unsupported agent protocol")
+ }
+ if strings.TrimSpace(h.AgentID) == "" || len(h.AgentID) > 120 {
+ return errors.New("invalid agent id")
+ }
+ if h.ObservedAt.IsZero() || h.ObservedAt.After(now.Add(time.Minute)) {
+ return errors.New("invalid agent observation time")
+ }
+ if len(h.Capabilities) > 50 {
+ return errors.New("too many agent capabilities")
+ }
+ for _, capability := range h.Capabilities {
+ if strings.TrimSpace(capability.ID) == "" || capability.Version == "" || !capability.ReadOnly {
+ return errors.New("agent capability must be explicit and read-only")
+ }
+ }
+ return nil
+}
diff --git a/internal/agentprotocol/protocol_test.go b/internal/agentprotocol/protocol_test.go
new file mode 100644
index 0000000..eebc375
--- /dev/null
+++ b/internal/agentprotocol/protocol_test.go
@@ -0,0 +1,14 @@
+package agentprotocol
+
+import (
+ "testing"
+ "time"
+)
+
+func TestHelloRejectsMutationCapability(t *testing.T) {
+ now := time.Now().UTC()
+ hello := Hello{Protocol: Version, AgentID: "agent-1", ObservedAt: now, Capabilities: []Capability{{ID: "containers.read", Version: Version, ReadOnly: false}}}
+ if err := hello.Validate(now); err == nil {
+ t.Fatal("expected mutation capability rejection")
+ }
+}
diff --git a/internal/agentsource/agentsource.go b/internal/agentsource/agentsource.go
new file mode 100644
index 0000000..4003000
--- /dev/null
+++ b/internal/agentsource/agentsource.go
@@ -0,0 +1,289 @@
+// Package agentsource turns the bounded snapshots pulse-agent writes through
+// internal/agentstore into the normalized domain snapshots pulse-api serves.
+//
+// Every provider in this package follows the same three steps: read the newest snapshot
+// for one capability, decide whether it is usable, and normalize it through the domain's
+// own Adapter. Only the middle step is interesting, and it is the ADR-0008 enforcement
+// point for the whole read path: telemetry that is missing, stale, or undecodable
+// resolves to that domain's Unknown snapshot and never to Healthy.
+//
+// The freshness decision itself is delegated to internal/freshness so this package does
+// not become another private copy of the rule. freshness.Evaluate is called with the
+// snapshot presented as a required, currently healthy datasource.SourceHealth; if it
+// answers with anything other than Healthy the snapshot is discarded as stale. That keeps
+// one tested implementation of "missing telemetry never becomes Healthy" in the codebase.
+//
+// # Reason codes
+//
+// A provider that cannot serve real telemetry reports exactly one machine-readable reason
+// from this closed set, which the web app maps onto localized copy:
+//
+// source_unavailable — nothing has been recorded for the capability yet, or the store
+// could not be read at all.
+// source_stale — the newest snapshot is older than the capability's freshness
+// window, or its timestamps are not usable.
+// source_invalid — the payload could not be decoded into the domain's RawSnapshot,
+// or the domain rejected it during normalization.
+//
+// # Error handling
+//
+// Context cancellation and deadlines propagate to the caller. Any other store failure is
+// reported as an Unknown snapshot with source_unavailable rather than an error: a
+// monitoring surface that says explicitly "this source is unavailable" is more useful to
+// an operator than a bare 503, and it keeps a single database hiccup from taking every
+// monitoring page down at once.
+package agentsource
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/datasource"
+ "github.com/itworx/pulse/internal/freshness"
+)
+
+// Reason codes reported when a provider cannot serve real telemetry. The set is closed;
+// see the package documentation for the meaning of each code.
+const (
+ ReasonUnavailable = "source_unavailable"
+ ReasonStale = "source_stale"
+ ReasonInvalid = "source_invalid"
+)
+
+// Default freshness windows per capability. They mirror the freshness policy defaults of
+// the domain packages, so a snapshot that passes this gate is never marked stale again
+// further down the pipeline, and they follow the agent's sampling cadence: the host is
+// sampled every few seconds, processes and containers roughly every half minute, and
+// share usage is an expensive scan that runs far less often.
+const (
+ DefaultHostWindow = 30 * time.Second
+ DefaultProcessesWindow = 60 * time.Second
+ DefaultContainersWindow = 60 * time.Second
+ DefaultArrayWindow = 60 * time.Second
+ DefaultDisksWindow = 60 * time.Second
+ DefaultPoolsWindow = 60 * time.Second
+ DefaultSharesWindow = 2 * time.Minute
+)
+
+// Windows configures how old a snapshot may be before its capability resolves to Unknown.
+// A zero field takes the documented default for that capability.
+type Windows struct {
+ Host time.Duration
+ Processes time.Duration
+ Containers time.Duration
+ Array time.Duration
+ Disks time.Duration
+ Pools time.Duration
+ Shares time.Duration
+}
+
+// WithDefaults fills every unset window with its documented default.
+func (w Windows) WithDefaults() Windows {
+ if w.Host == 0 {
+ w.Host = DefaultHostWindow
+ }
+ if w.Processes == 0 {
+ w.Processes = DefaultProcessesWindow
+ }
+ if w.Containers == 0 {
+ w.Containers = DefaultContainersWindow
+ }
+ if w.Array == 0 {
+ w.Array = DefaultArrayWindow
+ }
+ if w.Disks == 0 {
+ w.Disks = DefaultDisksWindow
+ }
+ if w.Pools == 0 {
+ w.Pools = DefaultPoolsWindow
+ }
+ if w.Shares == 0 {
+ w.Shares = DefaultSharesWindow
+ }
+ return w
+}
+
+// Validate reports whether every configured window is inside safe bounds. Windows are
+// bounded above at 24 hours because datasource.FreshnessPolicy refuses anything longer:
+// a source nobody has heard from for a day is not fresh under any reading.
+func (w Windows) Validate() error {
+ for capability, window := range w.WithDefaults().byCapability() {
+ if window <= 0 || window > 24*time.Hour {
+ return fmt.Errorf("freshness window for %q is outside safe bounds", capability)
+ }
+ }
+ return nil
+}
+
+// For returns the freshness window for one capability, applying defaults.
+func (w Windows) For(capability agentstore.Capability) time.Duration {
+ if window, ok := w.WithDefaults().byCapability()[capability]; ok {
+ return window
+ }
+ // An unrecognized capability cannot be served at all; the tightest window keeps a
+ // caller that ignores that from treating anything as fresh.
+ return DefaultHostWindow
+}
+
+func (w Windows) byCapability() map[agentstore.Capability]time.Duration {
+ return map[agentstore.Capability]time.Duration{
+ agentstore.CapabilityHost: w.Host,
+ agentstore.CapabilityProcesses: w.Processes,
+ agentstore.CapabilityContainers: w.Containers,
+ agentstore.CapabilityArray: w.Array,
+ agentstore.CapabilityDisks: w.Disks,
+ agentstore.CapabilityPools: w.Pools,
+ agentstore.CapabilityShares: w.Shares,
+ }
+}
+
+// staticRaw presents an already decoded RawSnapshot as the raw source interface each
+// domain Adapter expects, so normalization keeps running through the domain's own code.
+type staticRaw[R any] struct{ raw R }
+
+func (s staticRaw[R]) Snapshot(context.Context) (R, error) { return s.raw, nil }
+
+// resolve implements the shared read path. It is generic over the domain's RawSnapshot
+// and normalized Snapshot so every capability enforces the same rules in the same order.
+func resolve[R any, S any](
+ ctx context.Context,
+ reader agentstore.Reader,
+ capability agentstore.Capability,
+ window time.Duration,
+ now time.Time,
+ unknown func(time.Time, string) S,
+ normalize func(R, time.Time) (S, error),
+) (S, error) {
+ var zero S
+ if err := ctx.Err(); err != nil {
+ return zero, err
+ }
+ if reader == nil {
+ return unknown(now, ReasonUnavailable), nil
+ }
+ stored, err := reader.Latest(ctx, capability)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return zero, err
+ }
+ return unknown(now, ReasonUnavailable), nil
+ }
+ if reason := usableReason(string(capability), stored, window, now); reason != "" {
+ return unknown(now, reason), nil
+ }
+ var raw R
+ if err := json.Unmarshal(stored.Payload, &raw); err != nil {
+ return unknown(now, ReasonInvalid), nil
+ }
+ snapshot, err := normalize(raw, now)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return zero, err
+ }
+ return unknown(now, ReasonInvalid), nil
+ }
+ return snapshot, nil
+}
+
+// usableReason returns an empty string when the snapshot may be normalized, or the reason
+// code that must be reported instead. The freshness judgement is made by
+// internal/freshness rather than re-derived here.
+func usableReason(sourceID string, stored agentstore.Snapshot, window time.Duration, now time.Time) string {
+ result, err := freshness.Evaluate(freshness.Input{
+ SourceID: sourceID,
+ Required: true,
+ Now: now,
+ Health: datasource.SourceHealth{
+ State: datasource.HealthHealthy,
+ ObservedAt: stored.ObservedAt,
+ ReceivedAt: stored.ReceivedAt,
+ LastSuccess: stored.ObservedAt,
+ Policy: datasource.FreshnessPolicy{MaxAge: window},
+ },
+ })
+ if err != nil {
+ // Unusable timestamps (missing, or materially in the future) are indistinguishable
+ // from staleness for a consumer: in both cases the observation cannot be trusted
+ // to describe the present.
+ return ReasonStale
+ }
+ if result.State != datasource.HealthHealthy {
+ return ReasonStale
+ }
+ return ""
+}
+
+// clockNow resolves the injected clock, defaulting to the wall clock in UTC.
+func clockNow(clock func() time.Time) time.Time {
+ if clock == nil {
+ return time.Now().UTC()
+ }
+ return clock().UTC()
+}
+
+func fixedClock(now time.Time) func() time.Time { return func() time.Time { return now } }
+
+// Health summarizes a bounded set of agent capabilities into one datasource health
+// observation. Every requested capability is required: a source is only Healthy when
+// each capability has a recent snapshot. This is used by self-observability so it reads
+// the same persisted transport as the domain providers instead of inferring agent
+// configuration from API process environment variables.
+func Health(ctx context.Context, reader agentstore.Reader, capabilities []agentstore.Capability, windows Windows, now time.Time) (datasource.SourceHealth, error) {
+ now = now.UTC()
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ health := datasource.SourceHealth{
+ State: datasource.HealthUnknown,
+ ReceivedAt: now,
+ Policy: datasource.FreshnessPolicy{MaxAge: DefaultHostWindow},
+ ReasonCode: ReasonUnavailable,
+ }
+ if err := ctx.Err(); err != nil {
+ return datasource.SourceHealth{}, err
+ }
+ if reader == nil || len(capabilities) == 0 {
+ return health, nil
+ }
+ for _, capability := range capabilities {
+ if window := windows.For(capability); window > health.Policy.MaxAge {
+ health.Policy.MaxAge = window
+ }
+ }
+ oldestObserved := now
+ oldestReceived := now
+ for _, capability := range capabilities {
+ if !capability.Valid() {
+ return datasource.SourceHealth{}, fmt.Errorf("summarize unknown agent capability %q", capability)
+ }
+ stored, err := reader.Latest(ctx, capability)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return datasource.SourceHealth{}, err
+ }
+ return health, nil
+ }
+ if reason := usableReason(string(capability), stored, windows.For(capability), now); reason != "" {
+ health.ObservedAt = stored.ObservedAt.UTC()
+ health.ReceivedAt = stored.ReceivedAt.UTC()
+ health.ReasonCode = reason
+ return health, nil
+ }
+ if stored.ObservedAt.Before(oldestObserved) {
+ oldestObserved = stored.ObservedAt.UTC()
+ }
+ if stored.ReceivedAt.Before(oldestReceived) {
+ oldestReceived = stored.ReceivedAt.UTC()
+ }
+ }
+ health.State = datasource.HealthHealthy
+ health.ObservedAt = oldestObserved
+ health.ReceivedAt = oldestReceived
+ health.LastSuccess = oldestObserved
+ health.ReasonCode = "source_sampled"
+ return health, nil
+}
diff --git a/internal/agentsource/application.go b/internal/agentsource/application.go
new file mode 100644
index 0000000..65276bd
--- /dev/null
+++ b/internal/agentsource/application.go
@@ -0,0 +1,212 @@
+package agentsource
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/application"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/service"
+)
+
+// ApplicationProvider derives application health from two existing surfaces rather than
+// from a capability of its own: the container inventory the agent records, and the
+// service probe results the API already stores. Containers are grouped into applications
+// by their Compose project (falling back to the container name for a standalone
+// container), and a probe result for a service with the same name refines the component
+// status of the matching container.
+//
+// Fresh container evidence is required. Service probes refine matching
+// components when configured; their absence does not erase container-runtime
+// availability, because those are separate claims and surfaces.
+type ApplicationProvider struct {
+ Containers container.Provider
+ Services service.Provider
+ // MaxApplications bounds the number of groups built before the payload is refused.
+ // Zero takes DefaultMaxApplications.
+ MaxApplications int
+ Now func() time.Time
+}
+
+var _ application.Provider = ApplicationProvider{}
+
+// DefaultMaxApplications matches the bound application.BuildSnapshot enforces, so an
+// oversized inventory is reported as Unknown instead of failing the request.
+const DefaultMaxApplications = 150
+
+func (p ApplicationProvider) Snapshot(ctx context.Context) (application.Snapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return application.Snapshot{}, err
+ }
+ now := clockNow(p.Now)
+ unknown := func(reason string) application.Snapshot {
+ return application.UnknownSnapshot(now, applicationSources, agentSourceType, reason)
+ }
+ if p.Containers == nil {
+ return unknown(ReasonUnavailable), nil
+ }
+ containers, err := p.Containers.Snapshot(ctx)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return application.Snapshot{}, err
+ }
+ return unknown(ReasonUnavailable), nil
+ }
+ if reason := unusableContainerReason(containers); reason != "" {
+ return unknown(reason), nil
+ }
+ // Service probes refine application health when configured, but are not an
+ // identity prerequisite. Fresh container state is complete evidence for
+ // runtime availability; an empty/disabled probe module must not erase every
+ // discovered application from the product.
+ services := service.Snapshot{ContractVersion: service.ContractVersion, ObservedAt: containers.Source.ObservedAt}
+ if p.Services != nil {
+ observed, serviceErr := p.Services.Snapshot(ctx)
+ if serviceErr != nil {
+ if errors.Is(serviceErr, context.Canceled) || errors.Is(serviceErr, context.DeadlineExceeded) {
+ return application.Snapshot{}, serviceErr
+ }
+ } else if strings.TrimSpace(observed.Reason) == "" {
+ services = observed
+ }
+ }
+ maximum := p.MaxApplications
+ if maximum <= 0 {
+ maximum = DefaultMaxApplications
+ }
+ discovered := groupApplications(containers, services, maximum)
+ if discovered == nil {
+ return unknown(ReasonInvalid), nil
+ }
+ source := application.Source{
+ ID: applicationSources,
+ Type: agentSourceType,
+ ObservedAt: containers.Source.ObservedAt,
+ ReceivedAt: containers.Source.ReceivedAt,
+ }
+ snapshot, err := application.BuildSnapshot(source, discovered, nil, now)
+ if err != nil {
+ return unknown(ReasonInvalid), nil
+ }
+ return snapshot, nil
+}
+
+// unusableContainerReason maps the container source state onto an application reason code.
+// The container provider has already applied the freshness rule, so an Unknown container
+// source here means the same thing for applications.
+func unusableContainerReason(snapshot container.Snapshot) string {
+ if snapshot.Source.State == "" || snapshot.Source.State == "unknown" {
+ switch snapshot.Source.Reason {
+ case ReasonStale, "stale_source":
+ return ReasonStale
+ case ReasonInvalid:
+ return ReasonInvalid
+ default:
+ return ReasonUnavailable
+ }
+ }
+ return ""
+}
+
+// groupApplications builds one discovered application per Compose project. It returns nil
+// when the inventory exceeds the configured bound.
+func groupApplications(containers container.Snapshot, services service.Snapshot, maximum int) []application.DiscoveredApplication {
+ states := make(map[string]application.State, len(services.Services))
+ for _, item := range services.Services {
+ states[normalizeKey(item.Name)] = serviceState(item.State)
+ }
+ // application.evaluateComponent normalizes an empty service state to Unknown, so a
+ // component with no probe would drag its application to Unknown even though the
+ // container telemetry is fresh. There is no probe constraint on such a component, and
+ // Healthy is the neutral element of the domain's aggregation: it leaves the component
+ // status equal to the container status instead of inventing an unknown.
+ stateFor := func(name string) application.State {
+ if state, ok := states[normalizeKey(name)]; ok {
+ return state
+ }
+ return application.StateHealthy
+ }
+ order := make([]string, 0, len(containers.Containers))
+ groups := make(map[string]*application.DiscoveredApplication, len(containers.Containers))
+ for _, item := range containers.Containers {
+ key := strings.TrimSpace(item.Project)
+ if key == "" {
+ key = strings.TrimPrefix(strings.TrimSpace(item.Name), "/")
+ }
+ if key == "" {
+ continue
+ }
+ group, seen := groups[key]
+ if !seen {
+ if len(order) >= maximum {
+ return nil
+ }
+ group = &application.DiscoveredApplication{ID: application.StableApplicationID(application.SourceID, key), Name: key}
+ groups[key] = group
+ order = append(order, key)
+ }
+ group.Components = append(group.Components, application.ComponentInput{
+ ID: item.ID,
+ Name: strings.TrimPrefix(item.Name, "/"),
+ Kind: "container",
+ ContainerState: containerState(item),
+ ServiceState: stateFor(item.Name),
+ // Every discovered component is critical until an operator overrides it;
+ // treating an unmapped component as optional would hide real failures.
+ Critical: true,
+ })
+ }
+ discovered := make([]application.DiscoveredApplication, 0, len(order))
+ for _, key := range order {
+ discovered = append(discovered, *groups[key])
+ }
+ return discovered
+}
+
+// containerState maps a normalized container onto the application state vocabulary. A
+// container the operator stopped on purpose is not a failure; anything the collector
+// could not classify is Unknown rather than Healthy.
+func containerState(item container.Container) application.State {
+ switch strings.ToLower(strings.TrimSpace(item.State)) {
+ case "running":
+ switch strings.ToLower(strings.TrimSpace(item.Health)) {
+ case "healthy":
+ return application.StateHealthy
+ case "unhealthy":
+ return application.StateDegraded
+ case "starting", "unknown", "":
+ return application.StateUnknown
+ default:
+ return application.StateUnknown
+ }
+ case "restarting", "paused", "removing":
+ return application.StateDegraded
+ case "exited", "dead", "stopped":
+ if item.IntentionalStop {
+ return application.StateUnknown
+ }
+ return application.StateDown
+ default:
+ return application.StateUnknown
+ }
+}
+
+// serviceState maps a probe verdict onto the application state vocabulary.
+func serviceState(state string) application.State {
+ switch strings.ToLower(strings.TrimSpace(state)) {
+ case service.StateUp:
+ return application.StateHealthy
+ case service.StateDegraded:
+ return application.StateDegraded
+ case service.StateDown:
+ return application.StateDown
+ default:
+ return application.StateUnknown
+ }
+}
+
+func normalizeKey(value string) string {
+ return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "/"))
+}
diff --git a/internal/agentsource/application_test.go b/internal/agentsource/application_test.go
new file mode 100644
index 0000000..cb93681
--- /dev/null
+++ b/internal/agentsource/application_test.go
@@ -0,0 +1,210 @@
+package agentsource
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/application"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/service"
+)
+
+type stubContainers struct {
+ snapshot container.Snapshot
+ err error
+}
+
+func (s stubContainers) Snapshot(context.Context) (container.Snapshot, error) {
+ return s.snapshot, s.err
+}
+
+type stubServices struct {
+ snapshot service.Snapshot
+ err error
+}
+
+func (s stubServices) Snapshot(context.Context) (service.Snapshot, error) { return s.snapshot, s.err }
+
+func containerSnapshot(t *testing.T, now time.Time, containers ...container.RawContainer) container.Snapshot {
+ t.Helper()
+ snapshot, err := container.Normalize(container.RawSnapshot{
+ Source: container.Source{ID: "container", Type: "agent"},
+ Containers: containers,
+ ObservedAt: now, ReceivedAt: now,
+ }, now, container.Limits{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return snapshot
+}
+
+func TestApplicationProviderRequiresFreshContainers(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ services := service.Snapshot{ContractVersion: service.ContractVersion, ObservedAt: now, Services: []service.ServiceStatus{}}
+
+ cases := []struct {
+ name string
+ containers container.Provider
+ services service.Provider
+ reason string
+ }{
+ {"no container provider", nil, stubServices{snapshot: services}, ReasonUnavailable},
+ {"container read fails", stubContainers{err: errors.New("boom")}, stubServices{snapshot: services}, ReasonUnavailable},
+ {"containers unavailable", stubContainers{snapshot: container.UnknownSnapshot(now, "container", "agent", ReasonUnavailable)}, stubServices{snapshot: services}, ReasonUnavailable},
+ {"containers invalid", stubContainers{snapshot: container.UnknownSnapshot(now, "container", "agent", ReasonInvalid)}, stubServices{snapshot: services}, ReasonInvalid},
+ {"containers stale", stubContainers{snapshot: container.UnknownSnapshot(now, "container", "agent", ReasonStale)}, stubServices{snapshot: services}, ReasonStale},
+ }
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ provider := ApplicationProvider{Containers: testCase.containers, Services: testCase.services, Now: fixedClock(now)}
+ snapshot, err := provider.Snapshot(context.Background())
+ if err != nil {
+ t.Fatalf("unusable input must not fail the request: %v", err)
+ }
+ if snapshot.Source.State != "unknown" || snapshot.Source.Reason != testCase.reason {
+ t.Fatalf("source = %+v, want unknown/%s", snapshot.Source, testCase.reason)
+ }
+ for _, item := range snapshot.Applications {
+ if item.Status == application.StateHealthy {
+ t.Fatal("an application became healthy without complete evidence, violating ADR-0008")
+ }
+ }
+ })
+ }
+}
+
+func TestApplicationProviderUsesContainersWhenProbesAreDisabled(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ containers := containerSnapshot(t, now, container.RawContainer{ID: "pulse-1", Name: "pulse-api", State: "running", Health: "healthy"})
+ for name, services := range map[string]service.Provider{
+ "not configured": nil,
+ "unavailable": stubServices{snapshot: service.UnknownSnapshot(now, "source_unavailable")},
+ "read failure": stubServices{err: errors.New("probe store unavailable")},
+ } {
+ t.Run(name, func(t *testing.T) {
+ snapshot, err := (ApplicationProvider{Containers: stubContainers{snapshot: containers}, Services: services, Now: fixedClock(now)}).Snapshot(context.Background())
+ if err != nil || snapshot.Total != 1 || snapshot.Applications[0].Status != application.StateHealthy {
+ t.Fatalf("snapshot=%+v err=%v", snapshot, err)
+ }
+ })
+ }
+}
+
+func TestApplicationProviderGroupsContainersAndAppliesProbes(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ containers := containerSnapshot(t, now,
+ container.RawContainer{ID: "web-1", Name: "media-web", State: "running", Health: "healthy", Project: "media"},
+ container.RawContainer{ID: "db-1", Name: "media-db", State: "running", Health: "healthy", Project: "media"},
+ container.RawContainer{ID: "solo-1", Name: "standalone", State: "running", Health: "healthy"},
+ container.RawContainer{ID: "off-1", Name: "archived", State: "exited", IntentionalStop: true},
+ container.RawContainer{ID: "bad-1", Name: "broken", State: "exited"},
+ )
+ services := service.Snapshot{ContractVersion: service.ContractVersion, ObservedAt: now, Services: []service.ServiceStatus{
+ {ID: "svc-web", Name: "media-web", State: service.StateUp},
+ {ID: "svc-db", Name: "media-db", State: service.StateDown},
+ }}
+ provider := ApplicationProvider{Containers: stubContainers{snapshot: containers}, Services: stubServices{snapshot: services}, Now: fixedClock(now)}
+ snapshot, err := provider.Snapshot(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Source.State != "healthy" || snapshot.Source.Reason != "" {
+ t.Fatalf("unexpected source %+v", snapshot.Source)
+ }
+ byName := make(map[string]application.Application, len(snapshot.Applications))
+ for _, item := range snapshot.Applications {
+ byName[item.Name] = item
+ }
+ if len(byName) != 4 {
+ t.Fatalf("expected one application per compose project or standalone container, got %d: %+v", len(byName), snapshot.Applications)
+ }
+ media, ok := byName["media"]
+ if !ok || len(media.Components) != 2 {
+ t.Fatalf("media project was not grouped: %+v", byName)
+ }
+ if media.Status != application.StateDegraded {
+ t.Fatalf("a down probe on a critical component must degrade the application, got %s", media.Status)
+ }
+ if standalone := byName["standalone"]; standalone.Status != application.StateHealthy {
+ t.Fatalf("a running container without a probe must stay healthy, got %s", standalone.Status)
+ }
+ if archived := byName["archived"]; archived.Status != application.StateUnknown {
+ t.Fatalf("an intentionally stopped critical component must remain explicit unknown, got %s", archived.Status)
+ }
+ if broken := byName["broken"]; broken.Status != application.StateDegraded {
+ t.Fatalf("an unexpectedly stopped container must not read as healthy, got %s", broken.Status)
+ }
+}
+
+func TestApplicationProviderBoundsTheInventory(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ raw := make([]container.RawContainer, 0, 4)
+ for _, name := range []string{"a", "b", "c", "d"} {
+ raw = append(raw, container.RawContainer{ID: name, Name: name, State: "running", Health: "healthy"})
+ }
+ provider := ApplicationProvider{
+ Containers: stubContainers{snapshot: containerSnapshot(t, now, raw...)},
+ Services: stubServices{snapshot: service.Snapshot{ContractVersion: service.ContractVersion, ObservedAt: now}},
+ MaxApplications: 2,
+ Now: fixedClock(now),
+ }
+ snapshot, err := provider.Snapshot(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Source.State != "unknown" || snapshot.Source.Reason != ReasonInvalid {
+ t.Fatalf("an oversized inventory must resolve to unknown, got %+v", snapshot.Source)
+ }
+}
+
+func TestApplicationProviderPropagatesCancellation(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ provider := ApplicationProvider{
+ Containers: stubContainers{err: context.Canceled},
+ Services: stubServices{},
+ Now: fixedClock(now),
+ }
+ if _, err := provider.Snapshot(ctx); !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected cancellation, got %v", err)
+ }
+}
+
+func TestContainerStateMappingNeverInventsHealth(t *testing.T) {
+ cases := map[string]application.State{
+ "running": application.StateHealthy,
+ "restarting": application.StateDegraded,
+ "paused": application.StateDegraded,
+ "exited": application.StateDown,
+ "dead": application.StateDown,
+ "created": application.StateUnknown,
+ "": application.StateUnknown,
+ "weird": application.StateUnknown,
+ }
+ for state, want := range cases {
+ if got := containerState(container.Container{State: state, Health: "healthy"}); got != want {
+ t.Fatalf("container state %q mapped to %s, want %s", state, got, want)
+ }
+ }
+ if got := containerState(container.Container{State: "running", Health: "unhealthy"}); got != application.StateDegraded {
+ t.Fatalf("an unhealthy running container mapped to %s", got)
+ }
+ if got := containerState(container.Container{State: "running", Health: "starting"}); got != application.StateUnknown {
+ t.Fatalf("a starting container mapped to %s", got)
+ }
+ if got := containerState(container.Container{State: "RUNNING", Health: "HEALTHY"}); got != application.StateHealthy {
+ t.Fatalf("uppercase runtime state mapped to %s", got)
+ }
+ if got := containerState(container.Container{State: "running", Health: "unknown"}); got != application.StateUnknown {
+ t.Fatalf("missing health became %s", got)
+ }
+ if got := containerState(container.Container{State: "exited", Health: "unknown", IntentionalStop: true}); got != application.StateUnknown {
+ t.Fatalf("intentional stop became %s", got)
+ }
+ if got := serviceState("nonsense"); got != application.StateUnknown {
+ t.Fatalf("an unrecognized probe verdict mapped to %s", got)
+ }
+}
diff --git a/internal/agentsource/health_test.go b/internal/agentsource/health_test.go
new file mode 100644
index 0000000..892b2fb
--- /dev/null
+++ b/internal/agentsource/health_test.go
@@ -0,0 +1,68 @@
+package agentsource
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/datasource"
+)
+
+type healthReader map[agentstore.Capability]agentstore.Snapshot
+
+func (r healthReader) Latest(_ context.Context, capability agentstore.Capability) (agentstore.Snapshot, error) {
+ snapshot, ok := r[capability]
+ if !ok {
+ return agentstore.Snapshot{}, agentstore.ErrNoSnapshot
+ }
+ return snapshot, nil
+}
+
+func TestHealthRequiresEveryCapabilityToBeFresh(t *testing.T) {
+ now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
+ reader := healthReader{
+ agentstore.CapabilityHost: {
+ Capability: agentstore.CapabilityHost, ObservedAt: now.Add(-time.Second), ReceivedAt: now.Add(-time.Second),
+ },
+ agentstore.CapabilityProcesses: {
+ Capability: agentstore.CapabilityProcesses, ObservedAt: now.Add(-2 * time.Second), ReceivedAt: now.Add(-2 * time.Second),
+ },
+ }
+ health, err := Health(context.Background(), reader, []agentstore.Capability{agentstore.CapabilityHost, agentstore.CapabilityProcesses}, Windows{}, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if health.State != datasource.HealthHealthy || health.LastSuccess != now.Add(-2*time.Second) {
+ t.Fatalf("health = %#v", health)
+ }
+
+ missing, err := Health(context.Background(), reader, []agentstore.Capability{agentstore.CapabilityHost, agentstore.CapabilityContainers}, Windows{}, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if missing.State != datasource.HealthUnknown || missing.ReasonCode != ReasonUnavailable {
+ t.Fatalf("missing health = %#v", missing)
+ }
+}
+
+func TestHealthRejectsStaleCapabilityAndPropagatesCancellation(t *testing.T) {
+ now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
+ reader := healthReader{agentstore.CapabilityHost: {
+ Capability: agentstore.CapabilityHost, ObservedAt: now.Add(-time.Hour), ReceivedAt: now.Add(-time.Hour),
+ }}
+ health, err := Health(context.Background(), reader, []agentstore.Capability{agentstore.CapabilityHost}, Windows{}, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if health.State != datasource.HealthUnknown || health.ReasonCode != ReasonStale {
+ t.Fatalf("stale health = %#v", health)
+ }
+
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := Health(cancelled, reader, []agentstore.Capability{agentstore.CapabilityHost}, Windows{}, now); !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation error = %v", err)
+ }
+}
diff --git a/internal/agentsource/providers.go b/internal/agentsource/providers.go
new file mode 100644
index 0000000..47a4eca
--- /dev/null
+++ b/internal/agentsource/providers.go
@@ -0,0 +1,185 @@
+package agentsource
+
+import (
+ "context"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/application"
+ "github.com/itworx/pulse/internal/array"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/disk"
+ "github.com/itworx/pulse/internal/host"
+ "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/process"
+ "github.com/itworx/pulse/internal/share"
+)
+
+// Source identifiers and types reported to the API. They match the defaults each domain
+// Adapter already applies, so a snapshot served from the agent store is indistinguishable
+// in shape from one served by any other adapter. The type records where the observation
+// originates: the host capabilities are read by the agent itself, the storage
+// capabilities are read by the agent from Unraid.
+const (
+ hostSourceID = "host"
+ processSourceID = "process"
+ containerSourceID = "container"
+ arraySourceID = "array"
+ diskSourceID = "disks"
+ poolSourceID = "pools"
+ shareSourceID = "shares"
+ agentSourceType = "agent"
+ unraidSourceType = "unraid"
+ applicationSources = application.SourceID
+)
+
+// HostProvider serves host telemetry recorded by the agent.
+type HostProvider struct {
+ Reader agentstore.Reader
+ Windows Windows
+ Limits host.Limits
+ Policy host.Policy
+ // Now overrides the clock in tests; production leaves it nil.
+ Now func() time.Time
+}
+
+var _ host.Provider = HostProvider{}
+
+func (p HostProvider) Snapshot(ctx context.Context) (host.Snapshot, error) {
+ now := clockNow(p.Now)
+ return resolve(ctx, p.Reader, agentstore.CapabilityHost, p.Windows.For(agentstore.CapabilityHost), now,
+ func(at time.Time, reason string) host.Snapshot {
+ return host.UnknownSnapshot(at, hostSourceID, agentSourceType, reason)
+ },
+ func(raw host.RawSnapshot, at time.Time) (host.Snapshot, error) {
+ return host.Adapter{Source: staticRaw[host.RawSnapshot]{raw}, Limits: p.Limits, Policy: p.Policy, Now: fixedClock(at)}.Snapshot(ctx)
+ })
+}
+
+// ProcessProvider serves the process inventory recorded by the agent.
+type ProcessProvider struct {
+ Reader agentstore.Reader
+ Windows Windows
+ Limits process.Limits
+ Now func() time.Time
+}
+
+func (p ProcessProvider) Snapshot(ctx context.Context) (process.Snapshot, error) {
+ now := clockNow(p.Now)
+ return resolve(ctx, p.Reader, agentstore.CapabilityProcesses, p.Windows.For(agentstore.CapabilityProcesses), now,
+ func(at time.Time, reason string) process.Snapshot {
+ return process.UnknownSnapshot(at, processSourceID, agentSourceType, reason)
+ },
+ func(raw process.RawSnapshot, at time.Time) (process.Snapshot, error) {
+ return process.Adapter{Source: staticRaw[process.RawSnapshot]{raw}, Limits: p.Limits, Now: fixedClock(at)}.Snapshot(ctx)
+ })
+}
+
+// ContainerProvider serves the container inventory recorded by the agent.
+type ContainerProvider struct {
+ Reader agentstore.Reader
+ Windows Windows
+ Limits container.Limits
+ Now func() time.Time
+}
+
+var _ container.Provider = ContainerProvider{}
+
+func (p ContainerProvider) Snapshot(ctx context.Context) (container.Snapshot, error) {
+ now := clockNow(p.Now)
+ return resolve(ctx, p.Reader, agentstore.CapabilityContainers, p.Windows.For(agentstore.CapabilityContainers), now,
+ func(at time.Time, reason string) container.Snapshot {
+ return container.UnknownSnapshot(at, containerSourceID, agentSourceType, reason)
+ },
+ func(raw container.RawSnapshot, at time.Time) (container.Snapshot, error) {
+ return container.Adapter{Source: staticRaw[container.RawSnapshot]{raw}, Limits: p.Limits, Now: fixedClock(at)}.Snapshot(ctx)
+ })
+}
+
+// ArrayProvider serves Unraid array state recorded by the agent.
+type ArrayProvider struct {
+ Reader agentstore.Reader
+ Windows Windows
+ Limits array.Limits
+ Policy array.Policy
+ Now func() time.Time
+}
+
+var _ array.Provider = ArrayProvider{}
+
+func (p ArrayProvider) Snapshot(ctx context.Context) (array.Snapshot, error) {
+ now := clockNow(p.Now)
+ return resolve(ctx, p.Reader, agentstore.CapabilityArray, p.Windows.For(agentstore.CapabilityArray), now,
+ func(at time.Time, reason string) array.Snapshot {
+ return array.UnknownSnapshot(at, arraySourceID, unraidSourceType, reason)
+ },
+ func(raw array.RawSnapshot, at time.Time) (array.Snapshot, error) {
+ return array.Adapter{Source: staticRaw[array.RawSnapshot]{raw}, Limits: p.Limits, Policy: p.Policy, Now: fixedClock(at)}.Snapshot(ctx)
+ })
+}
+
+// DiskProvider serves disk inventory, SMART and performance data recorded by the agent.
+type DiskProvider struct {
+ Reader agentstore.Reader
+ Windows Windows
+ Limits disk.Limits
+ Policy disk.Policy
+ Now func() time.Time
+}
+
+var _ disk.Provider = DiskProvider{}
+
+func (p DiskProvider) Snapshot(ctx context.Context) (disk.Snapshot, error) {
+ now := clockNow(p.Now)
+ return resolve(ctx, p.Reader, agentstore.CapabilityDisks, p.Windows.For(agentstore.CapabilityDisks), now,
+ func(at time.Time, reason string) disk.Snapshot {
+ return disk.UnknownSnapshot(at, diskSourceID, unraidSourceType, reason)
+ },
+ func(raw disk.RawSnapshot, at time.Time) (disk.Snapshot, error) {
+ return disk.Adapter{Source: staticRaw[disk.RawSnapshot]{raw}, Limits: p.Limits, Policy: p.Policy, Now: fixedClock(at)}.Snapshot(ctx)
+ })
+}
+
+// PoolProvider serves cache and named pool state recorded by the agent.
+type PoolProvider struct {
+ Reader agentstore.Reader
+ Windows Windows
+ Limits pool.Limits
+ Policy pool.Policy
+ Now func() time.Time
+}
+
+var _ pool.Provider = PoolProvider{}
+
+func (p PoolProvider) Snapshot(ctx context.Context) (pool.Snapshot, error) {
+ now := clockNow(p.Now)
+ return resolve(ctx, p.Reader, agentstore.CapabilityPools, p.Windows.For(agentstore.CapabilityPools), now,
+ func(at time.Time, reason string) pool.Snapshot {
+ return pool.UnknownSnapshot(at, poolSourceID, unraidSourceType, reason)
+ },
+ func(raw pool.RawSnapshot, at time.Time) (pool.Snapshot, error) {
+ return pool.Adapter{Source: staticRaw[pool.RawSnapshot]{raw}, Limits: p.Limits, Policy: p.Policy, Now: fixedClock(at)}.Snapshot(ctx)
+ })
+}
+
+// ShareProvider serves user share usage recorded by the agent.
+type ShareProvider struct {
+ Reader agentstore.Reader
+ Windows Windows
+ Limits share.Limits
+ Policy share.Policy
+ Now func() time.Time
+}
+
+var _ share.Provider = ShareProvider{}
+
+func (p ShareProvider) Snapshot(ctx context.Context) (share.Snapshot, error) {
+ now := clockNow(p.Now)
+ return resolve(ctx, p.Reader, agentstore.CapabilityShares, p.Windows.For(agentstore.CapabilityShares), now,
+ func(at time.Time, reason string) share.Snapshot {
+ return share.UnknownSnapshot(at, shareSourceID, unraidSourceType, reason)
+ },
+ func(raw share.RawSnapshot, at time.Time) (share.Snapshot, error) {
+ return share.Adapter{Source: staticRaw[share.RawSnapshot]{raw}, Limits: p.Limits, Policy: p.Policy, Now: fixedClock(at)}.Snapshot(ctx)
+ })
+}
diff --git a/internal/agentsource/providers_test.go b/internal/agentsource/providers_test.go
new file mode 100644
index 0000000..0097620
--- /dev/null
+++ b/internal/agentsource/providers_test.go
@@ -0,0 +1,423 @@
+package agentsource
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/array"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/disk"
+ "github.com/itworx/pulse/internal/host"
+ "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/process"
+ "github.com/itworx/pulse/internal/share"
+)
+
+// stubReader stands in for the PostgreSQL store so the read path can be exercised without
+// a database.
+type stubReader struct {
+ snapshot agentstore.Snapshot
+ err error
+ asked agentstore.Capability
+}
+
+func (r *stubReader) Latest(_ context.Context, capability agentstore.Capability) (agentstore.Snapshot, error) {
+ r.asked = capability
+ if r.err != nil {
+ return agentstore.Snapshot{}, r.err
+ }
+ return r.snapshot, nil
+}
+
+// observation is the part of every domain snapshot this package is responsible for.
+type observation struct {
+ state string
+ reason string
+ healthy bool
+}
+
+// domainUnderTest describes one capability generically so every domain runs the same
+// table: no snapshot, store failure, stale snapshot, corrupt payload, payload the domain
+// rejects, and a good payload.
+type domainUnderTest struct {
+ name string
+ capability agentstore.Capability
+ window time.Duration
+ good func(now time.Time) any
+ rejected func(now time.Time) any
+ observe func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error)
+}
+
+func floatPtr(value float64) *float64 { return &value }
+
+func domains() []domainUnderTest {
+ return []domainUnderTest{
+ {
+ name: "host",
+ capability: agentstore.CapabilityHost,
+ window: DefaultHostWindow,
+ good: func(now time.Time) any {
+ used := uint64(6 * 1024 * 1024 * 1024)
+ return host.RawSnapshot{
+ Source: host.Source{ID: "agent-1", Type: "agent"},
+ Identity: host.HostIdentity{Name: "pulse-host", Version: "7.2.2", Arch: "amd64"},
+ UptimeSeconds: 3600,
+ CPU: host.RawCPU{TotalPercent: floatPtr(42.5), PerCore: []float64{40, 45}, IOWaitPercent: floatPtr(2)},
+ Load: host.RawLoad{One: 0.2, Five: 0.1, Fifteen: 0.1},
+ Memory: host.RawMemory{TotalBytes: 8 * 1024 * 1024 * 1024, AvailableBytes: 2 * 1024 * 1024 * 1024, UsedBytes: &used},
+ Filesystems: []host.RawFilesystem{{Mount: "/", Filesystem: "xfs", CapacityBytes: 100, UsedBytes: 25}},
+ Network: []host.RawNetworkInterface{{Name: "eth0", State: "up"}},
+ Time: host.RawTime{Synchronized: true, OffsetSeconds: 0.002, Stratum: 2},
+ ObservedAt: now, ReceivedAt: now,
+ }
+ },
+ rejected: func(now time.Time) any {
+ return host.RawSnapshot{Identity: host.HostIdentity{Name: ""}, ObservedAt: now, ReceivedAt: now}
+ },
+ observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
+ snapshot, err := HostProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
+ return observation{
+ state: snapshot.Source.State,
+ reason: snapshot.Source.Reason,
+ healthy: snapshot.Source.Freshness == host.Fresh && snapshot.Status.State == host.StatusHealthy,
+ }, err
+ },
+ },
+ {
+ name: "processes",
+ capability: agentstore.CapabilityProcesses,
+ window: DefaultProcessesWindow,
+ good: func(now time.Time) any {
+ return process.RawSnapshot{
+ Source: process.Source{ID: "agent-1", Type: "agent"},
+ Processes: []process.RawProcess{{PID: 1, Name: "init", State: "sleeping", RuntimeSeconds: 100, CPUPercent: 1, MemoryBytes: 500}},
+ ObservedAt: now, ReceivedAt: now,
+ }
+ },
+ rejected: func(now time.Time) any {
+ return process.RawSnapshot{Processes: []process.RawProcess{{PID: 0, Name: ""}}, ObservedAt: now, ReceivedAt: now}
+ },
+ observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
+ snapshot, err := ProcessProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
+ return observation{state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.State == "healthy"}, err
+ },
+ },
+ {
+ name: "containers",
+ capability: agentstore.CapabilityContainers,
+ window: DefaultContainersWindow,
+ good: func(now time.Time) any {
+ return container.RawSnapshot{
+ Source: container.Source{ID: "agent-1", Type: "agent"},
+ Containers: []container.RawContainer{{ID: "a", Name: "alpha", State: "running", Health: "healthy"}},
+ ObservedAt: now, ReceivedAt: now,
+ }
+ },
+ rejected: func(now time.Time) any {
+ return container.RawSnapshot{Containers: []container.RawContainer{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
+ },
+ observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
+ snapshot, err := ContainerProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
+ return observation{
+ state: snapshot.Source.State,
+ reason: snapshot.Source.Reason,
+ healthy: snapshot.Source.Freshness == "fresh" && snapshot.Source.State == "healthy",
+ }, err
+ },
+ },
+ {
+ name: "array",
+ capability: agentstore.CapabilityArray,
+ window: DefaultArrayWindow,
+ good: func(now time.Time) any {
+ return array.RawSnapshot{
+ Source: array.Source{ID: "agent-1", Type: "unraid"},
+ State: array.StateOperational,
+ Parity: array.RawParity{Present: true, State: "idle"},
+ Members: []array.RawMember{
+ {ID: "disk1", Name: "Disk 1", Role: "data", State: "online", CapacityBytes: 100},
+ {ID: "parity", Name: "Parity", Role: "parity", State: "online", CapacityBytes: 100},
+ },
+ ObservedAt: now, ReceivedAt: now,
+ }
+ },
+ rejected: func(now time.Time) any {
+ return array.RawSnapshot{State: array.StateOperational, Members: []array.RawMember{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
+ },
+ observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
+ snapshot, err := ArrayProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
+ return observation{
+ state: snapshot.Source.State,
+ reason: snapshot.Source.Reason,
+ healthy: snapshot.Source.Freshness == array.Fresh && snapshot.State == array.StateOperational,
+ }, err
+ },
+ },
+ {
+ name: "disks",
+ capability: agentstore.CapabilityDisks,
+ window: DefaultDisksWindow,
+ good: func(now time.Time) any {
+ return disk.RawSnapshot{
+ Source: disk.Source{ID: "agent-1", Type: "unraid"},
+ Disks: []disk.RawDisk{{ID: "disk1", Name: "Disk 1", Role: "data", State: disk.StateOnline, SizeBytes: 100}},
+ ObservedAt: now, ReceivedAt: now,
+ }
+ },
+ rejected: func(now time.Time) any {
+ return disk.RawSnapshot{Disks: []disk.RawDisk{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
+ },
+ observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
+ snapshot, err := DiskProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
+ return observation{
+ state: snapshot.Source.State,
+ reason: snapshot.Source.Reason,
+ healthy: snapshot.Source.Freshness == disk.Fresh && snapshot.Source.State != disk.StateUnknown,
+ }, err
+ },
+ },
+ {
+ name: "pools",
+ capability: agentstore.CapabilityPools,
+ window: DefaultPoolsWindow,
+ good: func(now time.Time) any {
+ return pool.RawSnapshot{
+ Source: pool.Source{ID: "agent-1", Type: "unraid"},
+ Pools: []pool.RawPool{{ID: "cache", Name: "Cache", Filesystem: "btrfs", State: pool.StateHealthy, UsableBytes: 1000, UsedBytes: 100}},
+ ObservedAt: now, ReceivedAt: now,
+ }
+ },
+ rejected: func(now time.Time) any {
+ return pool.RawSnapshot{Pools: []pool.RawPool{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
+ },
+ observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
+ snapshot, err := PoolProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
+ return observation{
+ state: snapshot.Source.State,
+ reason: snapshot.Source.Reason,
+ healthy: snapshot.Source.Freshness == pool.Fresh && snapshot.Source.State != pool.StateUnknown,
+ }, err
+ },
+ },
+ {
+ name: "shares",
+ capability: agentstore.CapabilityShares,
+ window: DefaultSharesWindow,
+ good: func(now time.Time) any {
+ return share.RawSnapshot{
+ Source: share.Source{ID: "agent-1", Type: "unraid"},
+ Shares: []share.RawShare{{ID: "share-media", Name: "Media", UsedBytes: 700, SizeObservedAt: now, SizeState: share.SizeCached}},
+ ObservedAt: now, ReceivedAt: now,
+ }
+ },
+ rejected: func(now time.Time) any {
+ return share.RawSnapshot{Shares: []share.RawShare{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
+ },
+ observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
+ snapshot, err := ShareProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
+ return observation{
+ state: snapshot.Source.State,
+ reason: snapshot.Source.Reason,
+ healthy: snapshot.Source.Freshness == share.Fresh && snapshot.Source.State != share.StateUnknown,
+ }, err
+ },
+ },
+ }
+}
+
+func encode(t *testing.T, value any) json.RawMessage {
+ t.Helper()
+ payload, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return payload
+}
+
+func TestProvidersMapUnusableTelemetryToUnknown(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ for _, domain := range domains() {
+ t.Run(domain.name, func(t *testing.T) {
+ fresh := agentstore.Snapshot{AgentID: "agent-1", Capability: domain.capability, ObservedAt: now, ReceivedAt: now}
+ cases := []struct {
+ name string
+ reader *stubReader
+ reason string
+ }{
+ {"no snapshot", &stubReader{err: agentstore.ErrNoSnapshot}, ReasonUnavailable},
+ {"store unreachable", &stubReader{err: errors.New("connection refused")}, ReasonUnavailable},
+ {"stale snapshot", &stubReader{snapshot: func() agentstore.Snapshot {
+ stale := fresh
+ stale.ObservedAt = now.Add(-domain.window - time.Second)
+ stale.Payload = encode(t, domain.good(stale.ObservedAt))
+ return stale
+ }()}, ReasonStale},
+ {"missing observation time", &stubReader{snapshot: func() agentstore.Snapshot {
+ broken := fresh
+ broken.ObservedAt = time.Time{}
+ broken.Payload = encode(t, domain.good(now))
+ return broken
+ }()}, ReasonStale},
+ {"corrupt payload", &stubReader{snapshot: func() agentstore.Snapshot {
+ corrupt := fresh
+ corrupt.Payload = json.RawMessage(`{"source":"not-an-object"}`)
+ return corrupt
+ }()}, ReasonInvalid},
+ {"payload the domain rejects", &stubReader{snapshot: func() agentstore.Snapshot {
+ invalid := fresh
+ invalid.Payload = encode(t, domain.rejected(now))
+ return invalid
+ }()}, ReasonInvalid},
+ }
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ got, err := domain.observe(context.Background(), testCase.reader, now)
+ if err != nil {
+ t.Fatalf("unusable telemetry must not fail the request: %v", err)
+ }
+ if got.healthy {
+ t.Fatal("unusable telemetry became healthy, violating ADR-0008")
+ }
+ if got.state != "unknown" {
+ t.Fatalf("source state = %q, want unknown", got.state)
+ }
+ if got.reason != testCase.reason {
+ t.Fatalf("reason = %q, want %q", got.reason, testCase.reason)
+ }
+ if testCase.reader.asked != domain.capability {
+ t.Fatalf("read capability %q, want %q", testCase.reader.asked, domain.capability)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestProvidersNormalizeGoodTelemetry(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ for _, domain := range domains() {
+ t.Run(domain.name, func(t *testing.T) {
+ observed := now.Add(-time.Second)
+ reader := &stubReader{snapshot: agentstore.Snapshot{
+ AgentID: "agent-1", Capability: domain.capability, ObservedAt: observed, ReceivedAt: observed,
+ Payload: encode(t, domain.good(observed)),
+ }}
+ got, err := domain.observe(context.Background(), reader, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.healthy {
+ t.Fatalf("fresh telemetry did not normalize to a healthy source: %+v", got)
+ }
+ if got.reason != "" {
+ t.Fatalf("healthy source carries reason %q", got.reason)
+ }
+ })
+ }
+}
+
+func TestProvidersTreatTheWindowBoundaryAsFresh(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ for _, domain := range domains() {
+ t.Run(domain.name, func(t *testing.T) {
+ observed := now.Add(-domain.window)
+ reader := &stubReader{snapshot: agentstore.Snapshot{
+ AgentID: "agent-1", Capability: domain.capability, ObservedAt: observed, ReceivedAt: observed,
+ Payload: encode(t, domain.good(observed)),
+ }}
+ got, err := domain.observe(context.Background(), reader, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.state == "unknown" {
+ t.Fatalf("a snapshot exactly at the window edge must still be usable: %+v", got)
+ }
+ })
+ }
+}
+
+func TestProvidersWithoutAReaderReportUnavailable(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ for _, domain := range domains() {
+ t.Run(domain.name, func(t *testing.T) {
+ got, err := domain.observe(context.Background(), nil, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.healthy || got.state != "unknown" || got.reason != ReasonUnavailable {
+ t.Fatalf("unexpected observation %+v", got)
+ }
+ })
+ }
+}
+
+func TestProvidersPropagateContextCancellation(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ for _, domain := range domains() {
+ t.Run(domain.name, func(t *testing.T) {
+ reader := &stubReader{err: context.Canceled}
+ if _, err := domain.observe(ctx, reader, now); !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected cancellation, got %v", err)
+ }
+ })
+ }
+}
+
+func TestWindowsApplyDocumentedDefaults(t *testing.T) {
+ windows := Windows{}
+ expected := map[agentstore.Capability]time.Duration{
+ agentstore.CapabilityHost: DefaultHostWindow,
+ agentstore.CapabilityProcesses: DefaultProcessesWindow,
+ agentstore.CapabilityContainers: DefaultContainersWindow,
+ agentstore.CapabilityArray: DefaultArrayWindow,
+ agentstore.CapabilityDisks: DefaultDisksWindow,
+ agentstore.CapabilityPools: DefaultPoolsWindow,
+ agentstore.CapabilityShares: DefaultSharesWindow,
+ }
+ for _, capability := range agentstore.Capabilities() {
+ if got := windows.For(capability); got != expected[capability] {
+ t.Fatalf("window for %q = %s, want %s", capability, got, expected[capability])
+ }
+ }
+ if windows.For(agentstore.CapabilityHost) >= windows.For(agentstore.CapabilityShares) {
+ t.Fatal("the host is sampled far more often than shares and must have the tighter window")
+ }
+ if err := windows.Validate(); err != nil {
+ t.Fatalf("defaults must validate: %v", err)
+ }
+ if err := (Windows{Host: 48 * time.Hour}).Validate(); err == nil {
+ t.Fatal("a window beyond a day must be rejected")
+ }
+ if err := (Windows{Shares: -time.Second}).Validate(); err == nil {
+ t.Fatal("a negative window must be rejected")
+ }
+ custom := Windows{Host: 5 * time.Second}.WithDefaults()
+ if custom.Host != 5*time.Second || custom.Shares != DefaultSharesWindow {
+ t.Fatalf("configuration was not preserved: %+v", custom)
+ }
+}
+
+func TestConfiguredWindowOverridesTheDefault(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ observed := now.Add(-10 * time.Second)
+ payload := encode(t, process.RawSnapshot{
+ Source: process.Source{ID: "agent-1", Type: "agent"},
+ Processes: []process.RawProcess{{PID: 1, Name: "init", State: "sleeping"}},
+ ObservedAt: observed, ReceivedAt: observed,
+ })
+ reader := &stubReader{snapshot: agentstore.Snapshot{AgentID: "agent-1", Capability: agentstore.CapabilityProcesses, ObservedAt: observed, ReceivedAt: observed, Payload: payload}}
+ provider := ProcessProvider{Reader: reader, Windows: Windows{Processes: 5 * time.Second}, Now: fixedClock(now)}
+ snapshot, err := provider.Snapshot(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Source.State != "unknown" || snapshot.Source.Reason != ReasonStale {
+ t.Fatalf("a tightened window must make the snapshot stale: %+v", snapshot.Source)
+ }
+}
diff --git a/internal/agentstore/contract.go b/internal/agentstore/contract.go
new file mode 100644
index 0000000..54ee8a9
--- /dev/null
+++ b/internal/agentstore/contract.go
@@ -0,0 +1,104 @@
+// Package agentstore is the transport boundary between pulse-agent and pulse-api.
+//
+// The agent runs with the narrow read-only host access it needs and never exposes a
+// network endpoint; the API never reaches out to the host. Both processes already share
+// PostgreSQL on the internal-only Compose network (see deploy/compose.yaml and ADR-0010),
+// so the agent writes bounded, normalized snapshots into the database and the API reads
+// the most recent one per capability. This keeps the privilege separation required by
+// SYSTEM_ARCHITECTURE section "pulse-agent" without adding an inbound port to the agent.
+//
+// Freshness is deliberately the reader's problem, not the writer's: a snapshot carries
+// the time the agent observed it, and the reader decides whether that is still usable.
+// A capability with no snapshot, or one older than its freshness window, resolves to
+// Unknown and never to Healthy (ADR-0008).
+package agentstore
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "time"
+)
+
+// Capability identifies one bounded telemetry surface an agent can report. The set is
+// closed: a reader must never accept a capability it does not recognize, because an
+// unknown capability cannot be normalized or bounded.
+type Capability string
+
+const (
+ CapabilityHost Capability = "host"
+ CapabilityProcesses Capability = "processes"
+ CapabilityContainers Capability = "containers"
+ CapabilityArray Capability = "array"
+ CapabilityDisks Capability = "disks"
+ CapabilityPools Capability = "pools"
+ CapabilityShares Capability = "shares"
+)
+
+// Capabilities lists every capability the platform recognizes, in a stable order.
+func Capabilities() []Capability {
+ return []Capability{
+ CapabilityHost, CapabilityProcesses, CapabilityContainers,
+ CapabilityArray, CapabilityDisks, CapabilityPools, CapabilityShares,
+ }
+}
+
+// Valid reports whether the capability is one this platform recognizes.
+func (c Capability) Valid() bool {
+ for _, known := range Capabilities() {
+ if c == known {
+ return true
+ }
+ }
+ return false
+}
+
+// MaxPayloadBytes bounds a single snapshot. The largest realistic payload is the process
+// inventory at the documented scale target; this leaves generous headroom while keeping a
+// misbehaving or compromised agent from filling the database.
+const MaxPayloadBytes = 2 << 20
+
+// ErrNoSnapshot is returned by Reader.Latest when the capability has never been reported.
+// It is an expected condition on a fresh install, not a failure: callers translate it into
+// an Unknown status with an explicit reason.
+var ErrNoSnapshot = errors.New("no agent snapshot recorded")
+
+// Snapshot is one bounded observation of a single capability.
+type Snapshot struct {
+ // AgentID identifies the reporting agent.
+ AgentID string
+ // Capability is the telemetry surface this payload describes.
+ Capability Capability
+ // ObservedAt is when the agent read the underlying source, in UTC.
+ ObservedAt time.Time
+ // ReceivedAt is when the store accepted the snapshot, in UTC. It is set by the
+ // writer implementation, never by the agent, so a skewed agent clock cannot make
+ // stale data look fresh.
+ ReceivedAt time.Time
+ // Payload is the domain RawSnapshot for this capability, JSON encoded.
+ Payload json.RawMessage
+}
+
+// Age reports how long ago the agent observed this snapshot.
+func (s Snapshot) Age(now time.Time) time.Duration { return now.Sub(s.ObservedAt) }
+
+// Writer is the narrow interface pulse-agent depends on. The agent must not be able to
+// read other agents' data or mutate anything else.
+type Writer interface {
+ // Put records the newest snapshot for one capability, replacing any previous one.
+ // Implementations reject an unknown capability, an oversized payload, a zero or
+ // future ObservedAt, and payloads that are not valid JSON objects.
+ Put(ctx context.Context, snapshot Snapshot) error
+}
+
+// Reader is the narrow interface pulse-api depends on.
+type Reader interface {
+ // Latest returns the most recent snapshot for the capability, or ErrNoSnapshot.
+ Latest(ctx context.Context, capability Capability) (Snapshot, error)
+}
+
+// Store is the combined boundary. Only the migration-owning implementation satisfies it.
+type Store interface {
+ Writer
+ Reader
+}
diff --git a/internal/agentstore/postgres.go b/internal/agentstore/postgres.go
new file mode 100644
index 0000000..01c2930
--- /dev/null
+++ b/internal/agentstore/postgres.go
@@ -0,0 +1,246 @@
+package agentstore
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// MaxAgentIDBytes bounds the reporting agent identifier. It matches the column check in
+// migration 0016 so a rejection surfaces as a terse store error rather than a constraint
+// violation from PostgreSQL.
+const MaxAgentIDBytes = 128
+
+// MaxClockSkew is the only tolerance granted to an agent clock that runs ahead of the
+// database. The agent and the database share one host in the supported topology, so real
+// skew is sub-millisecond; one second absorbs scheduling jitter while staying far below
+// the tightest freshness window, so a skewed clock cannot mask meaningful staleness.
+const MaxClockSkew = time.Second
+
+// ErrUnavailable is returned when the store has no database pool. Callers translate it
+// into an Unknown status exactly as they do ErrNoSnapshot.
+var ErrUnavailable = errors.New("agent snapshot store is unavailable")
+
+// PostgresStore is the only implementation of Store. It owns the agent_snapshots table
+// created by migration 0016 and keeps exactly one row per (agent, capability).
+//
+// Transaction boundary: Put commits the latest snapshot and any derived capacity samples
+// atomically. Latest remains a single read statement.
+type PostgresStore struct {
+ // Pool is the shared pgx pool. A nil pool makes every call return ErrUnavailable.
+ Pool *pgxpool.Pool
+ // Clock supplies ReceivedAt and the future-observation check. It exists for tests;
+ // production leaves it nil and the store uses the wall clock in UTC.
+ Clock func() time.Time
+}
+
+var _ Store = PostgresStore{}
+
+func (s PostgresStore) now() time.Time {
+ if s.Clock == nil {
+ return time.Now().UTC()
+ }
+ return s.Clock().UTC()
+}
+
+// Put records the newest snapshot for one capability, replacing any previous one for the
+// same agent. It rejects an unknown capability, a missing or oversized payload, a payload
+// that is not a JSON object, and a zero or future ObservedAt.
+//
+// ReceivedAt is always taken from the store clock: the caller's value is ignored so a
+// skewed or hostile agent clock cannot make stale data look freshly received.
+//
+// A snapshot that is older than the row already stored is accepted but does not overwrite
+// it, so a delayed retry cannot resurrect superseded telemetry.
+func (s PostgresStore) Put(ctx context.Context, snapshot Snapshot) error {
+ row, err := prepare(snapshot, s.now())
+ if err != nil {
+ return err
+ }
+ if s.Pool == nil {
+ return ErrUnavailable
+ }
+ tx, err := s.Pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("begin agent snapshot write: %w", err)
+ }
+ defer func() {
+ rollbackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = tx.Rollback(rollbackCtx)
+ }()
+ if _, err := tx.Exec(ctx, `INSERT INTO agent_snapshots (agent_id, capability, observed_at, received_at, payload)
+ VALUES ($1, $2, $3, $4, $5::jsonb)
+ ON CONFLICT (agent_id, capability) DO UPDATE
+ SET observed_at = EXCLUDED.observed_at, received_at = EXCLUDED.received_at, payload = EXCLUDED.payload
+ WHERE agent_snapshots.observed_at <= EXCLUDED.observed_at`,
+ row.AgentID, string(row.Capability), row.ObservedAt, row.ReceivedAt, []byte(row.Payload)); err != nil {
+ return fmt.Errorf("write agent snapshot: %w", err)
+ }
+ if err := persistCapacitySamples(ctx, tx, row); err != nil {
+ return err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit agent snapshot write: %w", err)
+ }
+ return nil
+}
+
+type capacitySample struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ObservedAt time.Time `json:"observed_at"`
+ UsedBytes uint64 `json:"used_bytes"`
+ CapacityBytes uint64 `json:"capacity_bytes"`
+}
+
+func persistCapacitySamples(ctx context.Context, tx pgx.Tx, snapshot Snapshot) error {
+ samples, err := capacitySamples(snapshot)
+ if err != nil {
+ return err
+ }
+ if len(samples) == 0 {
+ return nil
+ }
+ payload, err := json.Marshal(samples)
+ if err != nil {
+ return fmt.Errorf("encode capacity samples: %w", err)
+ }
+ _, err = tx.Exec(ctx, `INSERT INTO capacity_samples (entity_kind,entity_id,entity_name,source_id,sampled_at,observed_at,used_bytes,capacity_bytes)
+SELECT sample.kind,sample.id,sample.name,$1,
+ date_trunc('day',sample.observed_at) + floor(extract(hour FROM sample.observed_at)/6)*interval '6 hours',
+ sample.observed_at,sample.used_bytes,sample.capacity_bytes
+FROM jsonb_to_recordset($2::jsonb) AS sample(kind text,id text,name text,observed_at timestamptz,used_bytes bigint,capacity_bytes bigint)
+ON CONFLICT (entity_kind,entity_id,source_id,sampled_at) DO UPDATE
+SET entity_name=EXCLUDED.entity_name,observed_at=EXCLUDED.observed_at,used_bytes=EXCLUDED.used_bytes,capacity_bytes=EXCLUDED.capacity_bytes
+WHERE capacity_samples.observed_at <= EXCLUDED.observed_at`, snapshot.AgentID, payload)
+ if err != nil {
+ return fmt.Errorf("persist capacity samples: %w", err)
+ }
+ return nil
+}
+
+func capacitySamples(snapshot Snapshot) ([]capacitySample, error) {
+ kind := ""
+ collection := ""
+ capacityField := ""
+ switch snapshot.Capability {
+ case CapabilityShares:
+ kind, collection = "share", "shares"
+ case CapabilityPools:
+ kind, collection, capacityField = "pool", "pools", "usableBytes"
+ case CapabilityDisks:
+ kind, collection, capacityField = "disk", "disks", "sizeBytes"
+ default:
+ return nil, nil
+ }
+ var document map[string]json.RawMessage
+ if err := json.Unmarshal(snapshot.Payload, &document); err != nil {
+ return nil, errors.New("decode capacity snapshot payload")
+ }
+ var items []map[string]json.RawMessage
+ if err := json.Unmarshal(document[collection], &items); err != nil {
+ return nil, fmt.Errorf("decode %s capacity collection", kind)
+ }
+ samples := make([]capacitySample, 0, len(items))
+ for _, item := range items {
+ var id, name string
+ var used, capacity uint64
+ if json.Unmarshal(item["id"], &id) != nil || json.Unmarshal(item["name"], &name) != nil || json.Unmarshal(item["usedBytes"], &used) != nil {
+ continue
+ }
+ if capacityField != "" {
+ if json.Unmarshal(item[capacityField], &capacity) != nil {
+ continue
+ }
+ }
+ observed := snapshot.ObservedAt
+ if kind == "share" {
+ var sizeObserved time.Time
+ if json.Unmarshal(item["sizeObservedAt"], &sizeObserved) == nil && !sizeObserved.IsZero() {
+ observed = sizeObserved.UTC()
+ }
+ }
+ id, name = strings.TrimSpace(id), strings.TrimSpace(name)
+ if id == "" || name == "" || len(id) > 128 || len(name) > 255 || used > math.MaxInt64 || capacity > math.MaxInt64 || observed.IsZero() || observed.After(snapshot.ReceivedAt.Add(MaxClockSkew)) {
+ continue
+ }
+ samples = append(samples, capacitySample{Kind: kind, ID: id, Name: name, ObservedAt: observed, UsedBytes: used, CapacityBytes: capacity})
+ }
+ return samples, nil
+}
+
+// Latest returns the most recent snapshot for the capability across every reporting
+// agent, or ErrNoSnapshot when none has been recorded.
+func (s PostgresStore) Latest(ctx context.Context, capability Capability) (Snapshot, error) {
+ if !capability.Valid() {
+ return Snapshot{}, fmt.Errorf("unknown agent capability %q", capability)
+ }
+ if s.Pool == nil {
+ return Snapshot{}, ErrUnavailable
+ }
+ return scanSnapshot(s.Pool.QueryRow(ctx, `SELECT agent_id, capability, observed_at, received_at, payload
+ FROM agent_snapshots WHERE capability = $1 ORDER BY observed_at DESC, received_at DESC LIMIT 1`,
+ string(capability)))
+}
+
+// scanSnapshot reads one row and maps the absence of a row onto ErrNoSnapshot, which is an
+// expected condition on a fresh install rather than a failure.
+func scanSnapshot(row pgx.Row) (Snapshot, error) {
+ var (
+ snapshot Snapshot
+ name string
+ payload []byte
+ )
+ err := row.Scan(&snapshot.AgentID, &name, &snapshot.ObservedAt, &snapshot.ReceivedAt, &payload)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Snapshot{}, ErrNoSnapshot
+ }
+ if err != nil {
+ return Snapshot{}, fmt.Errorf("read agent snapshot: %w", err)
+ }
+ snapshot.Capability = Capability(name)
+ snapshot.ObservedAt = snapshot.ObservedAt.UTC()
+ snapshot.ReceivedAt = snapshot.ReceivedAt.UTC()
+ snapshot.Payload = json.RawMessage(payload)
+ return snapshot, nil
+}
+
+// prepare enforces every bound the Writer contract documents and returns the row the
+// store persists. It is the single place where an inbound snapshot is trusted, and it
+// overwrites ReceivedAt with the store clock so the caller cannot influence freshness.
+func prepare(snapshot Snapshot, now time.Time) (Snapshot, error) {
+ if snapshot.AgentID == "" || len(snapshot.AgentID) > MaxAgentIDBytes {
+ return Snapshot{}, errors.New("agent id is required and bounded")
+ }
+ if !snapshot.Capability.Valid() {
+ return Snapshot{}, fmt.Errorf("unknown agent capability %q", snapshot.Capability)
+ }
+ if len(snapshot.Payload) == 0 {
+ return Snapshot{}, errors.New("agent snapshot payload is required")
+ }
+ if len(snapshot.Payload) > MaxPayloadBytes {
+ return Snapshot{}, fmt.Errorf("agent snapshot payload exceeds %d bytes", MaxPayloadBytes)
+ }
+ if snapshot.ObservedAt.IsZero() {
+ return Snapshot{}, errors.New("agent snapshot observed time is required")
+ }
+ if snapshot.ObservedAt.After(now.Add(MaxClockSkew)) {
+ return Snapshot{}, errors.New("agent snapshot observed time is in the future")
+ }
+ var object map[string]json.RawMessage
+ if err := json.Unmarshal(snapshot.Payload, &object); err != nil || object == nil {
+ return Snapshot{}, errors.New("agent snapshot payload must be a JSON object")
+ }
+ snapshot.ObservedAt = snapshot.ObservedAt.UTC()
+ snapshot.ReceivedAt = now
+ return snapshot, nil
+}
diff --git a/internal/agentstore/postgres_integration_test.go b/internal/agentstore/postgres_integration_test.go
new file mode 100644
index 0000000..cb0bfa6
--- /dev/null
+++ b/internal/agentstore/postgres_integration_test.go
@@ -0,0 +1,110 @@
+package agentstore
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+// TestPostgreSQLSnapshotRoundTrip exercises the real table created by migration 0016. It
+// skips unless PULSE_TEST_DATABASE_URL points at a disposable database.
+func TestPostgreSQLSnapshotRoundTrip(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `DELETE FROM agent_snapshots WHERE agent_id = 'integration-agent'`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `DELETE FROM capacity_samples WHERE source_id = 'integration-agent'`); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ cleanup, cancelCleanup := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancelCleanup()
+ _, _ = pool.Exec(cleanup, `DELETE FROM agent_snapshots WHERE agent_id = 'integration-agent'`)
+ _, _ = pool.Exec(cleanup, `DELETE FROM capacity_samples WHERE source_id = 'integration-agent'`)
+ })
+
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ store := PostgresStore{Pool: pool, Clock: func() time.Time { return now }}
+ readOwn := func() Snapshot {
+ t.Helper()
+ stored := Snapshot{AgentID: "integration-agent", Capability: CapabilityPools}
+ var payload []byte
+ if err := pool.QueryRow(ctx, `SELECT observed_at,received_at,payload FROM agent_snapshots WHERE agent_id=$1 AND capability=$2`, stored.AgentID, stored.Capability).Scan(&stored.ObservedAt, &stored.ReceivedAt, &payload); err != nil {
+ t.Fatal(err)
+ }
+ stored.Payload = payload
+ return stored
+ }
+ if _, err := store.Latest(ctx, CapabilityPools); err != nil && !errors.Is(err, ErrNoSnapshot) {
+ t.Fatalf("unexpected error reading an empty capability: %v", err)
+ }
+
+ first := Snapshot{AgentID: "integration-agent", Capability: CapabilityPools, ObservedAt: now.Add(-30 * time.Second), ReceivedAt: now.Add(72 * time.Hour), Payload: json.RawMessage(`{"pools":[{"id":"cache","name":"Cache","usedBytes":100,"usableBytes":1000}],"generation":1}`)}
+ if err := store.Put(ctx, first); err != nil {
+ t.Fatal(err)
+ }
+ stored := readOwn()
+ if !stored.ReceivedAt.Equal(now) {
+ t.Fatalf("received at = %s, want the store clock %s", stored.ReceivedAt, now)
+ }
+ if !stored.ObservedAt.Equal(first.ObservedAt) {
+ t.Fatalf("observed at = %s, want %s", stored.ObservedAt, first.ObservedAt)
+ }
+
+ newer := first
+ newer.ObservedAt = now.Add(-5 * time.Second)
+ newer.Payload = json.RawMessage(`{"pools":[{"id":"cache","name":"Cache","usedBytes":200,"usableBytes":1000}],"generation":2}`)
+ if err := store.Put(ctx, newer); err != nil {
+ t.Fatal(err)
+ }
+ older := first
+ older.ObservedAt = now.Add(-120 * time.Second)
+ older.Payload = json.RawMessage(`{"pools":[{"id":"cache","name":"Cache","usedBytes":50,"usableBytes":1000}],"generation":3}`)
+ if err := store.Put(ctx, older); err != nil {
+ t.Fatal(err)
+ }
+ stored = readOwn()
+ var decoded struct {
+ Generation int `json:"generation"`
+ }
+ if err := json.Unmarshal(stored.Payload, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if decoded.Generation != 2 {
+ t.Fatalf("a delayed retry must not resurrect superseded telemetry, got generation %d", decoded.Generation)
+ }
+
+ var rows int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM agent_snapshots WHERE agent_id = 'integration-agent'`).Scan(&rows); err != nil {
+ t.Fatal(err)
+ }
+ if rows != 1 {
+ t.Fatalf("agent snapshot rows = %d, want exactly one per (agent, capability)", rows)
+ }
+ var sampleRows int
+ var usedBytes int64
+ if err := pool.QueryRow(ctx, `SELECT count(*),max(used_bytes) FROM capacity_samples WHERE source_id='integration-agent' AND entity_kind='pool' AND entity_id='cache'`).Scan(&sampleRows, &usedBytes); err != nil {
+ t.Fatal(err)
+ }
+ if sampleRows != 1 || usedBytes != 200 {
+ t.Fatalf("six-hour bucket was not idempotent or accepted an older retry: rows=%d used=%d", sampleRows, usedBytes)
+ }
+}
diff --git a/internal/agentstore/postgres_test.go b/internal/agentstore/postgres_test.go
new file mode 100644
index 0000000..562a441
--- /dev/null
+++ b/internal/agentstore/postgres_test.go
@@ -0,0 +1,218 @@
+package agentstore
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func fixedClock(now time.Time) func() time.Time { return func() time.Time { return now } }
+
+func TestPutRejectsEveryDocumentedBound(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ valid := Snapshot{AgentID: "agent-1", Capability: CapabilityHost, ObservedAt: now.Add(-5 * time.Second), Payload: json.RawMessage(`{"identity":{"name":"tower"}}`)}
+ oversized := make([]byte, MaxPayloadBytes+1)
+ oversized[0] = '{'
+ for i := 1; i < len(oversized)-1; i++ {
+ oversized[i] = ' '
+ }
+ oversized[len(oversized)-1] = '}'
+
+ cases := []struct {
+ name string
+ mutate func(Snapshot) Snapshot
+ contains string
+ }{
+ {"missing agent", func(s Snapshot) Snapshot { s.AgentID = ""; return s }, "agent id"},
+ {"oversized agent", func(s Snapshot) Snapshot { s.AgentID = strings.Repeat("a", MaxAgentIDBytes+1); return s }, "agent id"},
+ {"unknown capability", func(s Snapshot) Snapshot { s.Capability = "gpu"; return s }, "unknown agent capability"},
+ {"empty capability", func(s Snapshot) Snapshot { s.Capability = ""; return s }, "unknown agent capability"},
+ {"missing payload", func(s Snapshot) Snapshot { s.Payload = nil; return s }, "payload is required"},
+ {"oversized payload", func(s Snapshot) Snapshot { s.Payload = oversized; return s }, "exceeds"},
+ {"zero observed at", func(s Snapshot) Snapshot { s.ObservedAt = time.Time{}; return s }, "observed time is required"},
+ {"future observed at", func(s Snapshot) Snapshot { s.ObservedAt = now.Add(time.Hour); return s }, "in the future"},
+ {"array payload", func(s Snapshot) Snapshot { s.Payload = json.RawMessage(`[]`); return s }, "JSON object"},
+ {"scalar payload", func(s Snapshot) Snapshot { s.Payload = json.RawMessage(`42`); return s }, "JSON object"},
+ {"null payload", func(s Snapshot) Snapshot { s.Payload = json.RawMessage(`null`); return s }, "JSON object"},
+ {"corrupt payload", func(s Snapshot) Snapshot { s.Payload = json.RawMessage(`{"a":`); return s }, "JSON object"},
+ }
+ store := PostgresStore{Clock: fixedClock(now)}
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ err := store.Put(context.Background(), testCase.mutate(valid))
+ if err == nil {
+ t.Fatal("expected rejection")
+ }
+ if errors.Is(err, ErrUnavailable) {
+ t.Fatalf("bounds must be enforced before availability: %v", err)
+ }
+ if !strings.Contains(err.Error(), testCase.contains) {
+ t.Fatalf("error %q does not mention %q", err, testCase.contains)
+ }
+ })
+ }
+ if err := store.Put(context.Background(), valid); !errors.Is(err, ErrUnavailable) {
+ t.Fatalf("a valid snapshot with no pool must report unavailability, got %v", err)
+ }
+}
+
+func TestPutAcceptsEveryKnownCapability(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ for _, capability := range Capabilities() {
+ snapshot := Snapshot{AgentID: "agent-1", Capability: capability, ObservedAt: now, Payload: json.RawMessage(`{}`)}
+ if _, err := prepare(snapshot, now); err != nil {
+ t.Fatalf("capability %q rejected: %v", capability, err)
+ }
+ }
+}
+
+func TestCapacitySamplesAreBoundedAndCapabilityAware(t *testing.T) {
+ now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
+ snapshot := Snapshot{AgentID: "agent-1", Capability: CapabilityShares, ObservedAt: now, ReceivedAt: now, Payload: json.RawMessage(`{"shares":[{"id":"media","name":"Media","usedBytes":123,"sizeObservedAt":"2026-08-12T01:00:00Z"},{"id":"","name":"invalid","usedBytes":1}]}`)}
+ samples, err := capacitySamples(snapshot)
+ if err != nil || len(samples) != 1 || samples[0].Kind != "share" || samples[0].ID != "media" || samples[0].UsedBytes != 123 {
+ t.Fatalf("share capacity extraction failed: %+v, %v", samples, err)
+ }
+ snapshot.Capability = CapabilityHost
+ samples, err = capacitySamples(snapshot)
+ if err != nil || len(samples) != 0 {
+ t.Fatalf("non-capacity capability emitted samples: %+v, %v", samples, err)
+ }
+ snapshot.Capability = CapabilityPools
+ snapshot.Payload = json.RawMessage(`{"pools":[{"id":"cache","name":"Cache","usedBytes":"invalid","usableBytes":1000}]}`)
+ samples, err = capacitySamples(snapshot)
+ if err != nil || len(samples) != 0 {
+ t.Fatalf("malformed capacity values must fail closed: %+v, %v", samples, err)
+ }
+}
+
+func TestPrepareTakesReceivedAtFromStoreClock(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ // A hostile agent claims it was received in the future and observed just now.
+ row, err := prepare(Snapshot{
+ AgentID: "agent-1",
+ Capability: CapabilityProcesses,
+ ObservedAt: now.Add(-90 * time.Second),
+ ReceivedAt: now.Add(48 * time.Hour),
+ Payload: json.RawMessage(`{"processes":[]}`),
+ }, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !row.ReceivedAt.Equal(now) {
+ t.Fatalf("received at = %s, want the store clock %s", row.ReceivedAt, now)
+ }
+ if age := row.Age(now); age != 90*time.Second {
+ t.Fatalf("age = %s, want 90s", age)
+ }
+}
+
+func TestPrepareToleratesOnlyBenignClockSkew(t *testing.T) {
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ base := Snapshot{AgentID: "agent-1", Capability: CapabilityHost, Payload: json.RawMessage(`{}`)}
+ base.ObservedAt = now.Add(MaxClockSkew)
+ if _, err := prepare(base, now); err != nil {
+ t.Fatalf("skew within tolerance must be accepted: %v", err)
+ }
+ base.ObservedAt = now.Add(MaxClockSkew + time.Millisecond)
+ if _, err := prepare(base, now); err == nil {
+ t.Fatal("skew beyond tolerance must be rejected")
+ }
+ if MaxClockSkew >= 30*time.Second {
+ t.Fatal("clock skew tolerance must stay well below the tightest freshness window")
+ }
+}
+
+func TestPrepareNormalizesObservedAtToUTC(t *testing.T) {
+ zone := time.FixedZone("CEST", 2*60*60)
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ row, err := prepare(Snapshot{AgentID: "agent-1", Capability: CapabilityShares, ObservedAt: now.Add(-time.Minute).In(zone), Payload: json.RawMessage(`{}`)}, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if row.ObservedAt.Location() != time.UTC {
+ t.Fatalf("observed at location = %s, want UTC", row.ObservedAt.Location())
+ }
+}
+
+func TestLatestRejectsUnknownCapabilityBeforeTouchingTheDatabase(t *testing.T) {
+ store := PostgresStore{}
+ if _, err := store.Latest(context.Background(), "gpu"); err == nil || !strings.Contains(err.Error(), "unknown agent capability") {
+ t.Fatalf("unexpected error %v", err)
+ }
+ if _, err := store.Latest(context.Background(), CapabilityHost); !errors.Is(err, ErrUnavailable) {
+ t.Fatalf("unexpected error %v", err)
+ }
+}
+
+// stubRow stands in for one PostgreSQL row so the ErrNoSnapshot and decoding paths of
+// Latest can be exercised without a live database.
+type stubRow struct {
+ err error
+ values []any
+}
+
+func (r stubRow) Scan(dest ...any) error {
+ if r.err != nil {
+ return r.err
+ }
+ for index, target := range dest {
+ switch typed := target.(type) {
+ case *string:
+ *typed = r.values[index].(string)
+ case *time.Time:
+ *typed = r.values[index].(time.Time)
+ case *[]byte:
+ *typed = r.values[index].([]byte)
+ default:
+ return errors.New("unsupported destination")
+ }
+ }
+ return nil
+}
+
+func TestLatestReportsErrNoSnapshotWhenNothingWasRecorded(t *testing.T) {
+ if _, err := scanSnapshot(stubRow{err: pgx.ErrNoRows}); !errors.Is(err, ErrNoSnapshot) {
+ t.Fatalf("missing row must map to ErrNoSnapshot, got %v", err)
+ }
+ failure := errors.New("connection reset")
+ _, err := scanSnapshot(stubRow{err: failure})
+ if err == nil || errors.Is(err, ErrNoSnapshot) || !errors.Is(err, failure) {
+ t.Fatalf("a read failure must not look like an absent snapshot, got %v", err)
+ }
+}
+
+func TestLatestDecodesRowIntoUTCSnapshot(t *testing.T) {
+ zone := time.FixedZone("CEST", 2*60*60)
+ observed := time.Date(2026, 8, 4, 12, 0, 0, 0, zone)
+ received := observed.Add(time.Second)
+ snapshot, err := scanSnapshot(stubRow{values: []any{"agent-1", string(CapabilityDisks), observed, received, []byte(`{"disks":[]}`)}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.AgentID != "agent-1" || snapshot.Capability != CapabilityDisks {
+ t.Fatalf("unexpected identity %+v", snapshot)
+ }
+ if snapshot.ObservedAt.Location() != time.UTC || snapshot.ReceivedAt.Location() != time.UTC {
+ t.Fatalf("timestamps must be UTC: %+v", snapshot)
+ }
+ if string(snapshot.Payload) != `{"disks":[]}` {
+ t.Fatalf("unexpected payload %s", snapshot.Payload)
+ }
+}
+
+func TestStoreClockDefaultsToWallClockInUTC(t *testing.T) {
+ store := PostgresStore{}
+ if location := store.now().Location(); location != time.UTC {
+ t.Fatalf("store clock location = %s, want UTC", location)
+ }
+ fixed := time.Date(2026, 8, 4, 12, 0, 0, 0, time.FixedZone("CEST", 2*60*60))
+ if got := (PostgresStore{Clock: fixedClock(fixed)}).now(); got.Location() != time.UTC || !got.Equal(fixed) {
+ t.Fatalf("store clock = %s, want the injected instant in UTC", got)
+ }
+}
diff --git a/internal/alert/alerts.go b/internal/alert/alerts.go
new file mode 100644
index 0000000..6ff17de
--- /dev/null
+++ b/internal/alert/alerts.go
@@ -0,0 +1,104 @@
+package alert
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type Alert struct {
+ Instance
+ RuleName string `json:"ruleName"`
+ Severity string `json:"severity"`
+ EntityType string `json:"entityType,omitempty"`
+ EntityName string `json:"entityName,omitempty"`
+ Occurrences []Occurrence `json:"occurrences,omitempty"`
+}
+
+type AlertReader interface {
+ ListAlerts(context.Context, int, string) ([]Alert, error)
+ GetAlert(context.Context, string, int) (Alert, error)
+}
+
+func (r StateRepository) ListAlerts(ctx context.Context, limit int, state string) ([]Alert, error) {
+ if r.Pool == nil {
+ return nil, ErrUnavailable
+ }
+ if limit < 1 || limit > 100 {
+ return nil, errors.New("alert limit is invalid")
+ }
+ if state != "" && !validState(State(state)) {
+ return nil, errors.New("alert state filter is invalid")
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT i.id,i.rule_id,i.rule_version_id,i.fingerprint,COALESCE(i.entity_id::text,''),i.current_state,i.retained_state,i.active_since,i.recovery_since,i.cooldown_until,i.last_evaluated_at,i.last_known_at,i.last_value,i.reason,i.source_health,COALESCE(i.acknowledged_by,''),i.acknowledged_at,i.revision,i.created_at,i.updated_at,r.name,r.severity,COALESCE(e.entity_type,''),COALESCE(e.display_name,'') FROM alert_instances i JOIN alert_rules r ON r.id=i.rule_id LEFT JOIN entities e ON e.id=i.entity_id WHERE (($2='' AND i.current_state <> 'inactive') OR ($2<>'' AND i.current_state=$2)) ORDER BY CASE i.current_state WHEN 'firing' THEN 1 WHEN 'acknowledged' THEN 2 WHEN 'pending' THEN 3 WHEN 'unknown' THEN 4 WHEN 'resolved' THEN 5 ELSE 6 END,i.updated_at DESC,i.id ASC LIMIT $1`, limit, state)
+ if err != nil {
+ return nil, fmt.Errorf("list alerts: %w", err)
+ }
+ defer rows.Close()
+ items := make([]Alert, 0, limit)
+ for rows.Next() {
+ item, err := scanAlert(rows)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate alerts: %w", err)
+ }
+ return items, nil
+}
+
+func (r StateRepository) GetAlert(ctx context.Context, id string, occurrenceLimit int) (Alert, error) {
+ if r.Pool == nil {
+ return Alert{}, ErrUnavailable
+ }
+ if strings.TrimSpace(id) == "" {
+ return Alert{}, ErrInstanceNotFound
+ }
+ if occurrenceLimit < 1 || occurrenceLimit > 500 {
+ return Alert{}, errors.New("alert occurrence limit is invalid")
+ }
+ row := r.Pool.QueryRow(ctx, `SELECT i.id,i.rule_id,i.rule_version_id,i.fingerprint,COALESCE(i.entity_id::text,''),i.current_state,i.retained_state,i.active_since,i.recovery_since,i.cooldown_until,i.last_evaluated_at,i.last_known_at,i.last_value,i.reason,i.source_health,COALESCE(i.acknowledged_by,''),i.acknowledged_at,i.revision,i.created_at,i.updated_at,r.name,r.severity,COALESCE(e.entity_type,''),COALESCE(e.display_name,'') FROM alert_instances i JOIN alert_rules r ON r.id=i.rule_id LEFT JOIN entities e ON e.id=i.entity_id WHERE i.id=$1`, id)
+ item, err := scanAlert(row)
+ if errors.Is(err, ErrInstanceNotFound) || errors.Is(err, pgx.ErrNoRows) {
+ return Alert{}, ErrInstanceNotFound
+ }
+ if err != nil {
+ return Alert{}, fmt.Errorf("get alert: %w", err)
+ }
+ occurrences, err := r.ListOccurrences(ctx, id, occurrenceLimit)
+ if err != nil {
+ return Alert{}, err
+ }
+ item.Occurrences = occurrences
+ return item, nil
+}
+
+type alertRow interface{ Scan(...any) error }
+
+func scanAlert(row alertRow) (Alert, error) {
+ var item Alert
+ var valueJSON, healthJSON []byte
+ var state, retained State
+ err := row.Scan(&item.ID, &item.RuleID, &item.RuleVersionID, &item.Fingerprint, &item.EntityID, &state, &retained, &item.ActiveSince, &item.RecoverySince, &item.CooldownUntil, &item.LastEvaluatedAt, &item.LastKnownAt, &valueJSON, &item.Reason, &healthJSON, &item.AcknowledgedBy, &item.AcknowledgedAt, &item.Revision, &item.CreatedAt, &item.UpdatedAt, &item.RuleName, &item.Severity, &item.EntityType, &item.EntityName)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Alert{}, ErrInstanceNotFound
+ }
+ if err != nil {
+ return Alert{}, fmt.Errorf("scan alert: %w", err)
+ }
+ item.State, item.RetainedState = state, retained
+ item.LastValue, err = decodeJSON(valueJSON)
+ if err != nil {
+ return Alert{}, fmt.Errorf("decode alert value: %w", err)
+ }
+ item.SourceHealth, err = decodeMap(healthJSON)
+ if err != nil {
+ return Alert{}, fmt.Errorf("decode alert source health: %w", err)
+ }
+ return item, nil
+}
diff --git a/internal/alert/grouping.go b/internal/alert/grouping.go
new file mode 100644
index 0000000..8507d0d
--- /dev/null
+++ b/internal/alert/grouping.go
@@ -0,0 +1,230 @@
+package alert
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+)
+
+const (
+ MaxFingerprintLabels = 10
+ MaxAlertGroups = 1000
+ MaxSignalsPerGroup = 500
+)
+
+var ErrInvalidAlertIdentity = errors.New("invalid alert identity")
+var alertLabelPattern = regexp.MustCompile("^[A-Za-z0-9_.:/-]+$")
+
+type Signal struct {
+ InstanceID string
+ RuleID string
+ RuleVersionID string
+ EntityID string
+ Severity string
+ State State
+ Fingerprint string
+ EvaluationKey string
+ ObservedAt time.Time
+ Labels map[string]string
+ GroupBy []string
+ SuppressWhen []string
+}
+
+type Group struct {
+ Key string
+ Severity string
+ Labels map[string]string
+ Signals []Signal
+}
+
+type Cause struct {
+ Key string
+ State State
+ Confirmed bool
+ Confidence float64
+ ObservedAt time.Time
+}
+
+type SuppressionDecision struct {
+ Suppressed bool `json:"suppressed"`
+ CauseKey string `json:"causeKey,omitempty"`
+ Reason string `json:"reason"`
+}
+
+func BuildFingerprint(ruleID, ruleVersionID, entityID string, labels map[string]string) (string, error) {
+ if strings.TrimSpace(ruleID) == "" || strings.TrimSpace(ruleVersionID) == "" || strings.TrimSpace(entityID) == "" || validateLabel(ruleID, 160) != nil || validateLabel(ruleVersionID, 160) != nil || validateLabel(entityID, 160) != nil {
+ return "", ErrInvalidAlertIdentity
+ }
+ canonical, err := canonicalLabels(labels, MaxFingerprintLabels)
+ if err != nil {
+ return "", err
+ }
+ value := "rule=" + ruleID + "\x00version=" + ruleVersionID + "\x00entity=" + entityID + "\x00" + canonical
+ digest := sha256.Sum256([]byte(value))
+ return hex.EncodeToString(digest[:]), nil
+}
+
+func GroupSignals(signals []Signal) ([]Group, error) {
+ groups := make(map[string]*Group)
+ for _, signal := range signals {
+ if err := validateSignal(signal); err != nil {
+ return nil, err
+ }
+ key, labels, err := groupKey(signal)
+ if err != nil {
+ return nil, err
+ }
+ group := groups[key]
+ if group == nil {
+ if len(groups) >= MaxAlertGroups {
+ return nil, fmt.Errorf("%w: too many alert groups", ErrInvalidAlertIdentity)
+ }
+ group = &Group{Key: key, Severity: signal.Severity, Labels: labels}
+ groups[key] = group
+ }
+ if len(group.Signals) >= MaxSignalsPerGroup {
+ return nil, fmt.Errorf("%w: too many signals in group", ErrInvalidAlertIdentity)
+ }
+ group.Signals = append(group.Signals, signal)
+ }
+ result := make([]Group, 0, len(groups))
+ for _, group := range groups {
+ sort.SliceStable(group.Signals, func(i, j int) bool { return signalSortKey(group.Signals[i]) < signalSortKey(group.Signals[j]) })
+ result = append(result, *group)
+ }
+ sort.SliceStable(result, func(i, j int) bool { return result[i].Key < result[j].Key })
+ return result, nil
+}
+
+func DeduplicateSignals(signals []Signal) ([]Signal, error) {
+ byKey := make(map[string]Signal, len(signals))
+ for _, signal := range signals {
+ if err := validateSignal(signal); err != nil {
+ return nil, err
+ }
+ key := signal.InstanceID + "\x00" + signal.EvaluationKey
+ if previous, exists := byKey[key]; !exists || signalSortKey(signal) > signalSortKey(previous) {
+ byKey[key] = signal
+ }
+ }
+ result := make([]Signal, 0, len(byKey))
+ for _, signal := range byKey {
+ result = append(result, signal)
+ }
+ sort.SliceStable(result, func(i, j int) bool { return signalSortKey(result[i]) < signalSortKey(result[j]) })
+ return result, nil
+}
+
+func EvaluateSuppression(signal Signal, causes []Cause) (SuppressionDecision, error) {
+ if err := validateSignal(signal); err != nil {
+ return SuppressionDecision{}, err
+ }
+ if signal.State != StatePending && signal.State != StateFiring && signal.State != StateAcknowledged && signal.State != StateUnknown {
+ return SuppressionDecision{Reason: "alert_not_active"}, nil
+ }
+ wanted := make(map[string]struct{}, len(signal.SuppressWhen))
+ for _, key := range signal.SuppressWhen {
+ if err := validateLabel(key, 160); err != nil {
+ return SuppressionDecision{}, err
+ }
+ wanted[key] = struct{}{}
+ }
+ ordered := append([]Cause(nil), causes...)
+ sort.SliceStable(ordered, func(i, j int) bool { return causeSortKey(ordered[i]) < causeSortKey(ordered[j]) })
+ for _, cause := range ordered {
+ if _, ok := wanted[cause.Key]; !ok || !causeActive(cause) {
+ continue
+ }
+ if !cause.Confirmed && cause.Confidence < .75 {
+ continue
+ }
+ reason := "dependency_failure"
+ if strings.HasPrefix(cause.Key, "source.") {
+ reason = "source_outage"
+ }
+ return SuppressionDecision{Suppressed: true, CauseKey: cause.Key, Reason: reason}, nil
+ }
+ return SuppressionDecision{Reason: "no_active_suppression_cause"}, nil
+}
+
+func groupKey(signal Signal) (string, map[string]string, error) {
+ labels := make(map[string]string, len(signal.GroupBy))
+ for _, key := range signal.GroupBy {
+ if err := validateLabel(key, 80); err != nil {
+ return "", nil, err
+ }
+ if value, ok := signal.Labels[key]; ok {
+ if err := validateLabel(value, 160); err != nil {
+ return "", nil, err
+ }
+ labels[key] = value
+ }
+ }
+ canonical, err := canonicalLabels(labels, MaxFingerprintLabels)
+ if err != nil {
+ return "", nil, err
+ }
+ return signal.RuleID + "|" + signal.Severity + "|" + canonical, labels, nil
+}
+
+func validateSignal(signal Signal) error {
+ if signal.InstanceID == "" || signal.RuleID == "" || signal.RuleVersionID == "" || signal.EvaluationKey == "" || signal.Severity == "" || !validState(signal.State) || validateLabel(signal.InstanceID, 160) != nil || validateLabel(signal.RuleID, 160) != nil || validateLabel(signal.RuleVersionID, 160) != nil || validateLabel(signal.EvaluationKey, 160) != nil || validateLabel(signal.Severity, 40) != nil {
+ return ErrInvalidAlertIdentity
+ }
+ if len(signal.GroupBy) > MaxFingerprintLabels || len(signal.Labels) > MaxFingerprintLabels {
+ return fmt.Errorf("%w: label cardinality exceeds limit", ErrInvalidAlertIdentity)
+ }
+ for key, value := range signal.Labels {
+ if err := validateLabel(key, 80); err != nil {
+ return err
+ }
+ if err := validateLabel(value, 160); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+func canonicalLabels(labels map[string]string, max int) (string, error) {
+ if len(labels) > max {
+ return "", fmt.Errorf("%w: too many labels", ErrInvalidAlertIdentity)
+ }
+ keys := make([]string, 0, len(labels))
+ for key, value := range labels {
+ if err := validateLabel(key, 80); err != nil {
+ return "", err
+ }
+ if err := validateLabel(value, 160); err != nil {
+ return "", err
+ }
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ parts := make([]string, 0, len(keys))
+ for _, key := range keys {
+ parts = append(parts, key+"="+labels[key])
+ }
+ return strings.Join(parts, "\x00"), nil
+}
+
+func validateLabel(value string, max int) error {
+ if value == "" || len(value) > max || strings.ContainsAny(value, "\r\n\x00") || !alertLabelPattern.MatchString(value) {
+ return ErrInvalidAlertIdentity
+ }
+ return nil
+}
+
+func signalSortKey(signal Signal) string {
+ return signal.InstanceID + "|" + signal.EvaluationKey + "|" + signal.ObservedAt.UTC().Format(time.RFC3339Nano)
+}
+func causeSortKey(cause Cause) string {
+ return cause.Key + "|" + string(cause.State) + "|" + cause.ObservedAt.UTC().Format(time.RFC3339Nano)
+}
+
+func causeActive(cause Cause) bool {
+ return cause.State == StateFiring || cause.State == StateAcknowledged || (cause.State == StateUnknown && cause.Confirmed)
+}
diff --git a/internal/alert/grouping_test.go b/internal/alert/grouping_test.go
new file mode 100644
index 0000000..10d358b
--- /dev/null
+++ b/internal/alert/grouping_test.go
@@ -0,0 +1,100 @@
+package alert
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+func signal(id, rule, evaluation string, state State, labels map[string]string) Signal {
+ return Signal{InstanceID: id, RuleID: rule, RuleVersionID: "version-1", Severity: SeverityDegraded, State: state, EvaluationKey: evaluation, ObservedAt: time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC), Labels: labels, GroupBy: []string{"host", "application"}, SuppressWhen: []string{"host.unreachable", "dns.failure", "source.unavailable"}}
+}
+
+func TestBuildFingerprintIsStableAndIncludesRuleBehavior(t *testing.T) {
+ first, err := BuildFingerprint("rule-1", "version-1", "entity-1", map[string]string{"application": "media", "host": "pulse"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := BuildFingerprint("rule-1", "version-1", "entity-1", map[string]string{"host": "pulse", "application": "media"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first != second || len(first) != 64 {
+ t.Fatalf("fingerprint instability: %q %q", first, second)
+ }
+ changedVersion, err := BuildFingerprint("rule-1", "version-2", "entity-1", map[string]string{"host": "pulse", "application": "media"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if changedVersion == first {
+ t.Fatal("rule version did not affect fingerprint")
+ }
+ tooMany := make(map[string]string, MaxFingerprintLabels+1)
+ for i := 0; i <= MaxFingerprintLabels; i++ {
+ tooMany["label"+string(rune('a'+i))] = "value"
+ }
+ if _, err := BuildFingerprint("rule-1", "version-1", "entity-1", tooMany); !errors.Is(err, ErrInvalidAlertIdentity) {
+ t.Fatalf("too many labels error = %v", err)
+ }
+}
+
+func TestGroupAndDeduplicateSignalsAreDeterministic(t *testing.T) {
+ inputs := []Signal{
+ signal("instance-b", "rule-1", "slot-1", StateFiring, map[string]string{"host": "pulse", "application": "media"}),
+ signal("instance-a", "rule-1", "slot-1", StateFiring, map[string]string{"host": "pulse", "application": "media"}),
+ signal("instance-a", "rule-1", "slot-1", StateFiring, map[string]string{"host": "pulse", "application": "media"}),
+ signal("instance-c", "rule-1", "slot-1", StateFiring, map[string]string{"host": "other", "application": "media"}),
+ }
+ deduplicated, err := DeduplicateSignals(inputs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(deduplicated) != 3 {
+ t.Fatalf("deduplicated signals = %d, want 3", len(deduplicated))
+ }
+ groups, err := GroupSignals(deduplicated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(groups) != 2 || len(groups[0].Signals) != 1 || len(groups[1].Signals) != 2 {
+ t.Fatalf("unexpected groups: %#v", groups)
+ }
+ if groups[0].Key > groups[1].Key {
+ t.Fatal("groups are not sorted")
+ }
+}
+
+func TestSuppressionScenariosRemainInspectablyBounded(t *testing.T) {
+ base := signal("instance-1", "rule-service", "slot-1", StateFiring, map[string]string{"host": "pulse", "application": "web"})
+ tests := []struct {
+ name string
+ cause Cause
+ want bool
+ reason string
+ }{
+ {name: "host outage", cause: Cause{Key: "host.unreachable", State: StateFiring, Confirmed: true}, want: true, reason: "dependency_failure"},
+ {name: "dns outage", cause: Cause{Key: "dns.failure", State: StateAcknowledged, Confidence: .9}, want: true, reason: "dependency_failure"},
+ {name: "source outage", cause: Cause{Key: "source.unavailable", State: StateUnknown, Confirmed: true}, want: true, reason: "source_outage"},
+ {name: "low confidence", cause: Cause{Key: "host.unreachable", State: StateFiring, Confidence: .5}, want: false, reason: "no_active_suppression_cause"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ decision, err := EvaluateSuppression(base, []Cause{test.cause})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if decision.Suppressed != test.want || decision.Reason != test.reason {
+ t.Fatalf("decision = %#v, want suppressed=%v reason=%s", decision, test.want, test.reason)
+ }
+ })
+ }
+ resolved := base
+ resolved.State = StateResolved
+ decision, err := EvaluateSuppression(resolved, []Cause{{Key: "host.unreachable", State: StateFiring, Confirmed: true}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if decision.Suppressed {
+ t.Fatal("resolved alert was suppressed")
+ }
+}
diff --git a/internal/alert/operations.go b/internal/alert/operations.go
new file mode 100644
index 0000000..f2de606
--- /dev/null
+++ b/internal/alert/operations.go
@@ -0,0 +1,99 @@
+package alert
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func Unacknowledge(current Snapshot, actor string, at time.Time) (TransitionResult, error) {
+ current = current.normalized()
+ if actor == "" || len(actor) > 160 || at.IsZero() {
+ return TransitionResult{}, ErrInvalidObservation
+ }
+ if current.State != StateAcknowledged {
+ return TransitionResult{}, ErrStateConflict
+ }
+ result := current
+ result.State = StateFiring
+ result.RetainedState = StateFiring
+ result.AcknowledgedBy = ""
+ result.AcknowledgedAt = nil
+ result.Reason = "unacknowledged"
+ return finish(current, result, StateFiring, "unacknowledge"), nil
+}
+
+func (r StateRepository) AcknowledgeRevision(ctx context.Context, instanceID, actor, evaluationKey string, at time.Time, expectedRevision int64) (Instance, Occurrence, bool, error) {
+ return r.applyOperation(ctx, instanceID, actor, evaluationKey, at, expectedRevision, true)
+}
+
+func (r StateRepository) Unacknowledge(ctx context.Context, instanceID, actor, evaluationKey string, at time.Time, expectedRevision int64) (Instance, Occurrence, bool, error) {
+ return r.applyOperation(ctx, instanceID, actor, evaluationKey, at, expectedRevision, false)
+}
+
+func (r StateRepository) applyOperation(ctx context.Context, instanceID, actor, evaluationKey string, at time.Time, expectedRevision int64, acknowledge bool) (Instance, Occurrence, bool, error) {
+ if r.Pool == nil {
+ return Instance{}, Occurrence{}, false, ErrUnavailable
+ }
+ if instanceID == "" || actor == "" || len(actor) > 160 || evaluationKey == "" || len(evaluationKey) > 160 || at.IsZero() || expectedRevision < 1 {
+ return Instance{}, Occurrence{}, false, ErrInvalidObservation
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Instance{}, Occurrence{}, false, fmt.Errorf("begin alert operation: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var current Instance
+ if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1 FOR UPDATE`, instanceID), ¤t); errors.Is(err, ErrInstanceNotFound) {
+ return Instance{}, Occurrence{}, false, ErrInstanceNotFound
+ } else if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if occurrence, err := scanOccurrence(tx.QueryRow(ctx, `SELECT id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at FROM alert_occurrences WHERE instance_id=$1 AND evaluation_key=$2`, instanceID, evaluationKey)); err == nil {
+ if err := tx.Commit(ctx); err != nil {
+ return Instance{}, Occurrence{}, false, fmt.Errorf("commit idempotent alert operation: %w", err)
+ }
+ return current, occurrence, true, nil
+ } else if !errors.Is(err, pgx.ErrNoRows) {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if current.Revision != expectedRevision {
+ return Instance{}, Occurrence{}, false, ErrRevisionConflict
+ }
+ var transition TransitionResult
+ if acknowledge {
+ transition, err = Acknowledge(snapshotFromInstance(current), actor, at.UTC())
+ } else {
+ transition, err = Unacknowledge(snapshotFromInstance(current), actor, at.UTC())
+ }
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ valueJSON, err := boundedJSON(current.LastValue, 128<<10)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ healthJSON, err := boundedJSON(nonNilMap(current.SourceHealth), 64<<10)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ acknowledgedBy := nullableText(transition.Snapshot.AcknowledgedBy)
+ acknowledgedAt := transition.Snapshot.AcknowledgedAt
+ if _, err := tx.Exec(ctx, `UPDATE alert_instances SET current_state=$1,retained_state=$2,reason=$3,acknowledged_by=$4,acknowledged_at=$5,revision=revision+1,updated_at=now() WHERE id=$6 AND revision=$7`, transition.Snapshot.State, transition.Snapshot.RetainedState, transition.Snapshot.Reason, acknowledgedBy, acknowledgedAt, instanceID, expectedRevision); err != nil {
+ return Instance{}, Occurrence{}, false, mapStateError(fmt.Errorf("update alert operation: %w", err))
+ }
+ occurrence, err := insertOccurrence(ctx, tx, instanceID, evaluationKey, transition, Observation{ObservedAt: at.UTC(), Reason: transition.Snapshot.Reason, Value: current.LastValue}, valueJSON, healthJSON)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1`, instanceID), ¤t); err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return Instance{}, Occurrence{}, false, fmt.Errorf("commit alert operation: %w", err)
+ }
+ return current, occurrence, false, nil
+}
diff --git a/internal/alert/operations_integration_test.go b/internal/alert/operations_integration_test.go
new file mode 100644
index 0000000..8612ccc
--- /dev/null
+++ b/internal/alert/operations_integration_test.go
@@ -0,0 +1,71 @@
+package alert
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func TestPostgreSQLAlertOperationsAreRevisionSafeAndRestartable(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ document, registry := validDocument(t)
+ document.Enabled = true
+ rules := Repository{Pool: pool, Registry: registry}
+ created, version, err := rules.Create(ctx, "operations-integration", document, "operations test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := StateRepository{Pool: pool}
+ base := time.Date(2026, time.January, 4, 12, 0, 0, 0, time.UTC)
+ policy := Policy{PendingSeconds: 0, ResolveSeconds: 0, UnknownBehavior: UnknownRetain}
+ firing, _, _, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "operations:test", Policy: policy, Observation: observation(base, "evaluation-1", true)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if firing.State != StateFiring {
+ t.Fatalf("state = %s", firing.State)
+ }
+ ack, occurrence, duplicate, err := store.AcknowledgeRevision(ctx, firing.ID, "operator", "ack-operation-1", base.Add(time.Second), firing.Revision)
+ if err != nil || duplicate || ack.State != StateAcknowledged || occurrence.EventType != "acknowledge" {
+ t.Fatalf("ack result=%#v occurrence=%#v duplicate=%v err=%v", ack, occurrence, duplicate, err)
+ }
+ retry, _, duplicate, err := store.AcknowledgeRevision(ctx, firing.ID, "operator", "ack-operation-1", base.Add(time.Second), firing.Revision)
+ if err != nil || !duplicate || retry.Revision != ack.Revision {
+ t.Fatalf("ack retry=%#v duplicate=%v err=%v", retry, duplicate, err)
+ }
+ restarted := StateRepository{Pool: pool}
+ persisted, err := restarted.GetInstance(ctx, firing.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if persisted.State != StateAcknowledged || persisted.AcknowledgedBy != "operator" {
+ t.Fatalf("ack did not survive restart: %#v", persisted)
+ }
+ unack, occurrence, duplicate, err := restarted.Unacknowledge(ctx, firing.ID, "operator", "unack-operation-1", base.Add(2*time.Second), ack.Revision)
+ if err != nil || duplicate || unack.State != StateFiring || unack.AcknowledgedBy != "" || occurrence.EventType != "unacknowledge" {
+ t.Fatalf("unack result=%#v occurrence=%#v duplicate=%v err=%v", unack, occurrence, duplicate, err)
+ }
+ resolved, _, _, err := restarted.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "operations:test", Policy: policy, Observation: observation(base.Add(3*time.Second), "evaluation-2", false)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolved.State != StateResolved {
+ t.Fatalf("resolved state was lost after unacknowledge: %#v", resolved)
+ }
+}
diff --git a/internal/alert/operations_test.go b/internal/alert/operations_test.go
new file mode 100644
index 0000000..c9fda7e
--- /dev/null
+++ b/internal/alert/operations_test.go
@@ -0,0 +1,26 @@
+package alert
+
+import (
+ "testing"
+ "time"
+)
+
+func TestUnacknowledgeReturnsFiringAndClearsActor(t *testing.T) {
+ at := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
+ result, err := Unacknowledge(Snapshot{State: StateAcknowledged, RetainedState: StateAcknowledged, AcknowledgedBy: "operator", AcknowledgedAt: &at, LastValue: 90}, "operator", at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.To != StateFiring || result.Snapshot.AcknowledgedBy != "" || result.Snapshot.AcknowledgedAt != nil || result.EventType != "unacknowledge" {
+ t.Fatalf("unexpected unacknowledge result: %#v", result)
+ }
+}
+
+func TestUnacknowledgeRejectsResolvedAndInactive(t *testing.T) {
+ at := time.Now().UTC()
+ for _, state := range []State{StateInactive, StatePending, StateFiring, StateResolved, StateUnknown} {
+ if _, err := Unacknowledge(Snapshot{State: state}, "operator", at); err != ErrStateConflict {
+ t.Fatalf("state %s error = %v", state, err)
+ }
+ }
+}
diff --git a/internal/alert/repository.go b/internal/alert/repository.go
new file mode 100644
index 0000000..2900e9d
--- /dev/null
+++ b/internal/alert/repository.go
@@ -0,0 +1,357 @@
+package alert
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/itworx/pulse/internal/metriccatalog"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "reflect"
+)
+
+type Store interface {
+ Create(context.Context, string, Document, string) (Rule, Version, error)
+ Get(context.Context, string) (Rule, error)
+ List(context.Context, int) ([]Rule, error)
+ Update(context.Context, string, string, int64, Document, string) (Rule, error)
+ Versions(context.Context, string, int) ([]Version, error)
+ SetEnabled(context.Context, string, int64, bool) (Rule, error)
+}
+
+type Repository struct {
+ Pool *pgxpool.Pool
+ Registry metriccatalog.Registry
+}
+
+func (r Repository) Create(ctx context.Context, actor string, document Document, changeSummary string) (Rule, Version, error) {
+ if r.Pool == nil {
+ return Rule{}, Version{}, ErrUnavailable
+ }
+ if err := document.Validate(r.Registry); err != nil {
+ return Rule{}, Version{}, err
+ }
+ if changeSummary == "" {
+ changeSummary = "initial version"
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Rule{}, Version{}, fmt.Errorf("begin alert rule create: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ docJSON, err := document.MarshalCanonical()
+ if err != nil {
+ return Rule{}, Version{}, fmt.Errorf("marshal alert rule: %w", err)
+ }
+ conditionJSON, _ := json.Marshal(document.Condition)
+ scopeJSON, _ := json.Marshal(nonNilMap(document.Scope))
+ groupJSON, _ := json.Marshal(nonNilStrings(document.GroupBy))
+ suppressJSON, _ := json.Marshal(nonNilStrings(document.SuppressWhen))
+ messageJSON, _ := json.Marshal(document.Message)
+ if _, err = tx.Exec(ctx, `INSERT INTO alert_rules (id,schema_version,name,enabled,severity,scope,condition,evaluation_interval_seconds,pending_seconds,resolve_seconds,cooldown_seconds,unknown_behavior,group_by,suppress_when,message,revision,created_by) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8,$9,$10,$11,$12,$13::jsonb,$14::jsonb,$15::jsonb,1,(SELECT id FROM users WHERE external_subject=$16))`, document.ID, document.SchemaVersion, document.Name, document.Enabled, document.Severity, scopeJSON, conditionJSON, document.EvaluationIntervalSeconds, document.PendingSeconds, document.ResolveSeconds, document.CooldownSeconds, document.UnknownBehavior, groupJSON, suppressJSON, messageJSON, actor); err != nil {
+ return Rule{}, Version{}, mapError(fmt.Errorf("create alert rule: %w", err))
+ }
+ versionID := NewID()
+ if _, err = tx.Exec(ctx, `INSERT INTO alert_rule_versions (id,rule_id,version_number,document,change_summary,created_by) VALUES ($1,$2,1,$3::jsonb,$4,(SELECT id FROM users WHERE external_subject=$5))`, versionID, document.ID, docJSON, changeSummary, actor); err != nil {
+ return Rule{}, Version{}, mapError(fmt.Errorf("create alert rule version: %w", err))
+ }
+ if _, err = tx.Exec(ctx, `UPDATE alert_rules SET current_version_id=$1 WHERE id=$2`, versionID, document.ID); err != nil {
+ return Rule{}, Version{}, fmt.Errorf("set current alert rule version: %w", err)
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return Rule{}, Version{}, fmt.Errorf("commit alert rule create: %w", err)
+ }
+ rule, err := r.Get(ctx, document.ID)
+ if err != nil {
+ return Rule{}, Version{}, err
+ }
+ versions, err := r.Versions(ctx, document.ID, 1)
+ if err != nil || len(versions) == 0 {
+ return Rule{}, Version{}, err
+ }
+ return rule, versions[0], nil
+}
+
+func (r Repository) Get(ctx context.Context, id string) (Rule, error) {
+ if r.Pool == nil {
+ return Rule{}, ErrUnavailable
+ }
+ var rule Rule
+ var scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON []byte
+ var createdBy *string
+ err := r.Pool.QueryRow(ctx, `SELECT r.id,r.schema_version,r.name,r.enabled,r.severity,r.scope,r.condition,r.evaluation_interval_seconds,r.pending_seconds,r.resolve_seconds,r.cooldown_seconds,r.unknown_behavior,r.group_by,r.suppress_when,r.message,r.revision,v.version_number,COALESCE(u.external_subject,''),r.created_at,r.updated_at FROM alert_rules r JOIN alert_rule_versions v ON v.id=r.current_version_id LEFT JOIN users u ON u.id=r.created_by WHERE r.id=$1`, id).Scan(&rule.ID, &rule.SchemaVersion, &rule.Name, &rule.Enabled, &rule.Severity, &scopeJSON, &conditionJSON, &rule.EvaluationIntervalSeconds, &rule.PendingSeconds, &rule.ResolveSeconds, &rule.CooldownSeconds, &rule.UnknownBehavior, &groupJSON, &suppressJSON, &messageJSON, &rule.Revision, &rule.CurrentVersion, &createdBy, &rule.CreatedAt, &rule.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Rule{}, ErrNotFound
+ }
+ if err != nil {
+ return Rule{}, fmt.Errorf("get alert rule: %w", err)
+ }
+ rule.CreatedBy = valueOrEmpty(createdBy)
+ if err := decodeStored(&rule.Document, scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON, rule.CooldownSeconds); err != nil {
+ return Rule{}, err
+ }
+ return rule, nil
+}
+
+func (r Repository) List(ctx context.Context, limit int) ([]Rule, error) {
+ if r.Pool == nil {
+ return nil, ErrUnavailable
+ }
+ if limit < 1 || limit > 100 {
+ return nil, errors.New("alert rule limit is invalid")
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT r.id,r.schema_version,r.name,r.enabled,r.severity,r.scope,r.condition,r.evaluation_interval_seconds,r.pending_seconds,r.resolve_seconds,r.cooldown_seconds,r.unknown_behavior,r.group_by,r.suppress_when,r.message,r.revision,v.version_number,COALESCE(u.external_subject,''),r.created_at,r.updated_at FROM alert_rules r JOIN alert_rule_versions v ON v.id=r.current_version_id LEFT JOIN users u ON u.id=r.created_by ORDER BY r.name ASC,r.id ASC LIMIT $1`, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list alert rules: %w", err)
+ }
+ defer rows.Close()
+ result := make([]Rule, 0, limit)
+ for rows.Next() {
+ var rule Rule
+ var scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON []byte
+ if err := rows.Scan(&rule.ID, &rule.SchemaVersion, &rule.Name, &rule.Enabled, &rule.Severity, &scopeJSON, &conditionJSON, &rule.EvaluationIntervalSeconds, &rule.PendingSeconds, &rule.ResolveSeconds, &rule.CooldownSeconds, &rule.UnknownBehavior, &groupJSON, &suppressJSON, &messageJSON, &rule.Revision, &rule.CurrentVersion, &rule.CreatedBy, &rule.CreatedAt, &rule.UpdatedAt); err != nil {
+ return nil, fmt.Errorf("scan alert rule: %w", err)
+ }
+ if err := decodeStored(&rule.Document, scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON, rule.CooldownSeconds); err != nil {
+ return nil, err
+ }
+ result = append(result, rule)
+ }
+ return result, rows.Err()
+}
+
+func (r Repository) Update(ctx context.Context, id, actor string, expected int64, document Document, changeSummary string) (Rule, error) {
+ if r.Pool == nil {
+ return Rule{}, ErrUnavailable
+ }
+ document.ID = id
+ if err := document.Validate(r.Registry); err != nil {
+ return Rule{}, err
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Rule{}, fmt.Errorf("begin alert rule update: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var currentRevision int64
+ var currentVersionID string
+ var currentJSON []byte
+ err = tx.QueryRow(ctx, `SELECT revision,current_version_id FROM alert_rules WHERE id=$1 FOR UPDATE`, id).Scan(¤tRevision, ¤tVersionID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Rule{}, ErrNotFound
+ }
+ if err != nil {
+ return Rule{}, fmt.Errorf("lock alert rule: %w", err)
+ }
+ if currentRevision != expected {
+ return Rule{}, ErrConflict
+ }
+ if err = tx.QueryRow(ctx, `SELECT document FROM alert_rule_versions WHERE id=$1`, currentVersionID).Scan(¤tJSON); err != nil {
+ return Rule{}, fmt.Errorf("read current alert rule version: %w", err)
+ }
+ nextJSON, err := document.MarshalCanonical()
+ if err != nil {
+ return Rule{}, err
+ }
+ if sameJSON(currentJSON, nextJSON) {
+ if err := tx.Commit(ctx); err != nil {
+ return Rule{}, err
+ }
+ return r.Get(ctx, id)
+ }
+ var currentVersion int
+ if err := tx.QueryRow(ctx, `SELECT version_number FROM alert_rule_versions WHERE id=$1`, currentVersionID).Scan(¤tVersion); err != nil {
+ return Rule{}, err
+ }
+ if changeSummary == "" {
+ changeSummary = "rule update"
+ }
+ versionID := NewID()
+ if _, err = tx.Exec(ctx, `INSERT INTO alert_rule_versions (id,rule_id,version_number,document,change_summary,created_by) VALUES ($1,$2,$3,$4::jsonb,$5,(SELECT id FROM users WHERE external_subject=$6))`, versionID, id, currentVersion+1, nextJSON, changeSummary, actor); err != nil {
+ return Rule{}, mapError(err)
+ }
+ conditionJSON, _ := json.Marshal(document.Condition)
+ scopeJSON, _ := json.Marshal(nonNilMap(document.Scope))
+ groupJSON, _ := json.Marshal(nonNilStrings(document.GroupBy))
+ suppressJSON, _ := json.Marshal(nonNilStrings(document.SuppressWhen))
+ messageJSON, _ := json.Marshal(document.Message)
+ tag, err := tx.Exec(ctx, `UPDATE alert_rules SET schema_version=$1,name=$2,enabled=$3,severity=$4,scope=$5::jsonb,condition=$6::jsonb,evaluation_interval_seconds=$7,pending_seconds=$8,resolve_seconds=$9,cooldown_seconds=$10,unknown_behavior=$11,group_by=$12::jsonb,suppress_when=$13::jsonb,message=$14::jsonb,current_version_id=$15,revision=revision+1,updated_at=now() WHERE id=$16 AND revision=$17`, document.SchemaVersion, document.Name, document.Enabled, document.Severity, scopeJSON, conditionJSON, document.EvaluationIntervalSeconds, document.PendingSeconds, document.ResolveSeconds, document.CooldownSeconds, document.UnknownBehavior, groupJSON, suppressJSON, messageJSON, versionID, id, expected)
+ if err != nil {
+ return Rule{}, mapError(err)
+ }
+ if tag.RowsAffected() != 1 {
+ return Rule{}, ErrConflict
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return Rule{}, fmt.Errorf("commit alert rule update: %w", err)
+ }
+ return r.Get(ctx, id)
+}
+
+func (r Repository) Versions(ctx context.Context, id string, limit int) ([]Version, error) {
+ if r.Pool == nil {
+ return nil, ErrUnavailable
+ }
+ if limit < 1 || limit > 100 {
+ return nil, errors.New("version limit is invalid")
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT v.id,v.rule_id,v.version_number,v.document,v.change_summary,COALESCE(u.external_subject,''),v.created_at FROM alert_rule_versions v LEFT JOIN users u ON u.id=v.created_by WHERE v.rule_id=$1 ORDER BY v.version_number DESC,v.id ASC LIMIT $2`, id, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list alert rule versions: %w", err)
+ }
+ defer rows.Close()
+ result := make([]Version, 0, limit)
+ for rows.Next() {
+ var version Version
+ var raw []byte
+ if err := rows.Scan(&version.ID, &version.RuleID, &version.VersionNumber, &raw, &version.ChangeSummary, &version.CreatedBy, &version.CreatedAt); err != nil {
+ return nil, fmt.Errorf("scan alert rule version: %w", err)
+ }
+ var document Document
+ if _, err := DecodeDocument(raw, r.Registry); err != nil {
+ return nil, fmt.Errorf("decode stored alert rule version: %w", err)
+ } else {
+ document = mustDecode(raw)
+ }
+ version.Document = document
+ result = append(result, version)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if len(result) == 0 {
+ var exists bool
+ if err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM alert_rules WHERE id=$1)`, id).Scan(&exists); err != nil {
+ return nil, err
+ }
+ if !exists {
+ return nil, ErrNotFound
+ }
+ }
+ return result, nil
+}
+
+func (r Repository) SetEnabled(ctx context.Context, id string, expected int64, enabled bool) (Rule, error) {
+ if r.Pool == nil {
+ return Rule{}, ErrUnavailable
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Rule{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var revision int64
+ var current bool
+ if err := tx.QueryRow(ctx, `SELECT revision,enabled FROM alert_rules WHERE id=$1 FOR UPDATE`, id).Scan(&revision, ¤t); errors.Is(err, pgx.ErrNoRows) {
+ return Rule{}, ErrNotFound
+ } else if err != nil {
+ return Rule{}, err
+ }
+ if revision != expected {
+ return Rule{}, ErrConflict
+ }
+ if current == enabled {
+ if err := tx.Commit(ctx); err != nil {
+ return Rule{}, err
+ }
+ return r.Get(ctx, id)
+ }
+ if _, err := tx.Exec(ctx, `UPDATE alert_rules SET enabled=$1,revision=revision+1,updated_at=now() WHERE id=$2 AND revision=$3`, enabled, id, expected); err != nil {
+ return Rule{}, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return Rule{}, err
+ }
+ return r.Get(ctx, id)
+}
+
+func (r Repository) ListEnabled(ctx context.Context, limit int) ([]Rule, error) {
+ if r.Pool == nil {
+ return nil, ErrUnavailable
+ }
+ if limit < 1 || limit > 100 {
+ return nil, errors.New("enabled alert rule limit is invalid")
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT r.id,r.schema_version,r.name,r.enabled,r.severity,r.scope,r.condition,r.evaluation_interval_seconds,r.pending_seconds,r.resolve_seconds,r.cooldown_seconds,r.unknown_behavior,r.group_by,r.suppress_when,r.message,r.revision,v.version_number,COALESCE(u.external_subject,''),r.created_at,r.updated_at FROM alert_rules r JOIN alert_rule_versions v ON v.id=r.current_version_id LEFT JOIN users u ON u.id=r.created_by WHERE r.enabled=true ORDER BY r.evaluation_interval_seconds ASC,r.name ASC,r.id ASC LIMIT $1`, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list enabled alert rules: %w", err)
+ }
+ defer rows.Close()
+ result := make([]Rule, 0, limit)
+ for rows.Next() {
+ var rule Rule
+ var scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON []byte
+ if err := rows.Scan(&rule.ID, &rule.SchemaVersion, &rule.Name, &rule.Enabled, &rule.Severity, &scopeJSON, &conditionJSON, &rule.EvaluationIntervalSeconds, &rule.PendingSeconds, &rule.ResolveSeconds, &rule.CooldownSeconds, &rule.UnknownBehavior, &groupJSON, &suppressJSON, &messageJSON, &rule.Revision, &rule.CurrentVersion, &rule.CreatedBy, &rule.CreatedAt, &rule.UpdatedAt); err != nil {
+ return nil, fmt.Errorf("scan enabled alert rule: %w", err)
+ }
+ if err := decodeStored(&rule.Document, scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON, rule.CooldownSeconds); err != nil {
+ return nil, err
+ }
+ result = append(result, rule)
+ }
+ return result, rows.Err()
+}
+
+func decodeStored(document *Document, scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON []byte, cooldownSeconds int) error {
+ if err := json.Unmarshal(scopeJSON, &document.Scope); err != nil {
+ return errors.New("invalid stored alert rule scope")
+ }
+ if err := json.Unmarshal(conditionJSON, &document.Condition); err != nil {
+ return errors.New("invalid stored alert rule condition")
+ }
+ if err := json.Unmarshal(groupJSON, &document.GroupBy); err != nil {
+ return errors.New("invalid stored alert rule groups")
+ }
+ if err := json.Unmarshal(suppressJSON, &document.SuppressWhen); err != nil {
+ return errors.New("invalid stored alert rule suppression")
+ }
+ document.CooldownSeconds = cooldownSeconds
+ if err := json.Unmarshal(messageJSON, &document.Message); err != nil {
+ return errors.New("invalid stored alert rule message")
+ }
+ return nil
+}
+
+func mustDecode(raw []byte) Document {
+ var document Document
+ _ = json.Unmarshal(raw, &document)
+ return document
+}
+
+func sameJSON(left, right []byte) bool {
+ var a, b any
+ if json.Unmarshal(left, &a) != nil || json.Unmarshal(right, &b) != nil {
+ return bytes.Equal(bytes.TrimSpace(left), bytes.TrimSpace(right))
+ }
+ return reflect.DeepEqual(a, b)
+}
+
+func nonNilMap(value map[string]any) map[string]any {
+ if value == nil {
+ return map[string]any{}
+ }
+ return value
+}
+func nonNilStrings(value []string) []string {
+ if value == nil {
+ return []string{}
+ }
+ return value
+}
+func valueOrEmpty(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
+
+func mapError(err error) error {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ return ErrConflict
+ }
+ return err
+}
diff --git a/internal/alert/repository_integration_test.go b/internal/alert/repository_integration_test.go
new file mode 100644
index 0000000..5ef584f
--- /dev/null
+++ b/internal/alert/repository_integration_test.go
@@ -0,0 +1,87 @@
+package alert
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func TestPostgreSQLRuleRepositoryLifecycle(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 4, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+
+ document, registry := validDocument(t)
+ repository := Repository{Pool: pool, Registry: registry}
+ created, version, err := repository.Create(ctx, "integration-editor", document, "integration create")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.Revision != 1 || created.CurrentVersion != 1 || version.VersionNumber != 1 {
+ t.Fatalf("unexpected create: %#v %#v", created, version)
+ }
+
+ if _, _, err := repository.Create(ctx, "integration-editor", document, "duplicate"); !errors.Is(err, ErrConflict) {
+ t.Fatalf("duplicate create error = %v, want conflict", err)
+ }
+ loaded, err := repository.Get(ctx, document.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Name != document.Name || loaded.Condition.Metric != document.Condition.Metric {
+ t.Fatalf("loaded rule mismatch: %#v", loaded)
+ }
+
+ same, err := repository.Update(ctx, document.ID, "integration-editor", 1, document, "idempotent")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if same.Revision != 1 || same.CurrentVersion != 1 {
+ t.Fatalf("idempotent update changed revision: %#v", same)
+ }
+
+ changed := document
+ changed.Name = "CPU aandacht gewijzigd"
+ updated, err := repository.Update(ctx, document.ID, "integration-editor", 1, changed, "change")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.Revision != 2 || updated.CurrentVersion != 2 {
+ t.Fatalf("unexpected update: %#v", updated)
+ }
+ if _, err := repository.Update(ctx, document.ID, "integration-editor", 1, changed, "stale"); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale update error = %v, want conflict", err)
+ }
+ enabled, err := repository.SetEnabled(ctx, document.ID, 2, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !enabled.Enabled || enabled.Revision != 3 {
+ t.Fatalf("unexpected enable: %#v", enabled)
+ }
+ versions, err := repository.Versions(ctx, document.ID, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(versions) != 2 || versions[0].VersionNumber != 2 || versions[1].VersionNumber != 1 {
+ t.Fatalf("unexpected immutable versions: %#v", versions)
+ }
+}
diff --git a/internal/alert/state.go b/internal/alert/state.go
new file mode 100644
index 0000000..a5b8bb9
--- /dev/null
+++ b/internal/alert/state.go
@@ -0,0 +1,261 @@
+package alert
+
+import (
+ "errors"
+ "fmt"
+ "time"
+)
+
+type State string
+
+const (
+ StateInactive State = "inactive"
+ StatePending State = "pending"
+ StateFiring State = "firing"
+ StateAcknowledged State = "acknowledged"
+ StateResolved State = "resolved"
+ StateUnknown State = "unknown"
+)
+
+var (
+ ErrInvalidObservation = errors.New("invalid alert observation")
+ ErrStaleObservation = errors.New("stale alert observation")
+ ErrInstanceNotFound = errors.New("alert instance not found")
+ ErrStateConflict = errors.New("alert instance state conflict")
+ ErrRevisionConflict = errors.New("alert instance revision conflict")
+)
+
+type Policy struct {
+ PendingSeconds int
+ ResolveSeconds int
+ CooldownSeconds int
+ UnknownBehavior string
+}
+
+type Snapshot struct {
+ State State
+ RetainedState State
+ ActiveSince *time.Time
+ RecoverySince *time.Time
+ CooldownUntil *time.Time
+ LastEvaluatedAt time.Time
+ LastKnownAt *time.Time
+ LastValue any
+ Reason string
+ SourceHealth map[string]any
+ AcknowledgedBy string
+ AcknowledgedAt *time.Time
+}
+
+type Notification string
+
+const (
+ NotificationNone Notification = ""
+ NotificationFiring Notification = "firing"
+ NotificationRecovery Notification = "recovery"
+ NotificationUnknown Notification = "unknown"
+)
+
+type TransitionResult struct {
+ Snapshot Snapshot
+ From State
+ To State
+ EventType string
+ Notification Notification
+}
+type Observation struct {
+ EvaluationKey string
+ ObservedAt time.Time
+ ConditionTrue bool
+ Unknown bool
+ Value any
+ Reason string
+ SourceHealth map[string]any
+}
+
+func (s Snapshot) normalized() Snapshot {
+ if s.State == "" {
+ s.State = StateInactive
+ }
+ if s.RetainedState == "" {
+ s.RetainedState = s.State
+ }
+ if s.SourceHealth == nil {
+ s.SourceHealth = map[string]any{}
+ }
+ return s
+}
+
+func Transition(current Snapshot, policy Policy, observation Observation) (TransitionResult, error) {
+ current = current.normalized()
+ if observation.ObservedAt.IsZero() || observation.EvaluationKey == "" || len(observation.EvaluationKey) > 160 {
+ return TransitionResult{}, ErrInvalidObservation
+ }
+ if !validState(current.State) || !validState(current.RetainedState) {
+ return TransitionResult{}, fmt.Errorf("%w: invalid current state", ErrInvalidObservation)
+ }
+ if policy.PendingSeconds < 0 || policy.ResolveSeconds < 0 || policy.CooldownSeconds < 0 || policy.CooldownSeconds > 2592000 || policy.UnknownBehavior == "" {
+ return TransitionResult{}, fmt.Errorf("%w: invalid policy", ErrInvalidObservation)
+ }
+ at := observation.ObservedAt.UTC()
+ if !current.LastEvaluatedAt.IsZero() && at.Before(current.LastEvaluatedAt.UTC()) {
+ return TransitionResult{}, ErrStaleObservation
+ }
+ result := current
+ result.LastEvaluatedAt = at
+ result.Reason = boundedReason(observation.Reason)
+ result.SourceHealth = cloneMap(observation.SourceHealth)
+ if observation.Unknown {
+ if policy.UnknownBehavior == UnknownIgnoreGap {
+ result.Reason = "unknown_input_ignored_short_gap"
+ return finish(current, result, State(current.State), "evaluation"), nil
+ }
+ result.RetainedState = current.State
+ if current.State == StateUnknown && current.RetainedState != StateUnknown {
+ result.RetainedState = current.RetainedState
+ }
+ result.State = StateUnknown
+ transition := finish(current, result, StateUnknown, "transition")
+ transition.Notification = notificationFor(current, transition, at)
+ return transition, nil
+ }
+
+ base := current.State
+ if base == StateUnknown {
+ base = current.RetainedState
+ if !validState(base) || base == StateUnknown {
+ base = StateInactive
+ }
+ result.State = base
+ }
+ result.RetainedState = base
+ result.LastKnownAt = timePtr(at)
+ result.LastValue = observation.Value
+ if observation.ConditionTrue {
+ result.RecoverySince = nil
+ switch base {
+ case StateInactive, StateResolved:
+ result.ActiveSince = timePtr(at)
+ if policy.PendingSeconds == 0 {
+ result.State = StateFiring
+ } else {
+ result.State = StatePending
+ }
+ case StatePending:
+ if result.ActiveSince == nil {
+ result.ActiveSince = timePtr(at)
+ }
+ if at.Sub(result.ActiveSince.UTC()) >= time.Duration(policy.PendingSeconds)*time.Second {
+ result.State = StateFiring
+ }
+ case StateFiring, StateAcknowledged:
+ result.State = base
+ default:
+ result.State = StateInactive
+ }
+ } else {
+ result.ActiveSince = current.ActiveSince
+ switch base {
+ case StatePending:
+ result.State = StateInactive
+ result.ActiveSince = nil
+ case StateFiring, StateAcknowledged:
+ if result.RecoverySince == nil {
+ result.RecoverySince = timePtr(at)
+ }
+ if at.Sub(result.RecoverySince.UTC()) >= time.Duration(policy.ResolveSeconds)*time.Second {
+ result.State = StateResolved
+ result.CooldownUntil = timePtr(at.Add(time.Duration(policy.CooldownSeconds) * time.Second))
+ result.ActiveSince = nil
+ result.RecoverySince = nil
+ }
+ default:
+ result.State = StateInactive
+ result.ActiveSince = nil
+ result.RecoverySince = nil
+ }
+ }
+ transition := finish(current, result, result.State, stateEvent(current.State, result.State))
+ transition.Notification = notificationFor(current, transition, at)
+ return transition, nil
+}
+
+func Acknowledge(current Snapshot, actor string, at time.Time) (TransitionResult, error) {
+ current = current.normalized()
+ if actor == "" || len(actor) > 160 || at.IsZero() {
+ return TransitionResult{}, ErrInvalidObservation
+ }
+ if current.State != StateFiring && current.State != StatePending {
+ return TransitionResult{}, ErrStateConflict
+ }
+ result := current
+ result.State = StateAcknowledged
+ result.RetainedState = StateAcknowledged
+ result.AcknowledgedBy = actor
+ result.AcknowledgedAt = timePtr(at.UTC())
+ result.Reason = "acknowledged"
+ return finish(current, result, StateAcknowledged, "acknowledge"), nil
+}
+
+func finish(current, result Snapshot, state State, eventType string) TransitionResult {
+ result.State = state
+ if result.SourceHealth == nil {
+ result.SourceHealth = map[string]any{}
+ }
+ return TransitionResult{Snapshot: result, From: current.State, To: state, EventType: eventType}
+}
+
+func notificationFor(current Snapshot, result TransitionResult, at time.Time) Notification {
+ switch {
+ case result.To == StateFiring && current.State != StateFiring && current.State != StateAcknowledged:
+ if current.CooldownUntil != nil && at.Before(current.CooldownUntil.UTC()) {
+ return NotificationNone
+ }
+ return NotificationFiring
+ case result.To == StateResolved && (current.State == StateFiring || current.State == StateAcknowledged):
+ return NotificationRecovery
+ case result.To == StateUnknown && current.State != StateUnknown:
+ return NotificationUnknown
+ default:
+ return NotificationNone
+ }
+}
+
+func stateEvent(from, to State) string {
+ if from == to {
+ return "evaluation"
+ }
+ return "transition"
+}
+
+func validState(state State) bool {
+ switch state {
+ case StateInactive, StatePending, StateFiring, StateAcknowledged, StateResolved, StateUnknown:
+ return true
+ default:
+ return false
+ }
+}
+
+func boundedReason(reason string) string {
+ if len(reason) > 500 {
+ return reason[:500]
+ }
+ return reason
+}
+
+func cloneMap(source map[string]any) map[string]any {
+ if source == nil {
+ return map[string]any{}
+ }
+ copy := make(map[string]any, len(source))
+ for key, value := range source {
+ copy[key] = value
+ }
+ return copy
+}
+
+func timePtr(value time.Time) *time.Time {
+ value = value.UTC()
+ return &value
+}
diff --git a/internal/alert/state_repository.go b/internal/alert/state_repository.go
new file mode 100644
index 0000000..fd383d8
--- /dev/null
+++ b/internal/alert/state_repository.go
@@ -0,0 +1,360 @@
+package alert
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type StateInput struct {
+ RuleID string
+ RuleVersionID string
+ Fingerprint string
+ EntityID string
+ Policy Policy
+ Observation Observation
+}
+
+type Instance struct {
+ ID string `json:"id"`
+ RuleID string `json:"ruleId"`
+ RuleVersionID string `json:"ruleVersionId"`
+ Fingerprint string `json:"fingerprint"`
+ EntityID string `json:"entityId,omitempty"`
+ State State `json:"state"`
+ RetainedState State `json:"retainedState"`
+ ActiveSince *time.Time `json:"activeSince,omitempty"`
+ RecoverySince *time.Time `json:"recoverySince,omitempty"`
+ LastEvaluatedAt time.Time `json:"lastEvaluatedAt"`
+ LastKnownAt *time.Time `json:"lastKnownAt,omitempty"`
+ LastValue any `json:"lastValue,omitempty"`
+ Reason string `json:"reason"`
+ SourceHealth map[string]any `json:"sourceHealth"`
+ AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
+ AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
+ CooldownUntil *time.Time `json:"cooldownUntil,omitempty"`
+ Revision int64 `json:"revision"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type Occurrence struct {
+ ID string `json:"id"`
+ InstanceID string `json:"instanceId"`
+ EvaluationKey string `json:"evaluationKey"`
+ EventType string `json:"eventType"`
+ From State `json:"from"`
+ To State `json:"to"`
+ ObservedAt time.Time `json:"observedAt"`
+ Value any `json:"value,omitempty"`
+ Reason string `json:"reason"`
+ SourceHealth map[string]any `json:"sourceHealth"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type StateStore interface {
+ ApplyObservation(context.Context, StateInput) (Instance, Occurrence, bool, error)
+ GetInstance(context.Context, string) (Instance, error)
+ ListOccurrences(context.Context, string, int) ([]Occurrence, error)
+ Acknowledge(context.Context, string, string, string, time.Time) (Instance, Occurrence, bool, error)
+}
+
+type StateRepository struct {
+ Pool *pgxpool.Pool
+}
+
+func (r StateRepository) ApplyObservation(ctx context.Context, input StateInput) (Instance, Occurrence, bool, error) {
+ if r.Pool == nil {
+ return Instance{}, Occurrence{}, false, ErrUnavailable
+ }
+ if err := validateStateInput(input); err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ healthJSON, err := boundedJSON(nonNilMap(input.Observation.SourceHealth), 64<<10)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Instance{}, Occurrence{}, false, fmt.Errorf("begin alert state transition: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ instanceID := NewID()
+ if err := tx.QueryRow(ctx, `INSERT INTO alert_instances (id,rule_id,rule_version_id,fingerprint,entity_id,last_evaluated_at) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (rule_id,fingerprint) DO NOTHING RETURNING id`, instanceID, input.RuleID, input.RuleVersionID, input.Fingerprint, nullableID(input.EntityID), input.Observation.ObservedAt.UTC()).Scan(&instanceID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return Instance{}, Occurrence{}, false, mapStateError(fmt.Errorf("create alert instance: %w", err))
+ }
+ var current Instance
+ if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE rule_id=$1 AND fingerprint=$2 FOR UPDATE`, input.RuleID, input.Fingerprint), ¤t); err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if current.EntityID != input.EntityID {
+ return Instance{}, Occurrence{}, false, ErrStateConflict
+ }
+ if occurrence, err := scanOccurrence(tx.QueryRow(ctx, `SELECT id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at FROM alert_occurrences WHERE instance_id=$1 AND evaluation_key=$2`, current.ID, input.Observation.EvaluationKey)); err == nil {
+ if err := tx.Commit(ctx); err != nil {
+ return Instance{}, Occurrence{}, false, fmt.Errorf("commit idempotent alert evaluation: %w", err)
+ }
+ return current, occurrence, true, nil
+ } else if !errors.Is(err, pgx.ErrNoRows) {
+ return Instance{}, Occurrence{}, false, err
+ }
+ transition, err := Transition(snapshotFromInstance(current), input.Policy, input.Observation)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ resultValue := transition.Snapshot.LastValue
+ if input.Observation.Unknown || input.Policy.UnknownBehavior == UnknownIgnoreGap && transition.Snapshot.LastKnownAt == nil {
+ resultValue = current.LastValue
+ }
+ resultValueJSON, err := boundedJSON(resultValue, 128<<10)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE alert_instances SET rule_version_id=$1,current_state=$2,retained_state=$3,active_since=$4,recovery_since=$5,cooldown_until=$6,last_evaluated_at=$7,last_known_at=$8,last_value=$9::jsonb,reason=$10,source_health=$11::jsonb,acknowledged_by=$12,acknowledged_at=$13,revision=revision+1,updated_at=now() WHERE id=$14`, input.RuleVersionID, transition.Snapshot.State, transition.Snapshot.RetainedState, transition.Snapshot.ActiveSince, transition.Snapshot.RecoverySince, transition.Snapshot.CooldownUntil, transition.Snapshot.LastEvaluatedAt.UTC(), transition.Snapshot.LastKnownAt, resultValueJSON, transition.Snapshot.Reason, healthJSON, nullableText(transition.Snapshot.AcknowledgedBy), transition.Snapshot.AcknowledgedAt, current.ID); err != nil {
+ return Instance{}, Occurrence{}, false, fmt.Errorf("update alert instance: %w", err)
+ }
+ occurrence, err := insertOccurrence(ctx, tx, current.ID, input.Observation.EvaluationKey, transition, input.Observation, resultValueJSON, healthJSON)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1`, current.ID), ¤t); err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return Instance{}, Occurrence{}, false, fmt.Errorf("commit alert state transition: %w", err)
+ }
+ return current, occurrence, false, nil
+}
+
+func (r StateRepository) GetInstance(ctx context.Context, id string) (Instance, error) {
+ if r.Pool == nil {
+ return Instance{}, ErrUnavailable
+ }
+ var instance Instance
+ err := scanInstance(r.Pool.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1`, id), &instance)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Instance{}, ErrInstanceNotFound
+ }
+ return instance, err
+}
+
+func (r StateRepository) ListOccurrences(ctx context.Context, instanceID string, limit int) ([]Occurrence, error) {
+ if r.Pool == nil {
+ return nil, ErrUnavailable
+ }
+ if limit < 1 || limit > 500 {
+ return nil, errors.New("alert occurrence limit is invalid")
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at FROM alert_occurrences WHERE instance_id=$1 ORDER BY observed_at DESC,id ASC LIMIT $2`, instanceID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list alert occurrences: %w", err)
+ }
+ defer rows.Close()
+ result := make([]Occurrence, 0, limit)
+ for rows.Next() {
+ occurrence, err := scanOccurrence(rows)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, occurrence)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if len(result) == 0 {
+ if _, err := r.GetInstance(ctx, instanceID); errors.Is(err, ErrInstanceNotFound) {
+ return nil, ErrInstanceNotFound
+ }
+ }
+ return result, nil
+}
+
+func (r StateRepository) Acknowledge(ctx context.Context, instanceID, actor, evaluationKey string, at time.Time) (Instance, Occurrence, bool, error) {
+ if r.Pool == nil {
+ return Instance{}, Occurrence{}, false, ErrUnavailable
+ }
+ if instanceID == "" || actor == "" || len(actor) > 160 || evaluationKey == "" || len(evaluationKey) > 160 || at.IsZero() {
+ return Instance{}, Occurrence{}, false, ErrInvalidObservation
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Instance{}, Occurrence{}, false, fmt.Errorf("begin alert acknowledgement: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var current Instance
+ if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1 FOR UPDATE`, instanceID), ¤t); errors.Is(err, pgx.ErrNoRows) {
+ return Instance{}, Occurrence{}, false, ErrInstanceNotFound
+ } else if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if occurrence, err := scanOccurrence(tx.QueryRow(ctx, `SELECT id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at FROM alert_occurrences WHERE instance_id=$1 AND evaluation_key=$2`, instanceID, evaluationKey)); err == nil {
+ if err := tx.Commit(ctx); err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ return current, occurrence, true, nil
+ } else if !errors.Is(err, pgx.ErrNoRows) {
+ return Instance{}, Occurrence{}, false, err
+ }
+ transition, err := Acknowledge(snapshotFromInstance(current), actor, at.UTC())
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ valueJSON, err := boundedJSON(current.LastValue, 128<<10)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ healthJSON, err := boundedJSON(nonNilMap(current.SourceHealth), 64<<10)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE alert_instances SET current_state=$1,retained_state=$2,reason=$3,acknowledged_by=$4,acknowledged_at=$5,revision=revision+1,updated_at=now() WHERE id=$6`, transition.Snapshot.State, transition.Snapshot.RetainedState, transition.Snapshot.Reason, actor, transition.Snapshot.AcknowledgedAt, instanceID); err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ occurrence, err := insertOccurrence(ctx, tx, instanceID, evaluationKey, transition, Observation{ObservedAt: at.UTC(), Reason: "acknowledged", Value: current.LastValue}, valueJSON, healthJSON)
+ if err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1`, instanceID), ¤t); err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return Instance{}, Occurrence{}, false, err
+ }
+ return current, occurrence, false, nil
+}
+
+func scanInstance(row pgx.Row, instance *Instance) error {
+ var valueJSON, healthJSON []byte
+ err := row.Scan(&instance.ID, &instance.RuleID, &instance.RuleVersionID, &instance.Fingerprint, &instance.EntityID, &instance.State, &instance.RetainedState, &instance.ActiveSince, &instance.RecoverySince, &instance.CooldownUntil, &instance.LastEvaluatedAt, &instance.LastKnownAt, &valueJSON, &instance.Reason, &healthJSON, &instance.AcknowledgedBy, &instance.AcknowledgedAt, &instance.Revision, &instance.CreatedAt, &instance.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrInstanceNotFound
+ }
+ if err != nil {
+ return fmt.Errorf("scan alert instance: %w", err)
+ }
+ instance.LastValue, err = decodeJSON(valueJSON)
+ if err != nil {
+ return fmt.Errorf("decode alert instance value: %w", err)
+ }
+ instance.SourceHealth, err = decodeMap(healthJSON)
+ if err != nil {
+ return fmt.Errorf("decode alert instance source health: %w", err)
+ }
+ return nil
+}
+
+func scanOccurrence(row interface{ Scan(...any) error }) (Occurrence, error) {
+ var occurrence Occurrence
+ var valueJSON, healthJSON []byte
+ err := row.Scan(&occurrence.ID, &occurrence.InstanceID, &occurrence.EvaluationKey, &occurrence.EventType, &occurrence.From, &occurrence.To, &occurrence.ObservedAt, &valueJSON, &occurrence.Reason, &healthJSON, &occurrence.CreatedAt)
+ if err != nil {
+ return Occurrence{}, err
+ }
+ occurrence.Value, err = decodeJSON(valueJSON)
+ if err != nil {
+ return Occurrence{}, fmt.Errorf("decode alert occurrence value: %w", err)
+ }
+ occurrence.SourceHealth, err = decodeMap(healthJSON)
+ if err != nil {
+ return Occurrence{}, fmt.Errorf("decode alert occurrence source health: %w", err)
+ }
+ return occurrence, nil
+}
+
+func insertOccurrence(ctx context.Context, tx pgx.Tx, instanceID, key string, transition TransitionResult, observation Observation, valueJSON, healthJSON []byte) (Occurrence, error) {
+ var occurrence Occurrence
+ err := tx.QueryRow(ctx, `INSERT INTO alert_occurrences (id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10::jsonb) RETURNING id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at`, NewID(), instanceID, key, transition.EventType, transition.From, transition.To, observation.ObservedAt.UTC(), valueJSON, transition.Snapshot.Reason, healthJSON).Scan(&occurrence.ID, &occurrence.InstanceID, &occurrence.EvaluationKey, &occurrence.EventType, &occurrence.From, &occurrence.To, &occurrence.ObservedAt, &valueJSON, &occurrence.Reason, &healthJSON, &occurrence.CreatedAt)
+ if err != nil {
+ return Occurrence{}, mapStateError(fmt.Errorf("insert alert occurrence: %w", err))
+ }
+ occurrence.Value, err = decodeJSON(valueJSON)
+ if err != nil {
+ return Occurrence{}, err
+ }
+ occurrence.SourceHealth, err = decodeMap(healthJSON)
+ if err != nil {
+ return Occurrence{}, err
+ }
+ return occurrence, nil
+}
+
+func snapshotFromInstance(instance Instance) Snapshot {
+ return Snapshot{State: instance.State, RetainedState: instance.RetainedState, ActiveSince: instance.ActiveSince, RecoverySince: instance.RecoverySince, CooldownUntil: instance.CooldownUntil, LastEvaluatedAt: instance.LastEvaluatedAt, LastKnownAt: instance.LastKnownAt, LastValue: instance.LastValue, Reason: instance.Reason, SourceHealth: instance.SourceHealth, AcknowledgedBy: instance.AcknowledgedBy, AcknowledgedAt: instance.AcknowledgedAt}
+}
+
+func validateStateInput(input StateInput) error {
+ if strings.TrimSpace(input.RuleID) == "" || strings.TrimSpace(input.RuleVersionID) == "" || input.Fingerprint == "" || len(input.Fingerprint) > 160 {
+ return ErrInvalidObservation
+ }
+ if input.Policy.UnknownBehavior != UnknownRetain && input.Policy.UnknownBehavior != UnknownBecome && input.Policy.UnknownBehavior != UnknownIgnoreGap {
+ return ErrInvalidObservation
+ }
+ if input.Policy.PendingSeconds < 0 || input.Policy.ResolveSeconds < 0 {
+ return ErrInvalidObservation
+ }
+ return nil
+}
+
+func boundedJSON(value any, max int) ([]byte, error) {
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ return nil, fmt.Errorf("encode alert state JSON: %w", err)
+ }
+ if len(encoded) > max {
+ return nil, ErrInvalidObservation
+ }
+ return encoded, nil
+}
+
+func decodeJSON(raw []byte) (any, error) {
+ if len(raw) == 0 || string(raw) == "null" {
+ return nil, nil
+ }
+ var value any
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return nil, err
+ }
+ return value, nil
+}
+
+func decodeMap(raw []byte) (map[string]any, error) {
+ if len(raw) == 0 || string(raw) == "null" {
+ return map[string]any{}, nil
+ }
+ var value map[string]any
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return nil, err
+ }
+ if value == nil {
+ return map[string]any{}, nil
+ }
+ return value, nil
+}
+
+func nullableID(value string) any {
+ if value == "" {
+ return nil
+ }
+ return value
+}
+
+func nullableText(value string) any {
+ if value == "" {
+ return nil
+ }
+ return value
+}
+
+func mapStateError(err error) error {
+ var pgErr interface{ SQLState() string }
+ if errors.As(err, &pgErr) && pgErr.SQLState() == "23505" {
+ return ErrStateConflict
+ }
+ return err
+}
diff --git a/internal/alert/state_repository_integration_test.go b/internal/alert/state_repository_integration_test.go
new file mode 100644
index 0000000..98ba13a
--- /dev/null
+++ b/internal/alert/state_repository_integration_test.go
@@ -0,0 +1,171 @@
+package alert
+
+import (
+ "context"
+ "os"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func TestPostgreSQLAlertStateLifecycleAndIdempotence(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ document, registry := validDocument(t)
+ document.Enabled = true
+ rules := Repository{Pool: pool, Registry: registry}
+ created, version, err := rules.Create(ctx, "state-integration", document, "state test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := StateRepository{Pool: pool}
+ policy := Policy{PendingSeconds: document.PendingSeconds, ResolveSeconds: document.ResolveSeconds, UnknownBehavior: document.UnknownBehavior}
+ base := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
+ first, firstOccurrence, duplicate, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: observation(base, "slot-1", true)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if duplicate || first.State != StatePending || firstOccurrence.To != StatePending {
+ t.Fatalf("unexpected first state: %#v %#v", first, firstOccurrence)
+ }
+ replayed, replayOccurrence, duplicate, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: observation(base, "slot-1", true)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !duplicate || replayed.Revision != first.Revision || replayOccurrence.ID != firstOccurrence.ID {
+ t.Fatalf("replay was not idempotent: %#v %#v", replayed, replayOccurrence)
+ }
+ firing, _, _, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: observation(base.Add(60*time.Second), "slot-2", true)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if firing.State != StateFiring {
+ t.Fatalf("pending did not fire: %#v", firing)
+ }
+ acknowledged, _, _, err := store.Acknowledge(ctx, firing.ID, "operator", "ack-1", base.Add(61*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if acknowledged.State != StateAcknowledged {
+ t.Fatalf("acknowledgement failed: %#v", acknowledged)
+ }
+ stillFiring, _, _, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: observation(base.Add(62*time.Second), "slot-3", true)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stillFiring.State != StateAcknowledged {
+ t.Fatalf("acknowledged firing alert changed state: %#v", stillFiring)
+ }
+ unknownObservation := observation(base.Add(90*time.Second), "slot-4", false)
+ unknownObservation.Unknown = true
+ unknown, _, _, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: unknownObservation})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if unknown.State != StateUnknown || unknown.RetainedState != StateAcknowledged {
+ t.Fatalf("unknown state lost acknowledgement context: %#v", unknown)
+ }
+ restarted := StateRepository{Pool: pool}
+ loaded, err := restarted.GetInstance(ctx, unknown.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.State != StateUnknown || loaded.LastKnownAt == nil || loaded.LastValue == nil {
+ t.Fatalf("restart did not preserve state: %#v", loaded)
+ }
+ occurrences, err := restarted.ListOccurrences(ctx, unknown.ID, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(occurrences) != 5 {
+ t.Fatalf("occurrence count = %d, want 5", len(occurrences))
+ }
+
+ missingVersion := StateInput{RuleID: created.ID, RuleVersionID: NewID(), Fingerprint: "rollback", Policy: policy, Observation: observation(base, "rollback", true)}
+ if _, _, _, err := store.ApplyObservation(ctx, missingVersion); err == nil {
+ t.Fatal("missing foreign key did not fail")
+ }
+ if _, err := pool.Exec(ctx, `SELECT 1 FROM alert_instances WHERE rule_id=$1 AND fingerprint=$2`, created.ID, "rollback"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPostgreSQLAlertStateCoordinatesOverlappingWrites(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ document, registry := validDocument(t)
+ rules := Repository{Pool: pool, Registry: registry}
+ created, version, err := rules.Create(ctx, "state-concurrency", document, "state concurrency")
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := StateRepository{Pool: pool}
+ input := StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:concurrent", Policy: Policy{PendingSeconds: 0, ResolveSeconds: 0, UnknownBehavior: UnknownRetain}, Observation: observation(time.Date(2026, time.January, 3, 12, 0, 0, 0, time.UTC), "same-slot", true)}
+ const workers = 8
+ results := make(chan bool, workers)
+ errorsCh := make(chan error, workers)
+ var group sync.WaitGroup
+ for i := 0; i < workers; i++ {
+ group.Add(1)
+ go func() {
+ defer group.Done()
+ _, _, duplicate, err := store.ApplyObservation(ctx, input)
+ if err != nil {
+ errorsCh <- err
+ return
+ }
+ results <- duplicate
+ }()
+ }
+ group.Wait()
+ close(results)
+ close(errorsCh)
+ for err := range errorsCh {
+ t.Fatal(err)
+ }
+ createdCount := 0
+ for duplicate := range results {
+ if !duplicate {
+ createdCount++
+ }
+ }
+ if createdCount != 1 {
+ t.Fatalf("non-idempotent concurrent writes = %d, want 1", createdCount)
+ }
+ var occurrenceCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM alert_occurrences WHERE instance_id=(SELECT id FROM alert_instances WHERE rule_id=$1 AND fingerprint=$2)`, created.ID, input.Fingerprint).Scan(&occurrenceCount); err != nil {
+ t.Fatal(err)
+ }
+ if occurrenceCount != 1 {
+ t.Fatalf("occurrences = %d, want 1", occurrenceCount)
+ }
+}
diff --git a/internal/alert/state_test.go b/internal/alert/state_test.go
new file mode 100644
index 0000000..329ab99
--- /dev/null
+++ b/internal/alert/state_test.go
@@ -0,0 +1,167 @@
+package alert
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+func testPolicy() Policy {
+ return Policy{PendingSeconds: 60, ResolveSeconds: 30, UnknownBehavior: UnknownRetain}
+}
+
+func observation(at time.Time, key string, fire bool) Observation {
+ return Observation{EvaluationKey: key, ObservedAt: at, ConditionTrue: fire, Value: 90, Reason: "condition_evaluated", SourceHealth: map[string]any{"source": "test"}}
+}
+
+func TestStateTransitionTable(t *testing.T) {
+ start := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
+ tests := []struct {
+ name string
+ state State
+ at time.Time
+ fire bool
+ want State
+ }{
+ {name: "inactive enters pending", state: StateInactive, at: start, fire: true, want: StatePending},
+ {name: "pending remains pending", state: StatePending, at: start.Add(30 * time.Second), fire: true, want: StatePending},
+ {name: "pending fires after duration", state: StatePending, at: start.Add(60 * time.Second), fire: true, want: StateFiring},
+ {name: "pending clears", state: StatePending, at: start.Add(30 * time.Second), fire: false, want: StateInactive},
+ {name: "firing starts recovery", state: StateFiring, at: start, fire: false, want: StateFiring},
+ {name: "firing resolves after duration", state: StateFiring, at: start.Add(30 * time.Second), fire: false, want: StateResolved},
+ {name: "resolved reopens", state: StateResolved, at: start, fire: true, want: StatePending},
+ {name: "acknowledged remains firing", state: StateAcknowledged, at: start, fire: true, want: StateAcknowledged},
+ {name: "acknowledged starts recovery", state: StateAcknowledged, at: start, fire: false, want: StateAcknowledged},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ current := Snapshot{State: test.state, RetainedState: test.state}
+ if test.state == StatePending {
+ current.ActiveSince = timePtr(start)
+ }
+ if test.state == StateFiring || test.state == StateAcknowledged {
+ current.RecoverySince = timePtr(start)
+ }
+ result, err := Transition(current, testPolicy(), observation(test.at, "slot-"+test.name, test.fire))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.To != test.want {
+ t.Fatalf("state = %s, want %s", result.To, test.want)
+ }
+ })
+ }
+}
+
+func TestAcknowledgementIsExplicitAndPreservesFiringContext(t *testing.T) {
+ at := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
+ result, err := Acknowledge(Snapshot{State: StateFiring, RetainedState: StateFiring, LastValue: 92}, "operator", at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.To != StateAcknowledged || result.Snapshot.LastValue != 92 || result.Snapshot.AcknowledgedBy != "operator" {
+ t.Fatalf("unexpected acknowledgement: %#v", result)
+ }
+ later, err := Transition(result.Snapshot, testPolicy(), observation(at.Add(time.Second), "slot-ack", true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if later.To != StateAcknowledged {
+ t.Fatalf("acknowledged alert did not remain acknowledged while firing: %#v", later)
+ }
+}
+
+func TestUnknownDoesNotFalseResolveAndRecoveryUsesRetainedState(t *testing.T) {
+ at := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
+ unknown, err := Transition(Snapshot{State: StateFiring, RetainedState: StateFiring, LastKnownAt: timePtr(at), LastValue: 91}, testPolicy(), Observation{EvaluationKey: "unknown", ObservedAt: at.Add(time.Minute), Unknown: true, Reason: "source_stale", SourceHealth: map[string]any{"state": "unknown"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if unknown.To != StateUnknown || unknown.Snapshot.RetainedState != StateFiring || unknown.Snapshot.LastValue != 91 {
+ t.Fatalf("unknown transition lost firing context: %#v", unknown)
+ }
+ recovered, err := Transition(unknown.Snapshot, testPolicy(), observation(at.Add(2*time.Minute), "recovery", false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if recovered.To != StateFiring {
+ t.Fatalf("unknown recovery falsely resolved immediately: %#v", recovered)
+ }
+}
+
+func TestStateRejectsOutOfOrderObservation(t *testing.T) {
+ at := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
+ _, err := Transition(Snapshot{State: StateFiring, RetainedState: StateFiring, LastEvaluatedAt: at}, testPolicy(), observation(at.Add(-time.Second), "old", false))
+ if !errors.Is(err, ErrStaleObservation) {
+ t.Fatalf("error = %v, want ErrStaleObservation", err)
+ }
+}
+
+func TestIgnoreUnknownGapPreservesState(t *testing.T) {
+ at := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
+ policy := testPolicy()
+ policy.UnknownBehavior = UnknownIgnoreGap
+ result, err := Transition(Snapshot{State: StateFiring, RetainedState: StateFiring, LastEvaluatedAt: at, LastValue: 80}, policy, Observation{EvaluationKey: "gap", ObservedAt: at.Add(time.Second), Unknown: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.To != StateFiring || result.Snapshot.LastValue != 80 {
+ t.Fatalf("unknown gap changed state: %#v", result)
+ }
+}
+
+func TestDiskTemperatureScenarioUsesPendingRecoveryAndCooldownDeterministically(t *testing.T) {
+ start := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
+ policy := Policy{PendingSeconds: 300, ResolveSeconds: 300, UnknownBehavior: UnknownRetain}
+ current := Snapshot{State: StateInactive, RetainedState: StateInactive}
+ transition, err := Transition(current, policy, observation(start, "disk-1", true))
+ if err != nil || transition.To != StatePending {
+ t.Fatalf("initial transition = %#v, %v", transition, err)
+ }
+ current = transition.Snapshot
+ transition, err = Transition(current, policy, observation(start.Add(299*time.Second), "disk-2", true))
+ if err != nil || transition.To != StatePending {
+ t.Fatalf("boundary pending transition = %#v, %v", transition, err)
+ }
+ current = transition.Snapshot
+ transition, err = Transition(current, policy, observation(start.Add(300*time.Second), "disk-3", true))
+ if err != nil || transition.To != StateFiring {
+ t.Fatalf("fire transition = %#v, %v", transition, err)
+ }
+ current = transition.Snapshot
+ transition, err = Transition(current, policy, observation(start.Add(330*time.Second), "disk-4", true))
+ if err != nil || transition.To != StateFiring {
+ t.Fatalf("temperature at recovery threshold changed state: %#v, %v", transition, err)
+ }
+ current = transition.Snapshot
+ transition, err = Transition(current, policy, observation(start.Add(360*time.Second), "disk-5", false))
+ if err != nil || transition.To != StateFiring {
+ t.Fatalf("recovery start transition = %#v, %v", transition, err)
+ }
+ current = transition.Snapshot
+ transition, err = Transition(current, policy, observation(start.Add(659*time.Second), "disk-6", false))
+ if err != nil || transition.To != StateFiring {
+ t.Fatalf("pre-recovery boundary transition = %#v, %v", transition, err)
+ }
+ transition, err = Transition(transition.Snapshot, policy, observation(start.Add(660*time.Second), "disk-7", false))
+ if err != nil || transition.To != StateResolved {
+ t.Fatalf("recovery boundary transition = %#v, %v", transition, err)
+ }
+}
+
+func TestCooldownSuppressesRepeatedFiringNotification(t *testing.T) {
+ start := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
+ policy := Policy{PendingSeconds: 0, ResolveSeconds: 0, CooldownSeconds: 60, UnknownBehavior: UnknownRetain}
+ first, err := Transition(Snapshot{State: StateInactive, RetainedState: StateInactive}, policy, observation(start, "cool-1", true))
+ if err != nil || first.Notification != NotificationFiring {
+ t.Fatalf("first notification = %#v, %v", first, err)
+ }
+ resolved, err := Transition(first.Snapshot, policy, observation(start.Add(time.Second), "cool-2", false))
+ if err != nil || resolved.To != StateResolved || resolved.Notification != NotificationRecovery || resolved.Snapshot.CooldownUntil == nil {
+ t.Fatalf("resolve notification = %#v, %v", resolved, err)
+ }
+ suppressed, err := Transition(resolved.Snapshot, policy, observation(start.Add(30*time.Second), "cool-3", true))
+ if err != nil || suppressed.To != StateFiring || suppressed.Notification != NotificationNone {
+ t.Fatalf("cooldown firing notification = %#v, %v", suppressed, err)
+ }
+}
diff --git a/internal/alert/types.go b/internal/alert/types.go
new file mode 100644
index 0000000..1d69917
--- /dev/null
+++ b/internal/alert/types.go
@@ -0,0 +1,423 @@
+package alert
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/metriccatalog"
+)
+
+const SchemaVersion = 1
+
+const (
+ SeverityAttention = "attention"
+ SeverityDegraded = "degraded"
+ SeverityCritical = "critical"
+ UnknownRetain = "retain-firing-as-unknown"
+ UnknownBecome = "become-unknown"
+ UnknownIgnoreGap = "ignore-short-gap"
+)
+
+var (
+ ErrInvalidRule = errors.New("invalid alert rule")
+ ErrConflict = errors.New("alert rule revision conflict")
+ ErrNotFound = errors.New("alert rule not found")
+ ErrUnavailable = errors.New("alert rule repository is unavailable")
+ semanticKeyPattern = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_.-]*$")
+ uuidPattern = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$")
+)
+
+type Condition struct {
+ InputType string `json:"inputType"`
+ Metric string `json:"metric,omitempty"`
+ Operator string `json:"operator"`
+ Threshold any `json:"threshold,omitempty"`
+ RecoveryThreshold *float64 `json:"recoveryThreshold,omitempty"`
+ Aggregation string `json:"aggregation,omitempty"`
+ WindowSeconds int `json:"windowSeconds,omitempty"`
+}
+
+type Message struct {
+ TitleKey string `json:"titleKey"`
+ BodyKey string `json:"bodyKey"`
+}
+
+type Document struct {
+ SchemaVersion int `json:"schemaVersion"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ Severity string `json:"severity"`
+ Scope map[string]any `json:"scope"`
+ Condition Condition `json:"condition"`
+ EvaluationIntervalSeconds int `json:"evaluationIntervalSeconds"`
+ PendingSeconds int `json:"pendingSeconds"`
+ ResolveSeconds int `json:"resolveSeconds"`
+ CooldownSeconds int `json:"cooldownSeconds,omitempty"`
+ UnknownBehavior string `json:"unknownBehavior"`
+ GroupBy []string `json:"groupBy,omitempty"`
+ SuppressWhen []string `json:"suppressWhen,omitempty"`
+ Message Message `json:"message"`
+}
+
+type Rule struct {
+ Document
+ Revision int64 `json:"revision"`
+ CurrentVersion int `json:"currentVersion"`
+ CreatedBy string `json:"createdBy,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type Version struct {
+ ID string `json:"id"`
+ RuleID string `json:"ruleId"`
+ VersionNumber int `json:"versionNumber"`
+ Document Document `json:"document"`
+ ChangeSummary string `json:"changeSummary"`
+ CreatedBy string `json:"createdBy,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type PreviewRequest struct {
+ Value any `json:"value,omitempty"`
+ Unknown bool `json:"unknown,omitempty"`
+ CurrentState string `json:"currentState,omitempty"`
+}
+
+type PreviewResult struct {
+ WouldFire bool `json:"wouldFire"`
+ State string `json:"state"`
+ Reason string `json:"reason"`
+}
+
+func (d Document) Validate(registry metriccatalog.Registry) error {
+ if d.SchemaVersion != SchemaVersion {
+ return invalid("schemaVersion", "must be 1")
+ }
+ if !uuidPattern.MatchString(d.ID) {
+ return invalid("id", "must be a UUID")
+ }
+ if strings.TrimSpace(d.Name) != d.Name || d.Name == "" || len(d.Name) > 160 || strings.ContainsAny(d.Name, "\r\n") {
+ return invalid("name", "must be 1-160 characters without line breaks")
+ }
+ if !oneOf(d.Severity, SeverityAttention, SeverityDegraded, SeverityCritical) {
+ return invalid("severity", "is unsupported")
+ }
+ if len(d.Scope) > 20 {
+ return invalid("scope", "has too many keys")
+ }
+ for key, value := range d.Scope {
+ if len(key) == 0 || len(key) > 80 || !semanticKeyPattern.MatchString(key) {
+ return invalid("scope", "contains an invalid key")
+ }
+ if err := validateValue(value, 160); err != nil {
+ return invalid("scope", err.Error())
+ }
+ }
+ if d.EvaluationIntervalSeconds < 5 || d.EvaluationIntervalSeconds > 3600 {
+ return invalid("evaluationIntervalSeconds", "must be between 5 and 3600")
+ }
+ if d.PendingSeconds < 0 || d.PendingSeconds > 2592000 || d.ResolveSeconds < 0 || d.ResolveSeconds > 2592000 {
+ return invalid("pendingSeconds", "is out of range")
+ }
+ if d.CooldownSeconds < 0 || d.CooldownSeconds > 2592000 {
+ return invalid("cooldownSeconds", "is out of range")
+ }
+
+ if !oneOf(d.UnknownBehavior, UnknownRetain, UnknownBecome, UnknownIgnoreGap) {
+ return invalid("unknownBehavior", "is unsupported")
+ }
+ if len(d.GroupBy) > 10 || uniqueStrings(d.GroupBy, 80) != nil {
+ return invalid("groupBy", "must contain at most 10 unique bounded keys")
+ }
+ if len(d.SuppressWhen) > 20 || uniqueStrings(d.SuppressWhen, 160) != nil {
+ return invalid("suppressWhen", "must contain at most 20 unique bounded rules")
+ }
+ if len(d.Message.TitleKey) == 0 || len(d.Message.TitleKey) > 160 || len(d.Message.BodyKey) == 0 || len(d.Message.BodyKey) > 160 {
+ return invalid("message", "titleKey and bodyKey are required and bounded")
+ }
+ if err := d.Condition.validate(registry); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (c Condition) validate(registry metriccatalog.Registry) error {
+ if !oneOf(c.InputType, "metric", "entity-status", "event", "datasource-health") {
+ return invalid("condition.inputType", "is unsupported")
+ }
+ if !oneOf(c.Operator, ">", ">=", "<", "<=", "==", "!=", "matches", "absent") {
+ return invalid("condition.operator", "is unsupported")
+ }
+ if c.InputType == "metric" {
+ if c.Metric == "" || strings.ContainsAny(c.Metric, "{};$()[]") || len(c.Metric) > 160 {
+ return invalid("condition.metric", "must be a semantic metric name")
+ }
+ if _, ok := registry.Find(c.Metric); !ok {
+ return invalid("condition.metric", "is not in the semantic metric catalog")
+ }
+ } else if c.Metric != "" {
+ return invalid("condition.metric", "is only valid for metric input")
+ }
+ if c.Aggregation != "" && !oneOf(c.Aggregation, "none", "avg", "sum", "min", "max", "rate", "increase", "count", "p50", "p95", "p99") {
+ return invalid("condition.aggregation", "is unsupported")
+ }
+ if c.Aggregation == "count" && c.InputType != "event" {
+ return invalid("condition.aggregation", "count is only valid for event input")
+ }
+ if c.WindowSeconds < 0 || c.WindowSeconds > 2592000 {
+ return invalid("condition.windowSeconds", "is out of range")
+ }
+ if c.Operator == "matches" {
+ value, ok := c.Threshold.(string)
+ if !ok || len(value) == 0 || len(value) > 160 {
+ return invalid("condition.threshold", "matches requires a bounded pattern")
+ }
+ if _, err := regexp.Compile(value); err != nil {
+ return invalid("condition.threshold", "contains an invalid pattern")
+ }
+ } else if c.Operator == "absent" {
+ if c.Threshold != nil {
+ return invalid("condition.threshold", "absent does not accept a threshold")
+ }
+ } else if !isNumber(c.Threshold) && !(c.Operator == "==" || c.Operator == "!=" && isComparableString(c.Threshold)) {
+ return invalid("condition.threshold", "a numeric threshold is required")
+ }
+ if c.RecoveryThreshold != nil {
+ if math.IsNaN(*c.RecoveryThreshold) || math.IsInf(*c.RecoveryThreshold, 0) {
+ return invalid("condition.recoveryThreshold", "must be finite")
+ }
+ if !oneOf(c.Operator, ">", ">=", "<", "<=") || !isNumber(c.Threshold) {
+ return invalid("condition.recoveryThreshold", "requires a numeric ordered condition")
+ }
+ threshold, _ := number(c.Threshold)
+ if (c.Operator == ">" || c.Operator == ">=") && *c.RecoveryThreshold >= threshold {
+ return invalid("condition.recoveryThreshold", "must be below the firing threshold")
+ }
+ if (c.Operator == "<" || c.Operator == "<=") && *c.RecoveryThreshold <= threshold {
+ return invalid("condition.recoveryThreshold", "must be above the firing threshold")
+ }
+ }
+ return nil
+}
+
+func (d Document) MarshalCanonical() ([]byte, error) {
+ if d.Scope == nil {
+ d.Scope = map[string]any{}
+ }
+ if d.GroupBy == nil {
+ d.GroupBy = []string{}
+ }
+ if d.SuppressWhen == nil {
+ d.SuppressWhen = []string{}
+ }
+ return json.Marshal(d)
+}
+
+func DecodeDocument(data []byte, registry metriccatalog.Registry) (Document, error) {
+ if len(data) == 0 || len(data) > 2<<20 {
+ return Document{}, invalid("document", "is empty or too large")
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ var document Document
+ if err := decoder.Decode(&document); err != nil {
+ return Document{}, fmt.Errorf("%w: document: %v", ErrInvalidRule, err)
+ }
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ return Document{}, invalid("document", "contains multiple JSON values")
+ }
+ if err := document.Validate(registry); err != nil {
+ return Document{}, err
+ }
+ return document, nil
+}
+
+func Preview(document Document, request PreviewRequest, registry metriccatalog.Registry) (PreviewResult, error) {
+ if err := document.Validate(registry); err != nil {
+ return PreviewResult{}, err
+ }
+ if request.Unknown {
+ if document.UnknownBehavior == UnknownRetain {
+ return PreviewResult{State: "unknown", Reason: "unknown_input_retains_last_state"}, nil
+ }
+ return PreviewResult{State: "unknown", Reason: "unknown_input"}, nil
+ }
+ active := request.CurrentState == string(StateFiring) || request.CurrentState == string(StateAcknowledged) || request.CurrentState == string(StateUnknown)
+ fire, err := document.Condition.Evaluate(request.Value, active)
+ if err != nil {
+ return PreviewResult{}, err
+ }
+ state := "inactive"
+ if fire {
+ state = "firing"
+ }
+ return PreviewResult{WouldFire: fire, State: state, Reason: "condition_evaluated"}, nil
+}
+
+func (c Condition) Evaluate(value any, active bool) (bool, error) {
+ threshold := c.Threshold
+ operator := c.Operator
+ if active && c.RecoveryThreshold != nil {
+ threshold = *c.RecoveryThreshold
+ if operator == ">" || operator == ">=" {
+ operator = ">="
+ }
+ if operator == "<" || operator == "<=" {
+ operator = "<="
+ }
+ }
+ return compare(operator, value, threshold)
+}
+func compare(operator string, value, threshold any) (bool, error) {
+ if operator == "absent" {
+ return value == nil, nil
+ }
+ if operator == "matches" {
+ left, ok := value.(string)
+ right, ok2 := threshold.(string)
+ if !ok || !ok2 {
+ return false, invalid("preview", "matches requires string input")
+ }
+ matched, err := regexp.MatchString(right, left)
+ return matched, err
+ }
+ if left, ok := number(value); ok {
+ right, ok := number(threshold)
+ if !ok {
+ return false, invalid("preview", "numeric threshold is required")
+ }
+ switch operator {
+ case ">":
+ return left > right, nil
+ case ">=":
+ return left >= right, nil
+ case "<":
+ return left < right, nil
+ case "<=":
+ return left <= right, nil
+ case "==":
+ return left == right, nil
+ case "!=":
+ return left != right, nil
+ }
+ }
+ if operator == "==" || operator == "!=" {
+ equal := fmt.Sprint(value) == fmt.Sprint(threshold)
+ if operator == "!=" {
+ equal = !equal
+ }
+ return equal, nil
+ }
+ return false, invalid("preview", "value type does not support operator")
+}
+
+func number(value any) (float64, bool) {
+ switch value := value.(type) {
+ case float64:
+ return value, !math.IsNaN(value) && !math.IsInf(value, 0)
+ case float32:
+ return float64(value), true
+ case int:
+ return float64(value), true
+ case int64:
+ return float64(value), true
+ case json.Number:
+ parsed, err := value.Float64()
+ return parsed, err == nil
+ default:
+ return 0, false
+ }
+}
+
+func isNumber(value any) bool { _, ok := number(value); return ok }
+func isComparableString(value any) bool { _, ok := value.(string); return ok }
+
+func validateValue(value any, maxString int) error {
+ switch value := value.(type) {
+ case nil, bool:
+ return nil
+ case string:
+ if len(value) > maxString || strings.ContainsAny(value, "\r\n") {
+ return errors.New("contains an unsafe string")
+ }
+ case float64:
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return errors.New("contains a non-finite number")
+ }
+ case []any:
+ if len(value) > 20 {
+ return errors.New("contains too many values")
+ }
+ for _, item := range value {
+ if err := validateValue(item, maxString); err != nil {
+ return err
+ }
+ }
+ case map[string]any:
+ if len(value) > 20 {
+ return errors.New("contains too many keys")
+ }
+ keys := make([]string, 0, len(value))
+ for key, item := range value {
+ keys = append(keys, key)
+ if err := validateValue(item, maxString); err != nil {
+ return err
+ }
+ }
+ sort.Strings(keys)
+ default:
+ return errors.New("contains an unsupported value")
+ }
+ return nil
+}
+
+func uniqueStrings(values []string, max int) error {
+ seen := map[string]struct{}{}
+ for _, value := range values {
+ if len(value) == 0 || len(value) > max || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\r\n") {
+ return errors.New("contains an invalid value")
+ }
+ if _, ok := seen[value]; ok {
+ return errors.New("contains duplicate values")
+ }
+ seen[value] = struct{}{}
+ }
+ return nil
+}
+
+func oneOf(value string, allowed ...string) bool {
+ for _, item := range allowed {
+ if value == item {
+ return true
+ }
+ }
+ return false
+}
+
+func invalid(field, detail string) error {
+ return fmt.Errorf("%w: %s %s", ErrInvalidRule, field, detail)
+}
+
+func NewID() string {
+ var raw [16]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return "00000000-0000-4000-8000-000000000000"
+ }
+ raw[6] = (raw[6] & 0x0f) | 0x40
+ raw[8] = (raw[8] & 0x3f) | 0x80
+ encoded := hex.EncodeToString(raw[:])
+ return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32]
+}
diff --git a/internal/alert/types_test.go b/internal/alert/types_test.go
new file mode 100644
index 0000000..7dd245d
--- /dev/null
+++ b/internal/alert/types_test.go
@@ -0,0 +1,87 @@
+package alert
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/itworx/pulse/internal/metriccatalog"
+)
+
+func validDocument(t *testing.T) (Document, metriccatalog.Registry) {
+ t.Helper()
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return Document{
+ SchemaVersion: 1, ID: NewID(), Name: "CPU aandacht", Enabled: false, Severity: SeverityAttention,
+ Scope: map[string]any{"entityType": "host"},
+ Condition: Condition{InputType: "metric", Metric: registry.Metrics()[0].SemanticName, Operator: ">", Threshold: float64(80), Aggregation: "avg", WindowSeconds: 60},
+ EvaluationIntervalSeconds: 30, PendingSeconds: 60, ResolveSeconds: 120, CooldownSeconds: 60, UnknownBehavior: UnknownRetain,
+ GroupBy: []string{"instance"}, SuppressWhen: []string{"maintenance"}, Message: Message{TitleKey: "alerts.cpu.title", BodyKey: "alerts.cpu.body"},
+ }, registry
+}
+
+func TestDocumentValidationRejectsRawPromQLAndUnknownMetric(t *testing.T) {
+ document, registry := validDocument(t)
+ document.Condition.Metric = "rate(node_cpu_seconds_total[5m])"
+ if !errors.Is(document.Validate(registry), ErrInvalidRule) {
+ t.Fatal("expected raw query rejection")
+ }
+ document.Condition.Metric = "pulse.not_in_catalog"
+ if !errors.Is(document.Validate(registry), ErrInvalidRule) {
+ t.Fatal("expected unknown metric rejection")
+ }
+}
+
+func TestPreviewHasDeterministicNoSideEffectEvaluation(t *testing.T) {
+ document, registry := validDocument(t)
+ result, err := Preview(document, PreviewRequest{Value: float64(90)}, registry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !result.WouldFire || result.State != "firing" || result.Reason != "condition_evaluated" {
+ t.Fatalf("unexpected preview: %#v", result)
+ }
+ unknown, err := Preview(document, PreviewRequest{Unknown: true}, registry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if unknown.WouldFire || unknown.State != "unknown" || unknown.Reason != "unknown_input_retains_last_state" {
+ t.Fatalf("unexpected unknown preview: %#v", unknown)
+ }
+}
+
+func TestDecodeDocumentRejectsUnknownFields(t *testing.T) {
+ _, registry := validDocument(t)
+ if _, err := DecodeDocument([]byte("{\"schemaVersion\":1,\"id\":\""+NewID()+"\",\"name\":\"x\",\"unsafeQuery\":\"rate(foo[5m])\"}"), registry); !errors.Is(err, ErrInvalidRule) {
+ t.Fatal("expected unknown field rejection")
+ }
+}
+
+func TestConditionEvaluatorAppliesDiskTemperatureHysteresisAtBoundaries(t *testing.T) {
+ document, registry := validDocument(t)
+ document.Condition.Operator = ">="
+ document.Condition.Threshold = float64(50)
+ recovery := float64(46)
+ document.Condition.RecoveryThreshold = &recovery
+ if err := document.Validate(registry); err != nil {
+ t.Fatal(err)
+ }
+ if firing, err := document.Condition.Evaluate(float64(49), false); err != nil || firing {
+ t.Fatalf("activation at 49 = %v, %v; want false", firing, err)
+ }
+ for _, value := range []float64{49, 46} {
+ firing, err := document.Condition.Evaluate(value, true)
+ if err != nil || !firing {
+ t.Fatalf("active evaluation at %v = %v, %v; want true", value, firing, err)
+ }
+ }
+ if firing, err := document.Condition.Evaluate(float64(45.999), true); err != nil || firing {
+ t.Fatalf("recovery at 45.999 = %v, %v; want false", firing, err)
+ }
+ recovery = 50
+ if err := document.Validate(registry); !errors.Is(err, ErrInvalidRule) {
+ t.Fatalf("inverted recovery threshold error = %v, want ErrInvalidRule", err)
+ }
+}
diff --git a/internal/alertapi/handler.go b/internal/alertapi/handler.go
new file mode 100644
index 0000000..203a80c
--- /dev/null
+++ b/internal/alertapi/handler.go
@@ -0,0 +1,293 @@
+package alertapi
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/correlation"
+ "github.com/itworx/pulse/internal/metriccatalog"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Handler struct {
+ Repository alert.Store
+ Registry metriccatalog.Registry
+ Audit audit.Store
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ principal, ok := auth.PrincipalFromContext(r.Context())
+ if !ok {
+ fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
+ return
+ }
+ path := strings.TrimPrefix(r.URL.Path, "/api/v1/alert-rules")
+ if path == "" || path == "/" {
+ switch r.Method {
+ case http.MethodGet:
+ h.list(w, r)
+ case http.MethodPost:
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.create(w, r, principal.Subject)
+ default:
+ fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
+ }
+ return
+ }
+ parts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(parts) < 1 || parts[0] == "" || len(parts) > 2 {
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert rule route not found.")
+ return
+ }
+ id := parts[0]
+ if len(parts) == 1 && r.Method == http.MethodGet {
+ h.get(w, r, id)
+ return
+ }
+ if len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodGet {
+ h.versions(w, r, id)
+ return
+ }
+ if len(parts) == 2 && parts[1] == "test" && r.Method == http.MethodPost {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.test(w, r, id)
+ return
+ }
+ if len(parts) == 2 && (parts[1] == "enable" || parts[1] == "disable") && r.Method == http.MethodPost {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.setEnabled(w, r, id, principal.Subject, parts[1] == "enable")
+ return
+ }
+ if len(parts) == 1 && r.Method == http.MethodPut {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.update(w, r, id, principal.Subject)
+ return
+ }
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert rule route not found.")
+}
+
+func (h Handler) list(w http.ResponseWriter, r *http.Request) {
+ limit := 100
+ if value := r.URL.Query().Get("limit"); value != "" {
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed < 1 || parsed > 100 {
+ fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The alert-rule limit must be between 1 and 100.")
+ return
+ }
+ limit = parsed
+ }
+ items, err := h.Repository.List(r.Context(), limit)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Alert rules are unavailable.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"items": items})
+}
+
+func (h Handler) get(w http.ResponseWriter, r *http.Request, id string) {
+ item, err := h.Repository.Get(r.Context(), id)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Alert rule not found.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"rule": item})
+}
+
+func (h Handler) versions(w http.ResponseWriter, r *http.Request, id string) {
+ limit := 100
+ if value := r.URL.Query().Get("limit"); value != "" {
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed < 1 || parsed > 100 {
+ fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The version limit must be between 1 and 100.")
+ return
+ }
+ limit = parsed
+ }
+ items, err := h.Repository.Versions(r.Context(), id, limit)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Alert-rule versions are unavailable.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"items": items})
+}
+
+func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string) {
+ var document alert.Document
+ if err := decode(r, &document); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_RULE", "The alert-rule document is invalid.")
+ return
+ }
+ if document.ID == "" {
+ document.ID = alert.NewID()
+ }
+ rule, version, err := h.Repository.Create(r.Context(), actor, document, "initial version")
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Alert rule could not be created.")
+ return
+ }
+ if err := h.record(r, actor, "alert_rule.create", rule.ID, nil, map[string]any{"revision": rule.Revision, "version": version.VersionNumber}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusCreated, map[string]any{"rule": rule, "version": version})
+}
+
+func (h Handler) update(w http.ResponseWriter, r *http.Request, id, actor string) {
+ expected, err := revision(r)
+ if err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
+ return
+ }
+ var document alert.Document
+ if err := decode(r, &document); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_RULE", "The alert-rule document is invalid.")
+ return
+ }
+ updated, err := h.Repository.Update(r.Context(), id, actor, expected, document, "rule update")
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Alert rule update failed.")
+ return
+ }
+ if err := h.record(r, actor, "alert_rule.update", id, map[string]any{"revision": expected}, map[string]any{"revision": updated.Revision, "version": updated.CurrentVersion}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"rule": updated})
+}
+
+func (h Handler) setEnabled(w http.ResponseWriter, r *http.Request, id, actor string, enabled bool) {
+ expected, err := revision(r)
+ if err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
+ return
+ }
+ updated, err := h.Repository.SetEnabled(r.Context(), id, expected, enabled)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Alert rule state update failed.")
+ return
+ }
+ action := "alert_rule.disable"
+ if enabled {
+ action = "alert_rule.enable"
+ }
+ if err := h.record(r, actor, action, id, map[string]any{"enabled": !enabled, "revision": expected}, map[string]any{"enabled": enabled, "revision": updated.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"rule": updated})
+}
+
+func (h Handler) test(w http.ResponseWriter, r *http.Request, id string) {
+ var request struct {
+ Rule *alert.Document `json:"rule"`
+ Value any `json:"value"`
+ Unknown bool `json:"unknown"`
+ }
+ if err := decode(r, &request); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The alert-rule preview request is invalid.")
+ return
+ }
+ document := request.Rule
+ if document == nil {
+ current, err := h.Repository.Get(r.Context(), id)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Alert rule preview is unavailable.")
+ return
+ }
+ document = ¤t.Document
+ }
+ result, err := alert.Preview(*document, alert.PreviewRequest{Value: request.Value, Unknown: request.Unknown}, h.Registry)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "The alert-rule preview is invalid.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"preview": result})
+}
+
+func (h Handler) record(r *http.Request, actor, action, resourceID string, before, after map[string]any) error {
+ if h.Audit == nil {
+ return nil
+ }
+ return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "alert_rule", ResourceID: resourceID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after})
+}
+
+func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error, fallback string) {
+ switch {
+ case errors.Is(err, alert.ErrInvalidRule):
+ fail(w, r, http.StatusBadRequest, "INVALID_RULE", fallback)
+ case errors.Is(err, alert.ErrConflict):
+ fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert rule was changed by another request.")
+ case errors.Is(err, alert.ErrNotFound):
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", fallback)
+ case errors.Is(err, alert.ErrUnavailable):
+ fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", fallback)
+ default:
+ fail(w, r, http.StatusInternalServerError, "ALERT_RULE_REQUEST_FAILED", fallback)
+ }
+}
+
+func requireEdit(w http.ResponseWriter, r *http.Request, role auth.Role) bool {
+ if auth.Allows(role, auth.PermissionEdit) {
+ return true
+ }
+ fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert-rule editing is not allowed for this role.")
+ return false
+}
+
+func decode(r *http.Request, target any) error {
+ contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
+ if contentType != "" && contentType != "application/json" {
+ return errors.New("unsupported content type")
+ }
+ body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1))
+ if err != nil {
+ return err
+ }
+ defer r.Body.Close()
+ if len(body) > 2<<20 {
+ return errors.New("request too large")
+ }
+ decoder := json.NewDecoder(strings.NewReader(string(body)))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return err
+ }
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ return errors.New("multiple JSON values")
+ }
+ return nil
+}
+
+func revision(r *http.Request) (int64, error) {
+ value := r.Header.Get("If-Match")
+ if value == "" {
+ value = r.URL.Query().Get("revision")
+ }
+ return strconv.ParseInt(strings.Trim(value, "\""), 10, 64)
+}
+
+func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
+ problem.Write(w, r, status, code, http.StatusText(status), detail, nil)
+}
+
+func write(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
diff --git a/internal/alertapi/handler_test.go b/internal/alertapi/handler_test.go
new file mode 100644
index 0000000..51f3831
--- /dev/null
+++ b/internal/alertapi/handler_test.go
@@ -0,0 +1,121 @@
+package alertapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/metriccatalog"
+)
+
+type fakeStore struct {
+ rule alert.Rule
+ getCalls, createCalls, updateCalls, toggleCalls int
+}
+
+func (f *fakeStore) Create(_ context.Context, _ string, document alert.Document, _ string) (alert.Rule, alert.Version, error) {
+ f.createCalls++
+ f.rule = alert.Rule{Document: document, Revision: 1, CurrentVersion: 1}
+ return f.rule, alert.Version{RuleID: document.ID, VersionNumber: 1, Document: document}, nil
+}
+func (f *fakeStore) Get(_ context.Context, _ string) (alert.Rule, error) {
+ f.getCalls++
+ return f.rule, nil
+}
+func (f *fakeStore) List(_ context.Context, _ int) ([]alert.Rule, error) {
+ return []alert.Rule{f.rule}, nil
+}
+func (f *fakeStore) Update(_ context.Context, id, _ string, _ int64, document alert.Document, _ string) (alert.Rule, error) {
+ f.updateCalls++
+ document.ID = id
+ f.rule.Document = document
+ f.rule.Revision++
+ return f.rule, nil
+}
+func (f *fakeStore) Versions(_ context.Context, _ string, _ int) ([]alert.Version, error) {
+ return []alert.Version{{Document: f.rule.Document, VersionNumber: 1}}, nil
+}
+func (f *fakeStore) SetEnabled(_ context.Context, _ string, _ int64, enabled bool) (alert.Rule, error) {
+ f.toggleCalls++
+ f.rule.Enabled = enabled
+ f.rule.Document.Enabled = enabled
+ f.rule.Revision++
+ return f.rule, nil
+}
+
+func requestWithPrincipal(method, path string, body any, role auth.Role) *http.Request {
+ data, _ := json.Marshal(body)
+ request := httptest.NewRequest(method, path, strings.NewReader(string(data)))
+ request.Header.Set("Content-Type", "application/json")
+ return request.WithContext(auth.WithPrincipal(request.Context(), auth.Principal{Subject: "editor-1", Role: role}))
+}
+
+func TestViewerCannotCreateAlertRule(t *testing.T) {
+ document, registry := validDocumentForHandler(t)
+ store := &fakeStore{}
+ handler := Handler{Repository: store, Registry: registry}
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, requestWithPrincipal(http.MethodPost, "/api/v1/alert-rules", document, auth.RoleViewer))
+ if response.Code != http.StatusForbidden || store.createCalls != 0 {
+ t.Fatalf("status=%d creates=%d", response.Code, store.createCalls)
+ }
+}
+
+func TestPreviewDoesNotWriteOrAudit(t *testing.T) {
+ document, registry := validDocumentForHandler(t)
+ store := &fakeStore{}
+ auditStore := &audit.MemoryStore{}
+ handler := Handler{Repository: store, Registry: registry, Audit: auditStore}
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, requestWithPrincipal(http.MethodPost, "/api/v1/alert-rules/"+document.ID+"/test", map[string]any{"rule": document, "value": 90}, auth.RoleEditor))
+ if response.Code != http.StatusOK {
+ t.Fatalf("preview status=%d body=%s", response.Code, response.Body.String())
+ }
+ if store.createCalls != 0 || store.updateCalls != 0 || store.toggleCalls != 0 || len(auditStore.Events) != 0 {
+ t.Fatalf("preview had side effects: store=%#v audit=%d", store, len(auditStore.Events))
+ }
+ var body map[string]any
+ if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["preview"] == nil {
+ t.Fatal("preview result missing")
+ }
+}
+
+func TestEnableIsAudited(t *testing.T) {
+ document, registry := validDocumentForHandler(t)
+ store := &fakeStore{rule: alert.Rule{Document: document, Revision: 1}}
+ auditStore := &audit.MemoryStore{}
+ handler := Handler{Repository: store, Registry: registry, Audit: auditStore}
+ request := requestWithPrincipal(http.MethodPost, "/api/v1/alert-rules/"+document.ID+"/enable?revision=1", map[string]any{}, auth.RoleEditor)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != http.StatusOK || store.toggleCalls != 1 {
+ t.Fatalf("status=%d toggles=%d", response.Code, store.toggleCalls)
+ }
+ if len(auditStore.Events) != 1 || auditStore.Events[0].Action != "alert_rule.enable" {
+ t.Fatalf("audit=%#v", auditStore.Events)
+ }
+}
+
+func validDocumentForHandler(t *testing.T) (alert.Document, metriccatalog.Registry) {
+ t.Helper()
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return alert.Document{
+ SchemaVersion: 1, ID: alert.NewID(), Name: "CPU aandacht", Severity: alert.SeverityAttention,
+ Scope: map[string]any{"entityType": "host"},
+ Condition: alert.Condition{InputType: "metric", Metric: registry.Metrics()[0].SemanticName, Operator: ">", Threshold: float64(80)},
+ EvaluationIntervalSeconds: 30, UnknownBehavior: alert.UnknownRetain,
+ Message: alert.Message{TitleKey: "alerts.cpu.title", BodyKey: "alerts.cpu.body"},
+ }, registry
+}
diff --git a/internal/alertcontrol/expiry.go b/internal/alertcontrol/expiry.go
new file mode 100644
index 0000000..c8867cf
--- /dev/null
+++ b/internal/alertcontrol/expiry.go
@@ -0,0 +1,18 @@
+package alertcontrol
+
+import (
+ "context"
+ "time"
+)
+
+type ExpiryJob struct {
+ Store Store
+ Now func() time.Time
+}
+
+func (job ExpiryJob) Run(ctx context.Context) (ExpiryResult, error) {
+ if job.Now == nil {
+ job.Now = time.Now
+ }
+ return job.Store.Expire(ctx, job.Now().UTC())
+}
diff --git a/internal/alertcontrol/repository.go b/internal/alertcontrol/repository.go
new file mode 100644
index 0000000..4f4b632
--- /dev/null
+++ b/internal/alertcontrol/repository.go
@@ -0,0 +1,342 @@
+package alertcontrol
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Store interface {
+ CreateSilence(context.Context, string, Silence) (Silence, error)
+ ListSilences(context.Context, int, time.Time) ([]Silence, error)
+ RevokeSilence(context.Context, string, string, int64, time.Time) (Silence, error)
+ CreateMaintenance(context.Context, string, MaintenanceWindow) (MaintenanceWindow, error)
+ ListMaintenance(context.Context, int, time.Time) ([]MaintenanceWindow, error)
+ RevokeMaintenance(context.Context, string, string, int64, time.Time) (MaintenanceWindow, error)
+ Expire(context.Context, time.Time) (ExpiryResult, error)
+}
+
+type Repository struct{ Pool *pgxpool.Pool }
+
+type ExpiryResult struct {
+ Silences int `json:"silences"`
+ MaintenanceWindows int `json:"maintenanceWindows"`
+}
+
+func (r Repository) CreateSilence(ctx context.Context, actor string, silence Silence) (Silence, error) {
+ if r.Pool == nil {
+ return Silence{}, ErrUnavailable
+ }
+ if actor == "" {
+ return Silence{}, fmt.Errorf("%w: creator is required", ErrInvalid)
+ }
+ if silence.ID == "" {
+ silence.ID = NewID()
+ }
+ if silence.Owner == "" {
+ silence.Owner = actor
+ }
+ if err := silence.Validate(time.Now().UTC()); err != nil {
+ return Silence{}, err
+ }
+ matcher, err := json.Marshal(silence.Matchers)
+ if err != nil {
+ return Silence{}, fmt.Errorf("marshal silence matcher: %w", err)
+ }
+ if silence.Owner == "" {
+ silence.Owner = actor
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Silence{}, fmt.Errorf("begin silence create: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ _, err = tx.Exec(ctx, `INSERT INTO alert_silences (id,name,reason,owner,matchers,starts_at,expires_at,created_by) VALUES ($1::uuid,$2,$3,$4,$5::jsonb,$6,$7,$8)`, silence.ID, silence.Name, silence.Reason, silence.Owner, matcher, silence.StartsAt.UTC(), silence.ExpiresAt.UTC(), actor)
+ if err != nil {
+ return Silence{}, mapError(fmt.Errorf("create silence: %w", err))
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return Silence{}, fmt.Errorf("commit silence create: %w", err)
+ }
+ return r.getSilence(ctx, silence.ID, time.Now().UTC())
+}
+
+func (r Repository) ListSilences(ctx context.Context, limit int, now time.Time) ([]Silence, error) {
+ if r.Pool == nil {
+ return nil, ErrUnavailable
+ }
+ if limit < 1 || limit > 100 {
+ return nil, fmt.Errorf("%w: invalid list limit", ErrInvalid)
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT id,name,reason,owner,matchers,starts_at,expires_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision FROM alert_silences ORDER BY starts_at DESC,id DESC LIMIT $1`, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list silences: %w", err)
+ }
+ defer rows.Close()
+ items := make([]Silence, 0)
+ for rows.Next() {
+ item, err := scanSilence(rows)
+ if err != nil {
+ return nil, err
+ }
+ item.State = item.StateAt(now.UTC())
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate silences: %w", err)
+ }
+ return items, nil
+}
+
+func (r Repository) RevokeSilence(ctx context.Context, id, actor string, expected int64, now time.Time) (Silence, error) {
+ if r.Pool == nil {
+ return Silence{}, ErrUnavailable
+ }
+ if id == "" || actor == "" {
+ return Silence{}, fmt.Errorf("%w: id and actor are required", ErrInvalid)
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Silence{}, fmt.Errorf("begin silence revoke: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var query string
+ var args []any
+ if expected > 0 {
+ query = `UPDATE alert_silences SET status='revoked',revoked_by=$2,revoked_at=$3,revision=revision+1 WHERE id=$1 AND status='active' AND revision=$4`
+ args = []any{id, actor, now.UTC(), expected}
+ } else {
+ query = `UPDATE alert_silences SET status='revoked',revoked_by=$2,revoked_at=$3,revision=revision+1 WHERE id=$1 AND status='active'`
+ args = []any{id, actor, now.UTC()}
+ }
+ result, err := tx.Exec(ctx, query, args...)
+ if err != nil {
+ return Silence{}, mapError(fmt.Errorf("revoke silence: %w", err))
+ }
+ if result.RowsAffected() == 0 {
+ return Silence{}, r.revokeFailure(ctx, tx, id, expected, true)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return Silence{}, fmt.Errorf("commit silence revoke: %w", err)
+ }
+ return r.getSilence(ctx, id, now.UTC())
+}
+
+func (r Repository) CreateMaintenance(ctx context.Context, actor string, window MaintenanceWindow) (MaintenanceWindow, error) {
+ if r.Pool == nil {
+ return MaintenanceWindow{}, ErrUnavailable
+ }
+ if actor == "" {
+ return MaintenanceWindow{}, fmt.Errorf("%w: creator is required", ErrInvalid)
+ }
+ if window.ID == "" {
+ window.ID = NewID()
+ }
+ if err := window.Validate(time.Now().UTC()); err != nil {
+ return MaintenanceWindow{}, err
+ }
+ selector, err := json.Marshal(window.Selector)
+ if err != nil {
+ return MaintenanceWindow{}, fmt.Errorf("marshal maintenance selector: %w", err)
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return MaintenanceWindow{}, fmt.Errorf("begin maintenance create: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ _, err = tx.Exec(ctx, `INSERT INTO maintenance_windows (id,name,reason,selector,starts_at,ends_at,created_by) VALUES ($1::uuid,$2,$3,$4::jsonb,$5,$6,$7)`, window.ID, window.Name, window.Reason, selector, window.StartsAt.UTC(), window.EndsAt.UTC(), actor)
+ if err != nil {
+ return MaintenanceWindow{}, mapError(fmt.Errorf("create maintenance window: %w", err))
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return MaintenanceWindow{}, fmt.Errorf("commit maintenance create: %w", err)
+ }
+ return r.getMaintenance(ctx, window.ID, time.Now().UTC())
+}
+
+func (r Repository) ListMaintenance(ctx context.Context, limit int, now time.Time) ([]MaintenanceWindow, error) {
+ if r.Pool == nil {
+ return nil, ErrUnavailable
+ }
+ if limit < 1 || limit > 100 {
+ return nil, fmt.Errorf("%w: invalid list limit", ErrInvalid)
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT id,name,reason,selector,starts_at,ends_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision FROM maintenance_windows ORDER BY starts_at DESC,id DESC LIMIT $1`, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list maintenance windows: %w", err)
+ }
+ defer rows.Close()
+ items := make([]MaintenanceWindow, 0)
+ for rows.Next() {
+ item, err := scanMaintenance(rows)
+ if err != nil {
+ return nil, err
+ }
+ item.State = item.StateAt(now.UTC())
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate maintenance windows: %w", err)
+ }
+ return items, nil
+}
+
+func (r Repository) RevokeMaintenance(ctx context.Context, id, actor string, expected int64, now time.Time) (MaintenanceWindow, error) {
+ if r.Pool == nil {
+ return MaintenanceWindow{}, ErrUnavailable
+ }
+ if id == "" || actor == "" {
+ return MaintenanceWindow{}, fmt.Errorf("%w: id and actor are required", ErrInvalid)
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return MaintenanceWindow{}, fmt.Errorf("begin maintenance revoke: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var query string
+ var args []any
+ if expected > 0 {
+ query = `UPDATE maintenance_windows SET status='revoked',revoked_by=$2,revoked_at=$3,revision=revision+1 WHERE id=$1 AND status='active' AND revision=$4`
+ args = []any{id, actor, now.UTC(), expected}
+ } else {
+ query = `UPDATE maintenance_windows SET status='revoked',revoked_by=$2,revoked_at=$3,revision=revision+1 WHERE id=$1 AND status='active'`
+ args = []any{id, actor, now.UTC()}
+ }
+ result, err := tx.Exec(ctx, query, args...)
+ if err != nil {
+ return MaintenanceWindow{}, mapError(fmt.Errorf("revoke maintenance window: %w", err))
+ }
+ if result.RowsAffected() == 0 {
+ return MaintenanceWindow{}, r.revokeFailure(ctx, tx, id, expected, false)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return MaintenanceWindow{}, fmt.Errorf("commit maintenance revoke: %w", err)
+ }
+ return r.getMaintenance(ctx, id, now.UTC())
+}
+
+func (r Repository) Expire(ctx context.Context, now time.Time) (ExpiryResult, error) {
+ if r.Pool == nil {
+ return ExpiryResult{}, ErrUnavailable
+ }
+ now = now.UTC()
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return ExpiryResult{}, fmt.Errorf("begin control expiry: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var result ExpiryResult
+ if err := tx.QueryRow(ctx, `WITH expired AS (UPDATE alert_silences SET status='expired',expired_at=$1,revision=revision+1 WHERE status='active' AND expires_at <= $1 RETURNING id) SELECT count(*) FROM expired`, now).Scan(&result.Silences); err != nil {
+ return ExpiryResult{}, fmt.Errorf("expire silences: %w", err)
+ }
+ if err := tx.QueryRow(ctx, `WITH expired AS (UPDATE maintenance_windows SET status='expired',expired_at=$1,revision=revision+1 WHERE status='active' AND ends_at <= $1 RETURNING id) SELECT count(*) FROM expired`, now).Scan(&result.MaintenanceWindows); err != nil {
+ return ExpiryResult{}, fmt.Errorf("expire maintenance windows: %w", err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ExpiryResult{}, fmt.Errorf("commit control expiry: %w", err)
+ }
+ return result, nil
+}
+
+func (r Repository) getSilence(ctx context.Context, id string, now time.Time) (Silence, error) {
+ var item Silence
+ row := r.Pool.QueryRow(ctx, `SELECT id,name,reason,owner,matchers,starts_at,expires_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision FROM alert_silences WHERE id=$1`, id)
+ scanned, err := scanSilence(row)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Silence{}, ErrNotFound
+ }
+ if err != nil {
+ return Silence{}, fmt.Errorf("get silence: %w", err)
+ }
+ item = scanned
+ item.State = item.StateAt(now.UTC())
+ return item, nil
+}
+func (r Repository) getMaintenance(ctx context.Context, id string, now time.Time) (MaintenanceWindow, error) {
+ row := r.Pool.QueryRow(ctx, `SELECT id,name,reason,selector,starts_at,ends_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision FROM maintenance_windows WHERE id=$1`, id)
+ item, err := scanMaintenance(row)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return MaintenanceWindow{}, ErrNotFound
+ }
+ if err != nil {
+ return MaintenanceWindow{}, fmt.Errorf("get maintenance window: %w", err)
+ }
+ item.State = item.StateAt(now.UTC())
+ return item, nil
+}
+
+type rowScanner interface{ Scan(...any) error }
+
+func scanSilence(row rowScanner) (Silence, error) {
+ var item Silence
+ var matcher []byte
+ var status string
+ var revokedBy, createdBy *string
+ if err := row.Scan(&item.ID, &item.Name, &item.Reason, &item.Owner, &matcher, &item.StartsAt, &item.ExpiresAt, &status, &createdBy, &item.CreatedAt, &revokedBy, &item.RevokedAt, &item.ExpiredAt, &item.Revision); err != nil {
+ return Silence{}, err
+ }
+ if err := json.Unmarshal(matcher, &item.Matchers); err != nil {
+ return Silence{}, fmt.Errorf("decode silence matcher: %w", err)
+ }
+ item.CreatedBy = valueOrEmpty(createdBy)
+ item.RevokedBy = valueOrEmpty(revokedBy)
+ return item, nil
+}
+func scanMaintenance(row rowScanner) (MaintenanceWindow, error) {
+ var item MaintenanceWindow
+ var selector []byte
+ var status string
+ var revokedBy, createdBy *string
+ if err := row.Scan(&item.ID, &item.Name, &item.Reason, &selector, &item.StartsAt, &item.EndsAt, &status, &createdBy, &item.CreatedAt, &revokedBy, &item.RevokedAt, &item.ExpiredAt, &item.Revision); err != nil {
+ return MaintenanceWindow{}, err
+ }
+ if err := json.Unmarshal(selector, &item.Selector); err != nil {
+ return MaintenanceWindow{}, fmt.Errorf("decode maintenance selector: %w", err)
+ }
+ item.CreatedBy = valueOrEmpty(createdBy)
+ item.RevokedBy = valueOrEmpty(revokedBy)
+ return item, nil
+}
+func valueOrEmpty(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
+func (r Repository) revokeFailure(ctx context.Context, tx pgx.Tx, id string, expected int64, silence bool) error {
+ var revision int64
+ var status string
+ table := "maintenance_windows"
+ if silence {
+ table = "alert_silences"
+ }
+ err := tx.QueryRow(ctx, "SELECT revision,status FROM "+table+" WHERE id=$1 FOR UPDATE", id).Scan(&revision, &status)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrNotFound
+ }
+ if err != nil {
+ return fmt.Errorf("inspect control revoke: %w", err)
+ }
+ if expected > 0 && revision != expected {
+ return ErrConflict
+ }
+ return fmt.Errorf("%w: control is %s", ErrConflict, status)
+}
+func mapError(err error) error {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) {
+ switch pgErr.Code {
+ case "23505":
+ return fmt.Errorf("%w: duplicate control", ErrConflict)
+ case "23514", "22P02":
+ return fmt.Errorf("%w: database constraint", ErrInvalid)
+ }
+ }
+ return err
+}
diff --git a/internal/alertcontrol/repository_test.go b/internal/alertcontrol/repository_test.go
new file mode 100644
index 0000000..d123eb8
--- /dev/null
+++ b/internal/alertcontrol/repository_test.go
@@ -0,0 +1,121 @@
+package alertcontrol
+
+import (
+ "context"
+ "os"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func TestPostgreSQLControlsAreExpiringAuditedAndIdempotent(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ repo := Repository{Pool: pool}
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ silenceID, maintenanceID, expiringID := NewID(), NewID(), NewID()
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cleanupCancel()
+ _, _ = pool.Exec(cleanupCtx, `DELETE FROM alert_silences WHERE id = ANY($1::uuid[])`, []string{silenceID, expiringID})
+ _, _ = pool.Exec(cleanupCtx, `DELETE FROM maintenance_windows WHERE id = $1::uuid`, maintenanceID)
+ })
+
+ silence, err := repo.CreateSilence(ctx, "operator-1", Silence{ID: silenceID, Name: "planned deploy", Reason: "change window", Owner: "operator-1", Matchers: Matcher{Severities: []string{"critical"}}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if silence.State != StateActive || silence.Revision != 1 {
+ t.Fatalf("unexpected silence: %#v", silence)
+ }
+ window, err := repo.CreateMaintenance(ctx, "operator-1", MaintenanceWindow{ID: maintenanceID, Name: "maintenance", Reason: "firmware", Selector: Matcher{EntityTypes: []string{"host"}}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if window.State != StateActive {
+ t.Fatalf("maintenance state = %s", window.State)
+ }
+ items, err := repo.ListSilences(ctx, 100, now)
+ if err != nil || len(items) != 1 {
+ t.Fatalf("list silences: %d, %v", len(items), err)
+ }
+
+ expiring, err := repo.CreateSilence(ctx, "operator-1", Silence{ID: expiringID, Name: "short", Reason: "test expiry", Owner: "operator-1", Matchers: Matcher{}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(-time.Second)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if expiring.State != StateExpired {
+ t.Fatalf("expired control before job = %s", expiring.State)
+ }
+ first, err := repo.Expire(ctx, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := repo.Expire(ctx, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.Silences != 1 || first.MaintenanceWindows != 0 || second != (ExpiryResult{}) {
+ t.Fatalf("expiry not idempotent: first=%#v second=%#v", first, second)
+ }
+
+ concurrentID := NewID()
+ defer func() {
+ _, _ = pool.Exec(context.Background(), `DELETE FROM alert_silences WHERE id=$1::uuid`, concurrentID)
+ }()
+ if _, err := repo.CreateSilence(ctx, "operator-1", Silence{ID: concurrentID, Name: "concurrent", Reason: "test", Owner: "operator-1", Matchers: Matcher{}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(-time.Second)}); err != nil {
+ t.Fatal(err)
+ }
+ var wg sync.WaitGroup
+ results := make(chan ExpiryResult, 2)
+ errorsCh := make(chan error, 2)
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ result, err := repo.Expire(ctx, now.Add(time.Second))
+ if err != nil {
+ errorsCh <- err
+ return
+ }
+ results <- result
+ }()
+ }
+ wg.Wait()
+ close(results)
+ close(errorsCh)
+ total := 0
+ for result := range results {
+ total += result.Silences + result.MaintenanceWindows
+ }
+ for err := range errorsCh {
+ t.Fatal(err)
+ }
+ if total != 1 {
+ t.Fatalf("concurrent expiry count = %d, want 1", total)
+ }
+
+ if _, err := repo.CreateSilence(ctx, "operator-1", Silence{ID: silenceID, Name: "duplicate", Reason: "duplicate", Owner: "operator-1", Matchers: Matcher{}, StartsAt: now, ExpiresAt: now.Add(time.Hour)}); err == nil {
+ t.Fatal("duplicate silence should fail")
+ }
+ if _, err := repo.RevokeSilence(ctx, silenceID, "operator-1", silence.Revision, now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := repo.RevokeSilence(ctx, silenceID, "operator-1", silence.Revision+1, now); err == nil {
+ t.Fatal("repeated revoke should conflict")
+ }
+}
diff --git a/internal/alertcontrol/runner.go b/internal/alertcontrol/runner.go
new file mode 100644
index 0000000..9816c19
--- /dev/null
+++ b/internal/alertcontrol/runner.go
@@ -0,0 +1,26 @@
+package alertcontrol
+
+import (
+ "context"
+ "log/slog"
+ "time"
+)
+
+func RunExpiryLoop(ctx context.Context, store Store, interval time.Duration, logger *slog.Logger) {
+ if store == nil || interval <= 0 {
+ return
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ job := ExpiryJob{Store: store}
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if _, err := job.Run(ctx); err != nil && logger != nil {
+ logger.Warn("alert control expiry failed", "error", err)
+ }
+ }
+ }
+}
diff --git a/internal/alertcontrol/types.go b/internal/alertcontrol/types.go
new file mode 100644
index 0000000..b460d46
--- /dev/null
+++ b/internal/alertcontrol/types.go
@@ -0,0 +1,245 @@
+package alertcontrol
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+)
+
+const (
+ MaxName = 160
+ MaxReason = 500
+ MaxOwner = 255
+ MaxMatcherKeys = 20
+ MaxMatcherValues = 50
+ MaxDuration = 365 * 24 * time.Hour
+)
+
+var (
+ ErrInvalid = errors.New("invalid alert control")
+ ErrNotFound = errors.New("alert control not found")
+ ErrUnavailable = errors.New("alert control repository is unavailable")
+ ErrConflict = errors.New("alert control has already changed")
+ keyPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_.:/-]{0,63}$`)
+)
+
+type Matcher struct {
+ RuleIDs []string `json:"ruleIds,omitempty"`
+ EntityIDs []string `json:"entityIds,omitempty"`
+ EntityTypes []string `json:"entityTypes,omitempty"`
+ Severities []string `json:"severities,omitempty"`
+ Labels map[string]string `json:"labels,omitempty"`
+}
+
+type Signal struct {
+ InstanceID string `json:"instanceId"`
+ RuleID string `json:"ruleId"`
+ EntityID string `json:"entityId,omitempty"`
+ Severity string `json:"severity"`
+ Labels map[string]string `json:"labels,omitempty"`
+}
+
+type State string
+
+const (
+ StateScheduled State = "scheduled"
+ StateActive State = "active"
+ StateExpired State = "expired"
+ StateRevoked State = "revoked"
+)
+
+type Silence struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Reason string `json:"reason"`
+ Owner string `json:"owner"`
+ Matchers Matcher `json:"matchers"`
+ StartsAt time.Time `json:"startsAt"`
+ ExpiresAt time.Time `json:"expiresAt"`
+ State State `json:"state"`
+ CreatedBy string `json:"createdBy"`
+ CreatedAt time.Time `json:"createdAt"`
+ RevokedBy string `json:"revokedBy,omitempty"`
+ RevokedAt *time.Time `json:"revokedAt,omitempty"`
+ ExpiredAt *time.Time `json:"expiredAt,omitempty"`
+ Revision int64 `json:"revision"`
+}
+
+type MaintenanceWindow struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Reason string `json:"reason"`
+ Selector Matcher `json:"selector"`
+ StartsAt time.Time `json:"startsAt"`
+ EndsAt time.Time `json:"endsAt"`
+ State State `json:"state"`
+ CreatedBy string `json:"createdBy"`
+ CreatedAt time.Time `json:"createdAt"`
+ RevokedBy string `json:"revokedBy,omitempty"`
+ RevokedAt *time.Time `json:"revokedAt,omitempty"`
+ ExpiredAt *time.Time `json:"expiredAt,omitempty"`
+ Revision int64 `json:"revision"`
+}
+
+type Preview struct {
+ Matched bool `json:"matched"`
+ MatchedCount int `json:"matchedCount"`
+ InstanceIDs []string `json:"instanceIds"`
+}
+
+func NewID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "00000000-0000-4000-8000-000000000000"
+ }
+ b[6] = (b[6] & 0x0f) | 0x40
+ b[8] = (b[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%s-%s-%s-%s-%s", hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]), hex.EncodeToString(b[6:8]), hex.EncodeToString(b[8:10]), hex.EncodeToString(b[10:16]))
+}
+
+func (m Matcher) Validate() error {
+ count := len(m.RuleIDs) + len(m.EntityIDs) + len(m.EntityTypes) + len(m.Severities) + len(m.Labels)
+ if count > MaxMatcherKeys {
+ return fmt.Errorf("%w: too many matcher keys", ErrInvalid)
+ }
+ for _, values := range [][]string{m.RuleIDs, m.EntityIDs, m.EntityTypes, m.Severities} {
+ if len(values) > MaxMatcherValues {
+ return fmt.Errorf("%w: too many matcher values", ErrInvalid)
+ }
+ seen := make(map[string]struct{}, len(values))
+ for _, value := range values {
+ if err := validateValue(value); err != nil {
+ return err
+ }
+ if _, ok := seen[value]; ok {
+ return fmt.Errorf("%w: duplicate matcher value", ErrInvalid)
+ }
+ seen[value] = struct{}{}
+ }
+ }
+ if len(m.Labels) > MaxMatcherValues {
+ return fmt.Errorf("%w: too many label matchers", ErrInvalid)
+ }
+ for key, value := range m.Labels {
+ if !keyPattern.MatchString(key) {
+ return fmt.Errorf("%w: invalid label key", ErrInvalid)
+ }
+ if err := validateValue(value); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateValue(value string) error {
+ if value == "" || len(value) > 160 || strings.ContainsAny(value, "\r\n\x00") {
+ return fmt.Errorf("%w: invalid matcher value", ErrInvalid)
+ }
+ return nil
+}
+
+func (m Matcher) Matches(signal Signal) bool {
+ return containsOrWildcard(m.RuleIDs, signal.RuleID) && containsOrWildcard(m.EntityIDs, signal.EntityID) && containsOrWildcard(m.Severities, signal.Severity) && containsOrWildcard(m.EntityTypes, signal.Labels["entity.type"]) && labelsMatch(m.Labels, signal.Labels)
+}
+
+func containsOrWildcard(values []string, value string) bool {
+ if len(values) == 0 {
+ return true
+ }
+ for _, item := range values {
+ if item == value {
+ return true
+ }
+ }
+ return false
+}
+func labelsMatch(expected, actual map[string]string) bool {
+ for key, value := range expected {
+ if actual[key] != value {
+ return false
+ }
+ }
+ return true
+}
+
+func (s Silence) Validate(now time.Time) error {
+ if err := validateCommon(s.Name, s.Reason, s.StartsAt, s.ExpiresAt, s.Matchers); err != nil {
+ return err
+ }
+ if s.Owner == "" || len(s.Owner) > MaxOwner {
+ return fmt.Errorf("%w: invalid owner", ErrInvalid)
+ }
+ return nil
+}
+
+func (w MaintenanceWindow) Validate(now time.Time) error {
+ return validateCommon(w.Name, w.Reason, w.StartsAt, w.EndsAt, w.Selector)
+}
+
+func validateCommon(name, reason string, starts, ends time.Time, matcher Matcher) error {
+ if strings.TrimSpace(name) == "" || len(name) > MaxName || strings.ContainsAny(name, "\r\n\x00") {
+ return fmt.Errorf("%w: invalid name", ErrInvalid)
+ }
+ if strings.TrimSpace(reason) == "" || len(reason) > MaxReason || strings.ContainsAny(reason, "\r\n\x00") {
+ return fmt.Errorf("%w: invalid reason", ErrInvalid)
+ }
+ if starts.IsZero() || ends.IsZero() || !ends.After(starts) || ends.Sub(starts) > MaxDuration {
+ return fmt.Errorf("%w: invalid expiry window", ErrInvalid)
+ }
+ if err := matcher.Validate(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s Silence) StateAt(now time.Time) State {
+ return temporalState(s.StartsAt, s.ExpiresAt, s.RevokedAt, s.ExpiredAt, now)
+}
+func (w MaintenanceWindow) StateAt(now time.Time) State {
+ return temporalState(w.StartsAt, w.EndsAt, w.RevokedAt, w.ExpiredAt, now)
+}
+func temporalState(starts, ends time.Time, revoked, expired *time.Time, now time.Time) State {
+ if revoked != nil {
+ return StateRevoked
+ }
+ if expired != nil || !now.Before(ends) {
+ return StateExpired
+ }
+ if now.Before(starts) {
+ return StateScheduled
+ }
+ return StateActive
+}
+
+func (s Silence) Matches(signal Signal, now time.Time) bool {
+ return s.StateAt(now) == StateActive && s.Matchers.Matches(signal)
+}
+func (w MaintenanceWindow) Matches(signal Signal, now time.Time) bool {
+ return w.StateAt(now) == StateActive && w.Selector.Matches(signal)
+}
+
+func PreviewSignals(m Matcher, signals []Signal) (Preview, error) {
+ if err := m.Validate(); err != nil {
+ return Preview{}, err
+ }
+ ids := make([]string, 0, len(signals))
+ seen := make(map[string]struct{}, len(signals))
+ for _, signal := range signals {
+ if signal.InstanceID == "" {
+ return Preview{}, fmt.Errorf("%w: signal instance id is required", ErrInvalid)
+ }
+ if m.Matches(signal) {
+ if _, ok := seen[signal.InstanceID]; !ok {
+ ids = append(ids, signal.InstanceID)
+ seen[signal.InstanceID] = struct{}{}
+ }
+ }
+ }
+ sort.Strings(ids)
+ return Preview{Matched: len(ids) > 0, MatchedCount: len(ids), InstanceIDs: ids}, nil
+}
diff --git a/internal/alertcontrol/types_benchmark_test.go b/internal/alertcontrol/types_benchmark_test.go
new file mode 100644
index 0000000..8e74d6f
--- /dev/null
+++ b/internal/alertcontrol/types_benchmark_test.go
@@ -0,0 +1,17 @@
+package alertcontrol
+
+import "testing"
+
+func BenchmarkPreviewSignals1000(b *testing.B) {
+ matcher := Matcher{Severities: []string{"critical"}, Labels: map[string]string{"source.type": "prometheus"}}
+ signals := make([]Signal, 1000)
+ for index := range signals {
+ signals[index] = Signal{InstanceID: NewID(), Severity: "critical", Labels: map[string]string{"source.type": "prometheus"}}
+ }
+ b.ResetTimer()
+ for index := 0; index < b.N; index++ {
+ if _, err := PreviewSignals(matcher, signals); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/internal/alertcontrol/types_test.go b/internal/alertcontrol/types_test.go
new file mode 100644
index 0000000..958840b
--- /dev/null
+++ b/internal/alertcontrol/types_test.go
@@ -0,0 +1,54 @@
+package alertcontrol
+
+import (
+ "testing"
+ "time"
+)
+
+func TestMatcherIsBoundedAndDeterministic(t *testing.T) {
+ m := Matcher{RuleIDs: []string{"rule-a"}, EntityTypes: []string{"host"}, Labels: map[string]string{"source.type": "prometheus"}}
+ if err := m.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ signal := Signal{InstanceID: "instance-1", RuleID: "rule-a", Severity: "critical", Labels: map[string]string{"entity.type": "host", "source.type": "prometheus"}}
+ if !m.Matches(signal) {
+ t.Fatal("matcher should match signal")
+ }
+ signal.Labels["source.type"] = "agent"
+ if m.Matches(signal) {
+ t.Fatal("matcher should reject different labels")
+ }
+}
+
+func TestControlStateAndPreview(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ control := Silence{ID: "silence-1", Name: "Deploy", Reason: "planned change", Owner: "operator", Matchers: Matcher{Severities: []string{"critical"}}, StartsAt: now, ExpiresAt: now.Add(time.Hour)}
+ if got := control.StateAt(now); got != StateActive {
+ t.Fatalf("state at start = %s", got)
+ }
+ if got := control.StateAt(now.Add(time.Hour)); got != StateExpired {
+ t.Fatalf("state at expiry = %s", got)
+ }
+ if err := control.Validate(now); err != nil {
+ t.Fatal(err)
+ }
+ preview, err := PreviewSignals(control.Matchers, []Signal{{InstanceID: "b", Severity: "critical"}, {InstanceID: "a", Severity: "critical"}, {InstanceID: "x", Severity: "attention"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if preview.MatchedCount != 2 || preview.InstanceIDs[0] != "a" || preview.InstanceIDs[1] != "b" {
+ t.Fatalf("unexpected preview: %#v", preview)
+ }
+}
+
+func TestControlValidationRejectsUnboundedExpiryAndMatchers(t *testing.T) {
+ now := time.Now().UTC()
+ window := MaintenanceWindow{Name: "window", Reason: "reason", Selector: Matcher{Labels: map[string]string{"bad key": "value"}}, StartsAt: now, EndsAt: now.Add(2 * MaxDuration)}
+ if err := window.Validate(now); err == nil {
+ t.Fatal("expected validation error")
+ }
+ matcher := Matcher{RuleIDs: make([]string, MaxMatcherValues+1)}
+ if err := matcher.Validate(); err == nil {
+ t.Fatal("expected matcher bound error")
+ }
+}
diff --git a/internal/alertcontrolapi/handler.go b/internal/alertcontrolapi/handler.go
new file mode 100644
index 0000000..4576f31
--- /dev/null
+++ b/internal/alertcontrolapi/handler.go
@@ -0,0 +1,252 @@
+package alertcontrolapi
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/alertcontrol"
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/correlation"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Handler struct {
+ Store alertcontrol.Store
+ Audit audit.Store
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ principal, ok := auth.PrincipalFromContext(r.Context())
+ if !ok {
+ fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
+ return
+ }
+ base := "/api/v1/alert-silences"
+ maintenance := strings.HasPrefix(r.URL.Path, "/api/v1/maintenance-windows")
+ if maintenance {
+ base = "/api/v1/maintenance-windows"
+ }
+ path := strings.Trim(strings.TrimPrefix(r.URL.Path, base), "/")
+ if path == "" {
+ switch r.Method {
+ case http.MethodGet:
+ h.list(w, r, maintenance)
+ case http.MethodPost:
+ if !requireOperate(w, r, principal.Role) {
+ return
+ }
+ h.create(w, r, principal.Subject, maintenance)
+ default:
+ fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
+ }
+ return
+ }
+ if path == "preview" && r.Method == http.MethodPost {
+ h.preview(w, r, maintenance)
+ return
+ }
+ parts := strings.Split(path, "/")
+ if len(parts) == 2 && parts[1] == "revoke" && r.Method == http.MethodPost {
+ if !requireOperate(w, r, principal.Role) {
+ return
+ }
+ h.revoke(w, r, parts[0], principal.Subject, maintenance)
+ return
+ }
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert-control route not found.")
+}
+
+func (h Handler) list(w http.ResponseWriter, r *http.Request, maintenance bool) {
+ limit := 100
+ if value := r.URL.Query().Get("limit"); value != "" {
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed < 1 || parsed > 100 {
+ fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The limit must be between 1 and 100.")
+ return
+ }
+ limit = parsed
+ }
+ if maintenance {
+ items, err := h.Store.ListMaintenance(r.Context(), limit, time.Now().UTC())
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"items": items})
+ return
+ }
+ items, err := h.Store.ListSilences(r.Context(), limit, time.Now().UTC())
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"items": items})
+}
+
+func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string, maintenance bool) {
+ if maintenance {
+ var item alertcontrol.MaintenanceWindow
+ if err := decode(r, &item); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_MAINTENANCE", "The maintenance-window document is invalid.")
+ return
+ }
+ created, err := h.Store.CreateMaintenance(r.Context(), actor, item)
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ if err := h.record(r, actor, "maintenance_window.create", created.ID, "maintenance_window", nil, map[string]any{"state": created.State, "revision": created.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusCreated, map[string]any{"maintenance": created})
+ return
+ }
+ var item alertcontrol.Silence
+ if err := decode(r, &item); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_SILENCE", "The silence document is invalid.")
+ return
+ }
+ item.Owner = actor
+ created, err := h.Store.CreateSilence(r.Context(), actor, item)
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ if err := h.record(r, actor, "alert_silence.create", created.ID, "alert_silence", nil, map[string]any{"state": created.State, "revision": created.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusCreated, map[string]any{"silence": created})
+}
+
+func (h Handler) revoke(w http.ResponseWriter, r *http.Request, id, actor string, maintenance bool) {
+ expected, err := revision(r)
+ if err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
+ return
+ }
+ if maintenance {
+ item, err := h.Store.RevokeMaintenance(r.Context(), id, actor, expected, time.Now().UTC())
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ if err := h.record(r, actor, "maintenance_window.revoke", id, "maintenance_window", map[string]any{"revision": expected}, map[string]any{"state": item.State, "revision": item.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"maintenance": item})
+ return
+ }
+ item, err := h.Store.RevokeSilence(r.Context(), id, actor, expected, time.Now().UTC())
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ if err := h.record(r, actor, "alert_silence.revoke", id, "alert_silence", map[string]any{"revision": expected}, map[string]any{"state": item.State, "revision": item.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"silence": item})
+}
+
+func (h Handler) preview(w http.ResponseWriter, r *http.Request, maintenance bool) {
+ var request struct {
+ Matcher alertcontrol.Matcher `json:"matcher"`
+ Selector alertcontrol.Matcher `json:"selector"`
+ Signals []alertcontrol.Signal `json:"signals"`
+ }
+ if err := decode(r, &request); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The matcher preview request is invalid.")
+ return
+ }
+ matcher := request.Matcher
+ if maintenance {
+ matcher = request.Selector
+ }
+ result, err := alertcontrol.PreviewSignals(matcher, request.Signals)
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"preview": result})
+}
+
+func (h Handler) record(r *http.Request, actor, action, id, resourceType string, before, after map[string]any) error {
+ if h.Audit == nil {
+ return nil
+ }
+ return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: resourceType, ResourceID: id, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after})
+}
+
+func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error) {
+ switch {
+ case errors.Is(err, alertcontrol.ErrInvalid):
+ fail(w, r, http.StatusBadRequest, "INVALID_ALERT_CONTROL", "The alert-control document is invalid.")
+ case errors.Is(err, alertcontrol.ErrConflict):
+ fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert control was changed or expired.")
+ case errors.Is(err, alertcontrol.ErrNotFound):
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", "The alert control was not found.")
+ case errors.Is(err, alertcontrol.ErrUnavailable):
+ fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "Alert controls are unavailable.")
+ default:
+ fail(w, r, http.StatusInternalServerError, "ALERT_CONTROL_REQUEST_FAILED", "The alert-control request failed.")
+ }
+}
+func requireOperate(w http.ResponseWriter, r *http.Request, role auth.Role) bool {
+ if auth.Allows(role, auth.PermissionOperate) {
+ return true
+ }
+ fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert-control editing is not allowed for this role.")
+ return false
+}
+func revision(r *http.Request) (int64, error) {
+ value := r.Header.Get("If-Match")
+ if value == "" {
+ value = r.URL.Query().Get("revision")
+ }
+ value = strings.Trim(value, "\"")
+ if value == "" {
+ return 0, errors.New("revision required")
+ }
+ return strconv.ParseInt(value, 10, 64)
+}
+func decode(r *http.Request, target any) error {
+ contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
+ if contentType != "" && contentType != "application/json" {
+ return errors.New("unsupported content type")
+ }
+ body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1))
+ if err != nil {
+ return err
+ }
+ defer r.Body.Close()
+ if len(body) > 2<<20 {
+ return errors.New("request too large")
+ }
+ decoder := json.NewDecoder(strings.NewReader(string(body)))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return err
+ }
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ return errors.New("multiple JSON values")
+ }
+ return nil
+}
+func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
+ problem.Write(w, r, status, code, http.StatusText(status), detail, nil)
+}
+func write(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
diff --git a/internal/alertcontrolapi/handler_test.go b/internal/alertcontrolapi/handler_test.go
new file mode 100644
index 0000000..a9c5dce
--- /dev/null
+++ b/internal/alertcontrolapi/handler_test.go
@@ -0,0 +1,197 @@
+package alertcontrolapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/alertcontrol"
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+)
+
+type memoryStore struct {
+ mu sync.Mutex
+ silences map[string]alertcontrol.Silence
+ maintenance map[string]alertcontrol.MaintenanceWindow
+}
+
+func newMemoryStore() *memoryStore {
+ return &memoryStore{silences: map[string]alertcontrol.Silence{}, maintenance: map[string]alertcontrol.MaintenanceWindow{}}
+}
+func (s *memoryStore) CreateSilence(_ context.Context, actor string, item alertcontrol.Silence) (alertcontrol.Silence, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if item.ID == "" {
+ item.ID = alertcontrol.NewID()
+ }
+ if item.Owner == "" {
+ item.Owner = actor
+ }
+ if err := item.Validate(time.Now().UTC()); err != nil {
+ return alertcontrol.Silence{}, err
+ }
+ if _, ok := s.silences[item.ID]; ok {
+ return alertcontrol.Silence{}, alertcontrol.ErrConflict
+ }
+ item.CreatedBy, item.CreatedAt, item.Revision = actor, time.Now().UTC(), 1
+ item.State = item.StateAt(time.Now().UTC())
+ s.silences[item.ID] = item
+ return item, nil
+}
+func (s *memoryStore) ListSilences(_ context.Context, _ int, now time.Time) ([]alertcontrol.Silence, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ items := make([]alertcontrol.Silence, 0, len(s.silences))
+ for _, item := range s.silences {
+ item.State = item.StateAt(now)
+ items = append(items, item)
+ }
+ return items, nil
+}
+func (s *memoryStore) RevokeSilence(_ context.Context, id, actor string, expected int64, now time.Time) (alertcontrol.Silence, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ item, ok := s.silences[id]
+ if !ok {
+ return alertcontrol.Silence{}, alertcontrol.ErrNotFound
+ }
+ if item.Revision != expected || item.StateAt(now) != alertcontrol.StateActive {
+ return alertcontrol.Silence{}, alertcontrol.ErrConflict
+ }
+ item.RevokedBy, item.RevokedAt, item.Revision, item.State = actor, &now, item.Revision+1, alertcontrol.StateRevoked
+ s.silences[id] = item
+ return item, nil
+}
+func (s *memoryStore) CreateMaintenance(_ context.Context, actor string, item alertcontrol.MaintenanceWindow) (alertcontrol.MaintenanceWindow, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if item.ID == "" {
+ item.ID = alertcontrol.NewID()
+ }
+ if err := item.Validate(time.Now().UTC()); err != nil {
+ return alertcontrol.MaintenanceWindow{}, err
+ }
+ item.CreatedBy, item.CreatedAt, item.Revision = actor, time.Now().UTC(), 1
+ item.State = item.StateAt(time.Now().UTC())
+ s.maintenance[item.ID] = item
+ return item, nil
+}
+func (s *memoryStore) ListMaintenance(_ context.Context, _ int, now time.Time) ([]alertcontrol.MaintenanceWindow, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ items := make([]alertcontrol.MaintenanceWindow, 0, len(s.maintenance))
+ for _, item := range s.maintenance {
+ item.State = item.StateAt(now)
+ items = append(items, item)
+ }
+ return items, nil
+}
+func (s *memoryStore) RevokeMaintenance(_ context.Context, id, actor string, expected int64, now time.Time) (alertcontrol.MaintenanceWindow, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ item, ok := s.maintenance[id]
+ if !ok {
+ return alertcontrol.MaintenanceWindow{}, alertcontrol.ErrNotFound
+ }
+ if item.Revision != expected {
+ return alertcontrol.MaintenanceWindow{}, alertcontrol.ErrConflict
+ }
+ item.RevokedBy, item.RevokedAt, item.Revision, item.State = actor, &now, item.Revision+1, alertcontrol.StateRevoked
+ s.maintenance[id] = item
+ return item, nil
+}
+func (s *memoryStore) Expire(_ context.Context, now time.Time) (alertcontrol.ExpiryResult, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var result alertcontrol.ExpiryResult
+ for id, item := range s.silences {
+ if item.State == alertcontrol.StateActive && !now.Before(item.ExpiresAt) {
+ item.ExpiredAt, item.Revision = &now, item.Revision+1
+ s.silences[id] = item
+ result.Silences++
+ }
+ }
+ for id, item := range s.maintenance {
+ if item.State == alertcontrol.StateActive && !now.Before(item.EndsAt) {
+ item.ExpiredAt, item.Revision = &now, item.Revision+1
+ s.maintenance[id] = item
+ result.MaintenanceWindows++
+ }
+ }
+ return result, nil
+}
+
+func TestHandlerEnforcesRBACAuditPreviewAndVisibleMaintenance(t *testing.T) {
+ store := newMemoryStore()
+ auditStore := &audit.MemoryStore{}
+ handler := Handler{Store: store, Audit: auditStore}
+ now := time.Now().UTC()
+ silence := alertcontrol.Silence{Name: "deploy", Reason: "planned", Owner: "operator", Matchers: alertcontrol.Matcher{Severities: []string{"critical"}}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}
+ viewer := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences", silence, auth.RoleViewer)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, viewer)
+ if response.Code != http.StatusForbidden {
+ t.Fatalf("viewer create status = %d", response.Code)
+ }
+ operator := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences", silence, auth.RoleOperator)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, operator)
+ if response.Code != http.StatusCreated {
+ t.Fatalf("operator create status = %d body=%s", response.Code, response.Body.String())
+ }
+ var created struct {
+ Silence alertcontrol.Silence `json:"silence"`
+ }
+ if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil {
+ t.Fatal(err)
+ }
+ if len(auditStore.Events) != 1 || auditStore.Events[0].Action != "alert_silence.create" {
+ t.Fatalf("audit events = %#v", auditStore.Events)
+ }
+ previewBody := map[string]any{"matcher": alertcontrol.Matcher{Severities: []string{"critical"}}, "signals": []alertcontrol.Signal{{InstanceID: "i-1", Severity: "critical"}}}
+ preview := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences/preview", previewBody, auth.RoleViewer)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, preview)
+ if response.Code != http.StatusOK || !contains(response.Body.String(), `"matched":true`) {
+ t.Fatalf("preview response = %d %s", response.Code, response.Body.String())
+ }
+ window := alertcontrol.MaintenanceWindow{Name: "maintenance", Reason: "upgrade", Selector: alertcontrol.Matcher{EntityTypes: []string{"host"}}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour)}
+ request := requestWithPrincipal(http.MethodPost, "/api/v1/maintenance-windows", window, auth.RoleOperator)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != http.StatusCreated {
+ t.Fatalf("maintenance create status = %d", response.Code)
+ }
+ request = requestWithPrincipal(http.MethodGet, "/api/v1/maintenance-windows", nil, auth.RoleViewer)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != http.StatusOK || !contains(response.Body.String(), `"state":"active"`) {
+ t.Fatalf("maintenance list = %d %s", response.Code, response.Body.String())
+ }
+ request = requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences/"+created.Silence.ID+"/revoke?revision=999", nil, auth.RoleOperator)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != http.StatusConflict {
+ t.Fatalf("stale revoke status = %d", response.Code)
+ }
+}
+
+func requestWithPrincipal(method, path string, body any, role auth.Role) *http.Request {
+ var reader *strings.Reader
+ if body == nil {
+ reader = strings.NewReader("")
+ } else {
+ encoded, _ := json.Marshal(body)
+ reader = strings.NewReader(string(encoded))
+ }
+ request := httptest.NewRequest(method, path, reader).WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "operator-1", Role: role}))
+ request.Header.Set("Content-Type", "application/json")
+ return request
+}
+func contains(value, part string) bool { return strings.Contains(value, part) }
diff --git a/internal/alertdefaults/seed.go b/internal/alertdefaults/seed.go
new file mode 100644
index 0000000..daed05c
--- /dev/null
+++ b/internal/alertdefaults/seed.go
@@ -0,0 +1,111 @@
+package alertdefaults
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/metriccatalog"
+)
+
+//go:embed seed.json
+var seedFS embed.FS
+
+type seedDocument struct {
+ SchemaVersion int `json:"schemaVersion"`
+ Rules []alert.Document `json:"rules"`
+}
+
+type RuleStore interface {
+ Create(context.Context, string, alert.Document, string) (alert.Rule, alert.Version, error)
+ Get(context.Context, string) (alert.Rule, error)
+ // Update is optional for the seed: stores that also implement it allow the
+ // seed to refresh an implementation-owned default that has never been edited
+ // (revision 1) when a newer seed corrects it. See Seed.
+}
+
+// RuleUpdater is implemented by stores that support versioned updates. The seed
+// uses it only for rules that are still at revision 1 (created by the seed and
+// never changed by an operator), so operator edits are never overwritten.
+type RuleUpdater interface {
+ Update(ctx context.Context, id, actor string, expected int64, document alert.Document, changeSummary string) (alert.Rule, error)
+}
+
+type Report struct {
+ Added int
+ Existing int
+ Refreshed int
+}
+
+func Load(registry metriccatalog.Registry) ([]alert.Document, error) {
+ raw, err := seedFS.ReadFile("seed.json")
+ if err != nil {
+ return nil, fmt.Errorf("read embedded alert defaults: %w", err)
+ }
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ var bundle seedDocument
+ if err := decoder.Decode(&bundle); err != nil {
+ return nil, fmt.Errorf("decode embedded alert defaults: %w", err)
+ }
+ if bundle.SchemaVersion != 1 || len(bundle.Rules) == 0 {
+ return nil, errors.New("embedded alert defaults have an invalid bundle")
+ }
+ seen := make(map[string]struct{}, len(bundle.Rules))
+ for _, rule := range bundle.Rules {
+ if _, ok := seen[rule.ID]; ok {
+ return nil, fmt.Errorf("embedded alert defaults contain duplicate rule %q", rule.ID)
+ }
+ seen[rule.ID] = struct{}{}
+ if err := rule.Validate(registry); err != nil {
+ return nil, fmt.Errorf("validate embedded alert default %q: %w", rule.ID, err)
+ }
+ }
+ return bundle.Rules, nil
+}
+
+func Seed(ctx context.Context, store RuleStore, registry metriccatalog.Registry, actor string) (Report, error) {
+ if store == nil {
+ return Report{}, alert.ErrUnavailable
+ }
+ rules, err := Load(registry)
+ if err != nil {
+ return Report{}, err
+ }
+ report := Report{}
+ for _, document := range rules {
+ if _, _, err := store.Create(ctx, actor, document, "system default seed v1"); err == nil {
+ report.Added++
+ } else if errors.Is(err, alert.ErrConflict) {
+ existing, getErr := store.Get(ctx, document.ID)
+ if getErr != nil {
+ return Report{}, fmt.Errorf("verify existing alert default %q: %w", document.ID, getErr)
+ }
+ if updater, ok := store.(RuleUpdater); ok && existing.Revision == 1 && !sameDocument(existing.Document, document) {
+ // The stored default is untouched since the seed created it, but the
+ // seed itself changed (for example a corrected metric binding). Refresh
+ // it through the normal versioned update path so history is kept.
+ if _, updErr := updater.Update(ctx, document.ID, actor, existing.Revision, document, "system default seed v1 refresh"); updErr != nil {
+ return Report{}, fmt.Errorf("refresh alert default %q: %w", document.ID, updErr)
+ }
+ report.Refreshed++
+ continue
+ }
+ report.Existing++
+ } else {
+ return Report{}, fmt.Errorf("seed alert default %q: %w", document.ID, err)
+ }
+ }
+ return report, nil
+}
+
+// sameDocument compares two rule documents by their canonical JSON encoding.
+func sameDocument(a, b alert.Document) bool {
+ left, errA := json.Marshal(a)
+ right, errB := json.Marshal(b)
+ return errA == nil && errB == nil && bytes.Equal(left, right)
+}
diff --git a/internal/alertdefaults/seed.json b/internal/alertdefaults/seed.json
new file mode 100644
index 0000000..a4e776c
--- /dev/null
+++ b/internal/alertdefaults/seed.json
@@ -0,0 +1,172 @@
+{
+ "schemaVersion": 1,
+ "rules": [
+ {
+ "schemaVersion": 1,
+ "id": "71111111-1111-4111-8111-111111111111",
+ "name": "Monitoringbron levert geen recente gegevens",
+ "enabled": true,
+ "severity": "degraded",
+ "scope": {
+ "entityType": "data-source",
+ "required": true
+ },
+ "condition": {
+ "inputType": "datasource-health",
+ "operator": "==",
+ "threshold": "stale",
+ "windowSeconds": 120
+ },
+ "evaluationIntervalSeconds": 30,
+ "pendingSeconds": 120,
+ "resolveSeconds": 60,
+ "unknownBehavior": "become-unknown",
+ "groupBy": [
+ "source"
+ ],
+ "suppressWhen": [],
+ "message": {
+ "titleKey": "alerts.datasourceStale.title",
+ "bodyKey": "alerts.datasourceStale.body"
+ }
+ },
+ {
+ "schemaVersion": 1,
+ "id": "81111111-1111-4111-8111-111111111111",
+ "name": "Container bevindt zich in een herstartlus",
+ "enabled": true,
+ "severity": "degraded",
+ "scope": {
+ "entityType": "container",
+ "excludeIntentionalStopped": true
+ },
+ "condition": {
+ "inputType": "event",
+ "operator": ">=",
+ "threshold": 3,
+ "aggregation": "count",
+ "windowSeconds": 900
+ },
+ "evaluationIntervalSeconds": 30,
+ "pendingSeconds": 0,
+ "resolveSeconds": 900,
+ "unknownBehavior": "become-unknown",
+ "groupBy": [
+ "container",
+ "application"
+ ],
+ "suppressWhen": [
+ "host.unreachable"
+ ],
+ "message": {
+ "titleKey": "alerts.containerRestartLoop.title",
+ "bodyKey": "alerts.containerRestartLoop.body"
+ }
+ },
+ {
+ "schemaVersion": 1,
+ "id": "91111111-1111-4111-8111-111111111111",
+ "name": "Disktemperatuur te hoog",
+ "enabled": true,
+ "severity": "degraded",
+ "scope": {
+ "entityType": "disk"
+ },
+ "condition": {
+ "inputType": "metric",
+ "metric": "storage.disk.temperature.maximum",
+ "operator": ">=",
+ "threshold": 50,
+ "recoveryThreshold": 46,
+ "aggregation": "max",
+ "windowSeconds": 300
+ },
+ "evaluationIntervalSeconds": 30,
+ "pendingSeconds": 300,
+ "resolveSeconds": 300,
+ "unknownBehavior": "retain-firing-as-unknown",
+ "groupBy": [
+ "disk",
+ "server"
+ ],
+ "suppressWhen": [
+ "host.unreachable",
+ "storage.source.unavailable"
+ ],
+ "message": {
+ "titleKey": "alerts.diskTemperature.title",
+ "bodyKey": "alerts.diskTemperature.body"
+ }
+ },
+ {
+ "schemaVersion": 1,
+ "id": "a1111111-1111-4111-8111-111111111111",
+ "name": "Service is niet bereikbaar",
+ "enabled": true,
+ "severity": "degraded",
+ "scope": {
+ "entityType": "service",
+ "critical": true
+ },
+ "condition": {
+ "inputType": "metric",
+ "metric": "service.availability.minimum",
+ "operator": "<",
+ "threshold": 1,
+ "aggregation": "min",
+ "windowSeconds": 90
+ },
+ "evaluationIntervalSeconds": 30,
+ "pendingSeconds": 90,
+ "resolveSeconds": 60,
+ "unknownBehavior": "become-unknown",
+ "groupBy": [
+ "service",
+ "application"
+ ],
+ "suppressWhen": [
+ "host.unreachable",
+ "network.gateway.unreachable",
+ "dns.unavailable"
+ ],
+ "message": {
+ "titleKey": "alerts.serviceUnavailable.title",
+ "bodyKey": "alerts.serviceUnavailable.body"
+ }
+ },
+ {
+ "schemaVersion": 1,
+ "id": "b1111111-1111-4111-8111-111111111111",
+ "name": "Opslagpool bijna vol",
+ "enabled": true,
+ "severity": "critical",
+ "scope": {
+ "entityType": "storage_pool"
+ },
+ "condition": {
+ "inputType": "metric",
+ "metric": "storage.pool.utilization.maximum",
+ "operator": ">=",
+ "threshold": 97,
+ "recoveryThreshold": 90,
+ "aggregation": "max",
+ "windowSeconds": 300
+ },
+ "evaluationIntervalSeconds": 60,
+ "pendingSeconds": 300,
+ "resolveSeconds": 300,
+ "unknownBehavior": "retain-firing-as-unknown",
+ "groupBy": [
+ "server"
+ ],
+ "suppressWhen": [
+ "host.unreachable",
+ "storage.source.unavailable"
+ ],
+ "message": {
+ "titleKey": "alerts.storagePoolCritical.title",
+ "bodyKey": "alerts.storagePoolCritical.body"
+ }
+ }
+ ]
+}
diff --git a/internal/alertdefaults/seed_integration_test.go b/internal/alertdefaults/seed_integration_test.go
new file mode 100644
index 0000000..34bc6e3
--- /dev/null
+++ b/internal/alertdefaults/seed_integration_test.go
@@ -0,0 +1,55 @@
+package alertdefaults
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/database"
+ "github.com/itworx/pulse/internal/metriccatalog"
+)
+
+func TestPostgreSQLDefaultSeedIsIdempotent(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 6, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ repository := alert.Repository{Pool: pool, Registry: registry}
+ first, err := Seed(ctx, repository, registry, "system-defaults")
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := Seed(ctx, repository, registry, "system-defaults")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.Added != 0 || second.Existing != first.Added+first.Existing {
+ t.Fatalf("seed reports first=%+v second=%+v", first, second)
+ }
+ rules, err := Load(registry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, document := range rules {
+ loaded, err := repository.Get(ctx, document.ID)
+ if err != nil || loaded.ID != document.ID {
+ t.Fatalf("default %s was not readable after restart-safe seed: rule=%+v err=%v", document.ID, loaded, err)
+ }
+ }
+}
diff --git a/internal/alertdefaults/seed_test.go b/internal/alertdefaults/seed_test.go
new file mode 100644
index 0000000..dac2523
--- /dev/null
+++ b/internal/alertdefaults/seed_test.go
@@ -0,0 +1,167 @@
+package alertdefaults
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/metriccatalog"
+)
+
+type fakeStore struct {
+ rules map[string]alert.Rule
+ adds int
+}
+
+func (store *fakeStore) Create(_ context.Context, _ string, document alert.Document, _ string) (alert.Rule, alert.Version, error) {
+ if _, exists := store.rules[document.ID]; exists {
+ return alert.Rule{}, alert.Version{}, alert.ErrConflict
+ }
+ store.adds++
+ rule := alert.Rule{Document: document}
+ store.rules[document.ID] = rule
+ return rule, alert.Version{RuleID: document.ID, VersionNumber: 1}, nil
+}
+
+func (store *fakeStore) Update(_ context.Context, id, _ string, expected int64, document alert.Document, _ string) (alert.Rule, error) {
+ rule, exists := store.rules[id]
+ if !exists {
+ return alert.Rule{}, alert.ErrNotFound
+ }
+ if rule.Revision != expected {
+ return alert.Rule{}, alert.ErrConflict
+ }
+ rule.Document = document
+ rule.Revision++
+ store.rules[id] = rule
+ return rule, nil
+}
+
+func (store *fakeStore) Get(_ context.Context, id string) (alert.Rule, error) {
+ rule, exists := store.rules[id]
+ if !exists {
+ return alert.Rule{}, alert.ErrNotFound
+ }
+ return rule, nil
+}
+
+func TestLoadValidatesImplementationOwnedDefaults(t *testing.T) {
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rules, err := Load(registry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rules) < 4 {
+ t.Fatalf("default rule count=%d, want at least 4", len(rules))
+ }
+}
+
+func TestSeedIsIdempotentAndDoesNotOverwriteExistingRule(t *testing.T) {
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rules, err := Load(registry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ custom := rules[0]
+ custom.Name = "Aangepaste naam"
+ store := &fakeStore{rules: map[string]alert.Rule{custom.ID: {Document: custom}}}
+ first, err := Seed(context.Background(), store, registry, "system-defaults")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.Added != len(rules)-1 || first.Existing != 1 || store.adds != len(rules)-1 {
+ t.Fatalf("first seed report=%+v adds=%d", first, store.adds)
+ }
+ second, err := Seed(context.Background(), store, registry, "system-defaults")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.Added != 0 || second.Existing != len(rules) {
+ t.Fatalf("second seed report=%+v", second)
+ }
+ loaded, err := store.Get(context.Background(), custom.ID)
+ if err != nil || loaded.Name != "Aangepaste naam" {
+ t.Fatalf("custom rule was overwritten: rule=%+v err=%v", loaded, err)
+ }
+}
+func TestDefaultRulesReduceStormNoise(t *testing.T) {
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rules, err := Load(registry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var serviceRule alert.Document
+ for _, rule := range rules {
+ if rule.Name == "Service is niet bereikbaar" {
+ serviceRule = rule
+ }
+ }
+ if serviceRule.ID == "" {
+ t.Fatal("service default rule is missing")
+ }
+ at := time.Date(2026, time.January, 4, 12, 0, 0, 0, time.UTC)
+ signals := make([]alert.Signal, 0, 5)
+ for i := 0; i < 5; i++ {
+ signals = append(signals, alert.Signal{InstanceID: "service-" + string(rune('a'+i)), RuleID: serviceRule.ID, RuleVersionID: "version-1", EntityID: "entity-" + string(rune('a'+i)), Severity: serviceRule.Severity, State: alert.StateFiring, Fingerprint: "fingerprint-" + string(rune('a'+i)), EvaluationKey: "evaluation-1", ObservedAt: at, Labels: map[string]string{"host": "pulse", "application": "media", "service": "svc-" + string(rune('a'+i))}, GroupBy: serviceRule.GroupBy, SuppressWhen: serviceRule.SuppressWhen})
+ }
+ deduplicated, err := alert.DeduplicateSignals(append(signals, signals[0]))
+ if err != nil || len(deduplicated) != len(signals) {
+ t.Fatalf("deduplicated=%d err=%v", len(deduplicated), err)
+ }
+ groups, err := alert.GroupSignals(deduplicated)
+ if err != nil || len(groups) != len(signals) {
+ t.Fatalf("groups=%+v err=%v", groups, err)
+ }
+ decision, err := alert.EvaluateSuppression(signals[0], []alert.Cause{{Key: "dns.unavailable", State: alert.StateFiring, Confirmed: true, ObservedAt: at}})
+ if err != nil || !decision.Suppressed || decision.CauseKey != "dns.unavailable" {
+ t.Fatalf("suppression=%+v err=%v", decision, err)
+ }
+}
+
+func TestSeedRefreshesUntouchedDefaultButKeepsOperatorEdits(t *testing.T) {
+ registry, err := metriccatalog.DefaultRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rules, err := Load(registry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stale := rules[0]
+ stale.PendingSeconds = stale.PendingSeconds + 60
+ edited := rules[1]
+ edited.Name = "Door operator aangepast"
+ store := &fakeStore{rules: map[string]alert.Rule{
+ stale.ID: {Document: stale, Revision: 1}, // seeded, never edited -> refreshed
+ edited.ID: {Document: edited, Revision: 2}, // edited by an operator -> untouched
+ }}
+ report, err := Seed(context.Background(), store, registry, "system-defaults")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.Refreshed != 1 || report.Existing != 1 || report.Added != len(rules)-2 {
+ t.Fatalf("report=%+v", report)
+ }
+ refreshed, _ := store.Get(context.Background(), stale.ID)
+ if refreshed.PendingSeconds != rules[0].PendingSeconds || refreshed.Revision != 2 {
+ t.Fatalf("stale default not refreshed: %+v", refreshed)
+ }
+ kept, _ := store.Get(context.Background(), edited.ID)
+ if kept.Name != "Door operator aangepast" || kept.Revision != 2 {
+ t.Fatalf("operator edit overwritten: %+v", kept)
+ }
+ again, err := Seed(context.Background(), store, registry, "system-defaults")
+ if err != nil || again.Refreshed != 0 || again.Added != 0 {
+ t.Fatalf("second seed not idempotent: %+v err=%v", again, err)
+ }
+}
diff --git a/internal/alertopsapi/handler.go b/internal/alertopsapi/handler.go
new file mode 100644
index 0000000..a938035
--- /dev/null
+++ b/internal/alertopsapi/handler.go
@@ -0,0 +1,202 @@
+package alertopsapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/correlation"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Store interface {
+ alert.AlertReader
+ AcknowledgeRevision(context.Context, string, string, string, time.Time, int64) (alert.Instance, alert.Occurrence, bool, error)
+ Unacknowledge(context.Context, string, string, string, time.Time, int64) (alert.Instance, alert.Occurrence, bool, error)
+}
+
+type Handler struct {
+ Store Store
+ Audit audit.Store
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ principal, ok := auth.PrincipalFromContext(r.Context())
+ if !ok {
+ fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
+ return
+ }
+ path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/alerts"), "/")
+ if path == "" && r.Method == http.MethodGet {
+ h.list(w, r)
+ return
+ }
+ parts := strings.Split(path, "/")
+ if len(parts) == 1 && parts[0] != "" && r.Method == http.MethodGet {
+ h.get(w, r, parts[0])
+ return
+ }
+ if len(parts) == 2 && (parts[1] == "acknowledge" || parts[1] == "unacknowledge") && r.Method == http.MethodPost {
+ if !auth.Allows(principal.Role, auth.PermissionOperate) {
+ fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert operations are not allowed for this role.")
+ return
+ }
+ h.operate(w, r, parts[0], principal.Subject, parts[1] == "acknowledge")
+ return
+ }
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert route not found.")
+}
+
+func (h Handler) list(w http.ResponseWriter, r *http.Request) {
+ limit := 100
+ if value := r.URL.Query().Get("limit"); value != "" {
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed < 1 || parsed > 100 {
+ fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The alert limit must be between 1 and 100.")
+ return
+ }
+ limit = parsed
+ }
+ items, err := h.Store.ListAlerts(r.Context(), limit, r.URL.Query().Get("state"))
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"items": items})
+}
+
+func (h Handler) get(w http.ResponseWriter, r *http.Request, id string) {
+ limit := 100
+ if value := r.URL.Query().Get("occurrenceLimit"); value != "" {
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed < 1 || parsed > 500 {
+ fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The occurrence limit must be between 1 and 500.")
+ return
+ }
+ limit = parsed
+ }
+ item, err := h.Store.GetAlert(r.Context(), id, limit)
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"alert": item})
+}
+
+func (h Handler) operate(w http.ResponseWriter, r *http.Request, id, actor string, acknowledge bool) {
+ expected, err := revision(r)
+ if err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
+ return
+ }
+ evaluationKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
+ if evaluationKey == "" {
+ var request struct {
+ EvaluationKey string `json:"evaluationKey"`
+ }
+ if err := decode(r, &request); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_OPERATION", "The alert operation request is invalid.")
+ return
+ }
+ evaluationKey = strings.TrimSpace(request.EvaluationKey)
+ }
+ if evaluationKey == "" || len(evaluationKey) > 160 {
+ fail(w, r, http.StatusBadRequest, "INVALID_OPERATION", "A bounded evaluation key or Idempotency-Key is required.")
+ return
+ }
+ var instance alert.Instance
+ var occurrence alert.Occurrence
+ var duplicate bool
+ if acknowledge {
+ instance, occurrence, duplicate, err = h.Store.AcknowledgeRevision(r.Context(), id, actor, evaluationKey, time.Now().UTC(), expected)
+ } else {
+ instance, occurrence, duplicate, err = h.Store.Unacknowledge(r.Context(), id, actor, evaluationKey, time.Now().UTC(), expected)
+ }
+ if err != nil {
+ h.repositoryFailure(w, r, err)
+ return
+ }
+ action := "alert.unacknowledge"
+ if acknowledge {
+ action = "alert.acknowledge"
+ }
+ result := "success"
+ if duplicate {
+ result = "idempotent"
+ }
+ if h.Audit != nil {
+ if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "alert_instance", ResourceID: id, Result: result, CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"state": instance.State, "revision": instance.Revision, "duplicate": duplicate}}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ }
+ write(w, http.StatusOK, map[string]any{"instance": instance, "occurrence": occurrence, "duplicate": duplicate})
+}
+
+func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error) {
+ switch {
+ case errors.Is(err, alert.ErrInvalidObservation):
+ fail(w, r, http.StatusBadRequest, "INVALID_ALERT_OPERATION", "The alert operation is invalid.")
+ case errors.Is(err, alert.ErrRevisionConflict):
+ fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert changed before this operation was applied.")
+ case errors.Is(err, alert.ErrStateConflict):
+ fail(w, r, http.StatusConflict, "STATE_CONFLICT", "The alert is not in a state that supports this operation.")
+ case errors.Is(err, alert.ErrInstanceNotFound):
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", "The alert instance was not found.")
+ case errors.Is(err, alert.ErrUnavailable):
+ fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "Alerts are unavailable.")
+ default:
+ fail(w, r, http.StatusInternalServerError, "ALERT_REQUEST_FAILED", "The alert request failed.")
+ }
+}
+func revision(r *http.Request) (int64, error) {
+ value := r.Header.Get("If-Match")
+ if value == "" {
+ value = r.URL.Query().Get("revision")
+ }
+ value = strings.Trim(value, "\"")
+ if value == "" {
+ return 0, errors.New("revision required")
+ }
+ parsed, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || parsed < 1 {
+ return 0, errors.New("invalid revision")
+ }
+ return parsed, nil
+}
+func decode(r *http.Request, target any) error {
+ body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1))
+ if err != nil {
+ return err
+ }
+ defer r.Body.Close()
+ if len(body) > 2<<20 {
+ return errors.New("request too large")
+ }
+ decoder := json.NewDecoder(strings.NewReader(string(body)))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return err
+ }
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ return errors.New("multiple JSON values")
+ }
+ return nil
+}
+func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
+ problem.Write(w, r, status, code, http.StatusText(status), detail, nil)
+}
+func write(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
diff --git a/internal/alertopsapi/handler_test.go b/internal/alertopsapi/handler_test.go
new file mode 100644
index 0000000..65321b3
--- /dev/null
+++ b/internal/alertopsapi/handler_test.go
@@ -0,0 +1,137 @@
+package alertopsapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+)
+
+type memoryStore struct {
+ mu sync.Mutex
+ items map[string]alert.Alert
+ occurrences map[string]alert.Occurrence
+}
+
+func newMemoryStore() *memoryStore {
+ return &memoryStore{items: map[string]alert.Alert{}, occurrences: map[string]alert.Occurrence{}}
+}
+func (s *memoryStore) ListAlerts(_ context.Context, _ int, state string) ([]alert.Alert, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ result := make([]alert.Alert, 0)
+ for _, item := range s.items {
+ if state == "" || string(item.State) == state {
+ result = append(result, item)
+ }
+ }
+ return result, nil
+}
+func (s *memoryStore) GetAlert(_ context.Context, id string, _ int) (alert.Alert, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ item, ok := s.items[id]
+ if !ok {
+ return alert.Alert{}, alert.ErrInstanceNotFound
+ }
+ return item, nil
+}
+func (s *memoryStore) AcknowledgeRevision(_ context.Context, id, actor, key string, at time.Time, expected int64) (alert.Instance, alert.Occurrence, bool, error) {
+ return s.mutate(id, actor, key, at, expected, true)
+}
+func (s *memoryStore) Unacknowledge(_ context.Context, id, actor, key string, at time.Time, expected int64) (alert.Instance, alert.Occurrence, bool, error) {
+ return s.mutate(id, actor, key, at, expected, false)
+}
+func (s *memoryStore) mutate(id, actor, key string, at time.Time, expected int64, acknowledge bool) (alert.Instance, alert.Occurrence, bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ item, ok := s.items[id]
+ if !ok {
+ return alert.Instance{}, alert.Occurrence{}, false, alert.ErrInstanceNotFound
+ }
+ if occurrence, ok := s.occurrences[key]; ok {
+ return item.Instance, occurrence, true, nil
+ }
+ if item.Revision != expected {
+ return alert.Instance{}, alert.Occurrence{}, false, alert.ErrRevisionConflict
+ }
+ if acknowledge {
+ if item.State != alert.StateFiring && item.State != alert.StatePending {
+ return alert.Instance{}, alert.Occurrence{}, false, alert.ErrStateConflict
+ }
+ item.State, item.RetainedState, item.AcknowledgedBy, item.AcknowledgedAt, item.Reason = alert.StateAcknowledged, alert.StateAcknowledged, actor, &at, "acknowledged"
+ } else {
+ if item.State != alert.StateAcknowledged {
+ return alert.Instance{}, alert.Occurrence{}, false, alert.ErrStateConflict
+ }
+ item.State, item.RetainedState, item.AcknowledgedBy, item.AcknowledgedAt, item.Reason = alert.StateFiring, alert.StateFiring, "", nil, "unacknowledged"
+ }
+ item.Revision++
+ occurrence := alert.Occurrence{ID: key, InstanceID: id, EvaluationKey: key, EventType: "acknowledge", From: alert.StateFiring, To: item.State, ObservedAt: at, Reason: item.Reason}
+ if !acknowledge {
+ occurrence.EventType = "unacknowledge"
+ occurrence.From = alert.StateAcknowledged
+ }
+ s.items[id] = item
+ s.occurrences[key] = occurrence
+ return item.Instance, occurrence, false, nil
+}
+
+func TestHandlerRoleMatrixIdempotenceAndAudit(t *testing.T) {
+ store := newMemoryStore()
+ store.items["instance-1"] = alert.Alert{Instance: alert.Instance{ID: "instance-1", State: alert.StateFiring, RetainedState: alert.StateFiring, Revision: 1, LastValue: 90, SourceHealth: map[string]any{}}, RuleName: "CPU", Severity: alert.SeverityCritical}
+ auditStore := &audit.MemoryStore{}
+ handler := Handler{Store: store, Audit: auditStore}
+ viewer := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleViewer)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, viewer)
+ if response.Code != http.StatusForbidden {
+ t.Fatalf("viewer status = %d", response.Code)
+ }
+ operator := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleOperator)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, operator)
+ if response.Code != http.StatusOK {
+ t.Fatalf("ack status = %d body=%s", response.Code, response.Body.String())
+ }
+ retry := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleOperator)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, retry)
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"duplicate":true`) {
+ t.Fatalf("duplicate ack = %d %s", response.Code, response.Body.String())
+ }
+ unack := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/unacknowledge?revision=2", map[string]string{"evaluationKey": "unack-1"}, auth.RoleOperator)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, unack)
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"state":"firing"`) {
+ t.Fatalf("unack = %d %s", response.Code, response.Body.String())
+ }
+ list := requestWithPrincipal(http.MethodGet, "/api/v1/alerts?state=firing", nil, auth.RoleViewer)
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, list)
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"ruleName":"CPU"`) {
+ t.Fatalf("list = %d %s", response.Code, response.Body.String())
+ }
+ if len(auditStore.Events) != 3 || auditStore.Events[1].Result != "idempotent" {
+ t.Fatalf("audit = %#v", auditStore.Events)
+ }
+}
+
+func requestWithPrincipal(method, path string, body any, role auth.Role) *http.Request {
+ encoded := ""
+ if body != nil {
+ value, _ := json.Marshal(body)
+ encoded = string(value)
+ }
+ request := httptest.NewRequest(method, path, strings.NewReader(encoded)).WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "operator-1", Role: role}))
+ request.Header.Set("Content-Type", "application/json")
+ return request
+}
diff --git a/internal/alertworker/memory_store.go b/internal/alertworker/memory_store.go
new file mode 100644
index 0000000..3d1906b
--- /dev/null
+++ b/internal/alertworker/memory_store.go
@@ -0,0 +1,69 @@
+package alertworker
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+)
+
+type memoryLease struct {
+ lease Lease
+ status string
+ leaseUntil time.Time
+}
+
+type MemoryLeaseStore struct {
+ mu sync.Mutex
+ records map[string]memoryLease
+}
+
+func NewMemoryLeaseStore() *MemoryLeaseStore {
+ return &MemoryLeaseStore{records: make(map[string]memoryLease)}
+}
+
+func (s *MemoryLeaseStore) Acquire(_ context.Context, jobType, jobKey string, scheduledAt time.Time, owner string, now time.Time, ttl time.Duration) (Lease, bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.records == nil {
+ s.records = make(map[string]memoryLease)
+ }
+ key := leaseKey(jobType, jobKey, scheduledAt)
+ if record, ok := s.records[key]; ok {
+ if record.status != "running" || record.leaseUntil.After(now) {
+ return Lease{}, false, nil
+ }
+ }
+ lease := Lease{ID: alert.NewID(), JobKey: jobKey, ScheduledAt: scheduledAt.UTC(), Owner: owner}
+ s.records[key] = memoryLease{lease: lease, status: "running", leaseUntil: now.Add(ttl)}
+ return lease, true, nil
+}
+
+func (s *MemoryLeaseStore) Complete(_ context.Context, lease Lease, status, _ string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ key := leaseKey(jobType, lease.JobKey, lease.ScheduledAt)
+ record, ok := s.records[key]
+ if !ok || record.lease.ID != lease.ID || record.lease.Owner != lease.Owner {
+ return ErrLeaseLost
+ }
+ record.status = status
+ record.leaseUntil = time.Time{}
+ s.records[key] = record
+ return nil
+}
+
+func (s *MemoryLeaseStore) Status(jobKey string, scheduledAt time.Time) string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ record, ok := s.records[leaseKey(jobType, jobKey, scheduledAt)]
+ if !ok {
+ return ""
+ }
+ return record.status
+}
+
+func leaseKey(jobType, jobKey string, scheduledAt time.Time) string {
+ return jobType + "|" + jobKey + "|" + scheduledAt.UTC().Format(time.RFC3339Nano)
+}
diff --git a/internal/alertworker/postgres_store.go b/internal/alertworker/postgres_store.go
new file mode 100644
index 0000000..d1bcfe7
--- /dev/null
+++ b/internal/alertworker/postgres_store.go
@@ -0,0 +1,89 @@
+package alertworker
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type PostgresLeaseStore struct {
+ Pool *pgxpool.Pool
+}
+
+func (s PostgresLeaseStore) Acquire(ctx context.Context, jobType, jobKey string, scheduledAt time.Time, owner string, now time.Time, ttl time.Duration) (Lease, bool, error) {
+ if s.Pool == nil {
+ return Lease{}, false, ErrInvalidConfig
+ }
+ tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Lease{}, false, fmt.Errorf("begin evaluator lease: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ leaseID := alert.NewID()
+ leaseUntil := now.Add(ttl)
+ var insertedID string
+ err = tx.QueryRow(ctx, `INSERT INTO job_runs (id,job_type,job_key,scheduled_at,started_at,status,lease_owner,lease_until) VALUES ($1,$2,$3,$4,$5,'running',$6,$7) ON CONFLICT (job_type,job_key,scheduled_at) DO NOTHING RETURNING id`, leaseID, jobType, jobKey, scheduledAt.UTC(), now.UTC(), owner, leaseUntil.UTC()).Scan(&insertedID)
+ if err == nil {
+ if err := tx.Commit(ctx); err != nil {
+ return Lease{}, false, fmt.Errorf("commit evaluator lease: %w", err)
+ }
+ return Lease{ID: insertedID, JobKey: jobKey, ScheduledAt: scheduledAt.UTC(), Owner: owner}, true, nil
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return Lease{}, false, fmt.Errorf("insert evaluator lease: %w", err)
+ }
+ var status string
+ var existingUntil *time.Time
+ if err := tx.QueryRow(ctx, `SELECT status,lease_until FROM job_runs WHERE job_type=$1 AND job_key=$2 AND scheduled_at=$3 FOR UPDATE`, jobType, jobKey, scheduledAt.UTC()).Scan(&status, &existingUntil); err != nil {
+ return Lease{}, false, fmt.Errorf("read evaluator lease: %w", err)
+ }
+ if status != "running" || (existingUntil != nil && existingUntil.After(now)) {
+ if err := tx.Commit(ctx); err != nil {
+ return Lease{}, false, err
+ }
+ return Lease{}, false, nil
+ }
+ tag, err := tx.Exec(ctx, `UPDATE job_runs SET status='running',started_at=$1,completed_at=NULL,error_code=NULL,lease_owner=$2,lease_until=$3 WHERE job_type=$4 AND job_key=$5 AND scheduled_at=$6 AND status='running' AND (lease_until IS NULL OR lease_until <= $7)`, now.UTC(), owner, leaseUntil.UTC(), jobType, jobKey, scheduledAt.UTC(), now.UTC())
+ if err != nil {
+ return Lease{}, false, fmt.Errorf("renew evaluator lease: %w", err)
+ }
+ if tag.RowsAffected() != 1 {
+ if err := tx.Commit(ctx); err != nil {
+ return Lease{}, false, err
+ }
+ return Lease{}, false, nil
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return Lease{}, false, fmt.Errorf("commit evaluator lease renewal: %w", err)
+ }
+ return Lease{ID: leaseID, JobKey: jobKey, ScheduledAt: scheduledAt.UTC(), Owner: owner}, true, nil
+}
+
+func (s PostgresLeaseStore) Complete(ctx context.Context, lease Lease, status, errorCode string) error {
+ if s.Pool == nil {
+ return ErrInvalidConfig
+ }
+ if status != "completed" && status != "failed" && status != "canceled" {
+ return errors.New("invalid evaluator job status")
+ }
+ errorCode = strings.TrimSpace(errorCode)
+ if len(errorCode) > 160 {
+ errorCode = errorCode[:160]
+ }
+ counts, _ := json.Marshal(map[string]string{"status": status})
+ tag, err := s.Pool.Exec(ctx, `UPDATE job_runs SET status=$1,completed_at=now(),counts=$2::jsonb,error_code=NULLIF($3,'') ,lease_owner=NULL,lease_until=NULL WHERE job_type=$4 AND job_key=$5 AND scheduled_at=$6 AND lease_owner=$7 AND status='running'`, status, counts, errorCode, jobType, lease.JobKey, lease.ScheduledAt.UTC(), lease.Owner)
+ if err != nil {
+ return fmt.Errorf("complete evaluator job: %w", err)
+ }
+ if tag.RowsAffected() != 1 {
+ return ErrLeaseLost
+ }
+ return nil
+}
diff --git a/internal/alertworker/postgres_store_integration_test.go b/internal/alertworker/postgres_store_integration_test.go
new file mode 100644
index 0000000..943a35d
--- /dev/null
+++ b/internal/alertworker/postgres_store_integration_test.go
@@ -0,0 +1,57 @@
+package alertworker
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+ "github.com/itworx/pulse/internal/database"
+)
+
+func TestPostgreSQLLeaseStoreCoordinatesAndReclaimsExpiredLease(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 4, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ store := PostgresLeaseStore{Pool: pool}
+ now := time.Date(2026, 8, 2, 5, 0, 0, 0, time.UTC)
+ key := alert.NewID()
+ scheduled := now
+ first, acquired, err := store.Acquire(ctx, jobType, key, scheduled, "worker-a", now, time.Minute)
+ if err != nil || !acquired {
+ t.Fatalf("first acquire lease=%#v acquired=%v err=%v", first, acquired, err)
+ }
+ if _, acquired, err := store.Acquire(ctx, jobType, key, scheduled, "worker-b", now, time.Minute); err != nil || acquired {
+ t.Fatalf("duplicate acquire acquired=%v err=%v", acquired, err)
+ }
+ if err := store.Complete(ctx, first, "completed", ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, acquired, err := store.Acquire(ctx, jobType, key, scheduled, "worker-b", now, time.Minute); err != nil || acquired {
+ t.Fatalf("completed acquire acquired=%v err=%v", acquired, err)
+ }
+ expiredKey := alert.NewID()
+ expired, acquired, err := store.Acquire(ctx, jobType, expiredKey, scheduled, "worker-a", now, time.Second)
+ if err != nil || !acquired {
+ t.Fatalf("expired first acquire=%#v acquired=%v err=%v", expired, acquired, err)
+ }
+ reclaimed, acquired, err := store.Acquire(ctx, jobType, expiredKey, scheduled, "worker-b", now.Add(2*time.Second), time.Minute)
+ if err != nil || !acquired || reclaimed.JobKey != expiredKey {
+ t.Fatalf("reclaim lease=%#v acquired=%v err=%v", reclaimed, acquired, err)
+ }
+ if err := store.Complete(ctx, reclaimed, "failed", "timeout"); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/alertworker/worker.go b/internal/alertworker/worker.go
new file mode 100644
index 0000000..6a5c200
--- /dev/null
+++ b/internal/alertworker/worker.go
@@ -0,0 +1,273 @@
+package alertworker
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+)
+
+const jobType = "alert-evaluation"
+
+var (
+ ErrInvalidConfig = errors.New("alert evaluator configuration is invalid")
+ ErrLeaseLost = errors.New("alert evaluator lease was lost")
+)
+
+type RuleSource interface {
+ ListEnabled(context.Context, int) ([]alert.Rule, error)
+}
+
+type Evaluator interface {
+ Evaluate(context.Context, alert.Rule) error
+}
+
+type EvaluateFunc func(context.Context, alert.Rule) error
+
+func (f EvaluateFunc) Evaluate(ctx context.Context, rule alert.Rule) error {
+ return f(ctx, rule)
+}
+
+type Config struct {
+ MaxConcurrent int
+ MaxBatch int
+ AttemptTimeout time.Duration
+ LeaseTTL time.Duration
+ Owner string
+ Now func() time.Time
+}
+
+func (c Config) validate() error {
+ if c.MaxConcurrent < 1 || c.MaxConcurrent > 64 || c.MaxBatch < 1 || c.MaxBatch > 100 || c.MaxConcurrent > c.MaxBatch {
+ return ErrInvalidConfig
+ }
+ if c.AttemptTimeout < time.Millisecond || c.AttemptTimeout > 2*time.Minute || c.LeaseTTL < c.AttemptTimeout || c.LeaseTTL > 10*time.Minute {
+ return ErrInvalidConfig
+ }
+ if c.Owner == "" || len(c.Owner) > 120 || c.Now == nil {
+ return ErrInvalidConfig
+ }
+ return nil
+}
+
+type Lease struct {
+ ID string
+ JobKey string
+ ScheduledAt time.Time
+ Owner string
+}
+
+type LeaseStore interface {
+ Acquire(context.Context, string, string, time.Time, string, time.Time, time.Duration) (Lease, bool, error)
+ Complete(context.Context, Lease, string, string) error
+}
+
+type JobResult struct {
+ RuleID string
+ JobKey string
+ ScheduledAt time.Time
+ StartedAt time.Time
+ CompletedAt time.Time
+ Status string
+ ErrorCode string
+}
+
+type RunReport struct {
+ Scheduled int
+ Started int
+ Completed int
+ Skipped int
+ Failed int
+ Canceled int
+ Jobs []JobResult
+}
+
+type Metrics struct {
+ RunsStarted uint64
+ RunsCompleted uint64
+ JobsStarted uint64
+ JobsCompleted uint64
+ JobsFailed uint64
+ JobsSkipped uint64
+ LastRunDuration time.Duration
+}
+
+type Worker struct {
+ Source RuleSource
+ Store LeaseStore
+ Evaluator Evaluator
+ Config Config
+ mu sync.Mutex
+ metrics Metrics
+}
+
+func New(source RuleSource, store LeaseStore, evaluator Evaluator, config Config) (Worker, error) {
+ if source == nil || store == nil || evaluator == nil {
+ return Worker{}, ErrInvalidConfig
+ }
+ if config.Owner == "" {
+ config.Owner = alert.NewID()
+ }
+ if config.Now == nil {
+ config.Now = time.Now
+ }
+ if err := config.validate(); err != nil {
+ return Worker{}, err
+ }
+ return Worker{Source: source, Store: store, Evaluator: evaluator, Config: config}, nil
+}
+
+func (w *Worker) RunOnce(ctx context.Context) (RunReport, error) {
+ if err := ctx.Err(); err != nil {
+ return RunReport{}, err
+ }
+ start := w.Config.Now().UTC()
+ w.mu.Lock()
+ w.metrics.RunsStarted++
+ w.mu.Unlock()
+ rules, err := w.Source.ListEnabled(ctx, w.Config.MaxBatch)
+ if err != nil {
+ return RunReport{}, fmt.Errorf("list enabled alert rules: %w", err)
+ }
+ report := RunReport{Scheduled: len(rules), Jobs: make([]JobResult, 0, len(rules))}
+ type job struct {
+ rule alert.Rule
+ lease Lease
+ }
+ jobs := make([]job, 0, len(rules))
+ for _, rule := range rules {
+ interval := time.Duration(rule.EvaluationIntervalSeconds) * time.Second
+ scheduledAt := start.Truncate(interval)
+ key := rule.ID
+ lease, acquired, err := w.Store.Acquire(ctx, jobType, key, scheduledAt, w.Config.Owner, start, w.Config.LeaseTTL)
+ if err != nil {
+ return report, fmt.Errorf("acquire alert evaluation lease: %w", err)
+ }
+ if !acquired {
+ report.Skipped++
+ w.mu.Lock()
+ w.metrics.JobsSkipped++
+ w.mu.Unlock()
+ report.Jobs = append(report.Jobs, JobResult{RuleID: rule.ID, JobKey: key, ScheduledAt: scheduledAt, Status: "skipped"})
+ continue
+ }
+ jobs = append(jobs, job{rule: rule, lease: lease})
+ }
+ if len(jobs) == 0 {
+ w.finishRun(start)
+ return report, nil
+ }
+ sem := make(chan struct{}, w.Config.MaxConcurrent)
+ var wait sync.WaitGroup
+ var reportMu sync.Mutex
+ for _, item := range jobs {
+ select {
+ case <-ctx.Done():
+ report.Canceled++
+ report.Jobs = append(report.Jobs, JobResult{RuleID: item.rule.ID, JobKey: item.lease.JobKey, ScheduledAt: item.lease.ScheduledAt, Status: "canceled", ErrorCode: "shutdown"})
+ _ = w.completeLease(item.lease, "canceled", "shutdown")
+ case sem <- struct{}{}:
+ wait.Add(1)
+ report.Started++
+ w.mu.Lock()
+ w.metrics.JobsStarted++
+ w.mu.Unlock()
+ go func(item job) {
+ defer wait.Done()
+ defer func() { <-sem }()
+ jobResult := w.evaluate(ctx, item.rule, item.lease)
+ reportMu.Lock()
+ report.Jobs = append(report.Jobs, jobResult)
+ switch jobResult.Status {
+ case "completed":
+ report.Completed++
+ case "failed":
+ report.Failed++
+ case "canceled":
+ report.Canceled++
+ }
+ reportMu.Unlock()
+ }(item)
+ }
+ }
+ wait.Wait()
+ sort.Slice(report.Jobs, func(i, j int) bool {
+ if report.Jobs[i].ScheduledAt.Equal(report.Jobs[j].ScheduledAt) {
+ return report.Jobs[i].RuleID < report.Jobs[j].RuleID
+ }
+ return report.Jobs[i].ScheduledAt.Before(report.Jobs[j].ScheduledAt)
+ })
+ w.finishRun(start)
+ return report, nil
+}
+
+func (w *Worker) RunLoop(ctx context.Context, interval time.Duration) error {
+ if interval < time.Second || interval > time.Hour {
+ return ErrInvalidConfig
+ }
+ for {
+ _, err := w.RunOnce(ctx)
+ if err != nil && !errors.Is(err, context.Canceled) {
+ return err
+ }
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-time.After(interval):
+ }
+ }
+}
+
+func (w *Worker) evaluate(ctx context.Context, rule alert.Rule, lease Lease) JobResult {
+ started := w.Config.Now().UTC()
+ jobResult := JobResult{RuleID: rule.ID, JobKey: lease.JobKey, ScheduledAt: lease.ScheduledAt, StartedAt: started}
+ attemptCtx, cancel := context.WithTimeout(ctx, w.Config.AttemptTimeout)
+ err := w.Evaluator.Evaluate(attemptCtx, rule)
+ cancel()
+ status, errorCode := "completed", ""
+ if err != nil {
+ status = "failed"
+ errorCode = "evaluation_failed"
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(attemptCtx.Err(), context.DeadlineExceeded) {
+ errorCode = "timeout"
+ } else if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) {
+ status, errorCode = "canceled", "shutdown"
+ }
+ }
+ jobResult.CompletedAt = w.Config.Now().UTC()
+ jobResult.Status = status
+ jobResult.ErrorCode = errorCode
+ _ = w.completeLease(lease, status, errorCode)
+ w.mu.Lock()
+ switch status {
+ case "completed":
+ w.metrics.JobsCompleted++
+ case "failed", "canceled":
+ w.metrics.JobsFailed++
+ }
+ w.mu.Unlock()
+ return jobResult
+}
+
+func (w *Worker) completeLease(lease Lease, status, errorCode string) error {
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), 2*time.Second)
+ defer cancel()
+ return w.Store.Complete(ctx, lease, status, errorCode)
+}
+
+func (w *Worker) finishRun(start time.Time) {
+ w.mu.Lock()
+ w.metrics.RunsCompleted++
+ w.metrics.LastRunDuration = w.Config.Now().UTC().Sub(start)
+ w.mu.Unlock()
+}
+
+func (w *Worker) Metrics() Metrics {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.metrics
+}
diff --git a/internal/alertworker/worker_test.go b/internal/alertworker/worker_test.go
new file mode 100644
index 0000000..f28609c
--- /dev/null
+++ b/internal/alertworker/worker_test.go
@@ -0,0 +1,188 @@
+package alertworker
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/alert"
+)
+
+type fakeSource struct {
+ rules []alert.Rule
+}
+
+func (s fakeSource) ListEnabled(_ context.Context, limit int) ([]alert.Rule, error) {
+ if limit > len(s.rules) {
+ limit = len(s.rules)
+ }
+ return s.rules[:limit], nil
+}
+
+type trackingEvaluator struct {
+ active atomic.Int32
+ max atomic.Int32
+ calls atomic.Int32
+ wait time.Duration
+ err error
+}
+
+func (e *trackingEvaluator) Evaluate(ctx context.Context, _ alert.Rule) error {
+ e.calls.Add(1)
+ active := e.active.Add(1)
+ for {
+ current := e.max.Load()
+ if active <= current || e.max.CompareAndSwap(current, active) {
+ break
+ }
+ }
+ defer e.active.Add(-1)
+ if e.wait > 0 {
+ timer := time.NewTimer(e.wait)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ }
+ }
+ return e.err
+}
+
+func testRule(id string) alert.Rule {
+ return alert.Rule{Document: alert.Document{ID: id, EvaluationIntervalSeconds: 30}}
+}
+
+func testWorker(t *testing.T, source RuleSource, store LeaseStore, evaluator Evaluator, owner string, now time.Time, maxConcurrent int) *Worker {
+ t.Helper()
+ worker, err := New(source, store, evaluator, Config{MaxConcurrent: maxConcurrent, MaxBatch: 100, AttemptTimeout: 100 * time.Millisecond, LeaseTTL: time.Second, Owner: owner, Now: func() time.Time { return now }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &worker
+}
+
+func TestDuplicateWorkersDoNotEvaluateSameSlotTwice(t *testing.T) {
+ source := fakeSource{rules: []alert.Rule{testRule("rule-1")}}
+ store := NewMemoryLeaseStore()
+ evaluator := &trackingEvaluator{wait: 20 * time.Millisecond}
+ now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
+ first := testWorker(t, source, store, evaluator, "worker-1", now, 1)
+ second := testWorker(t, source, store, evaluator, "worker-2", now, 1)
+ var reports [2]RunReport
+ var wait sync.WaitGroup
+ wait.Add(2)
+ go func() { defer wait.Done(); reports[0], _ = first.RunOnce(context.Background()) }()
+ go func() { defer wait.Done(); reports[1], _ = second.RunOnce(context.Background()) }()
+ wait.Wait()
+ if evaluator.calls.Load() != 1 {
+ t.Fatalf("evaluation calls = %d, want 1", evaluator.calls.Load())
+ }
+ if reports[0].Skipped+reports[1].Skipped != 1 {
+ t.Fatalf("skips = %d, want 1", reports[0].Skipped+reports[1].Skipped)
+ }
+ third, err := first.RunOnce(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if third.Skipped != 1 || evaluator.calls.Load() != 1 {
+ t.Fatalf("repeat report=%#v calls=%d", third, evaluator.calls.Load())
+ }
+}
+
+func TestTimeoutIsVisibleInJobResult(t *testing.T) {
+ source := fakeSource{rules: []alert.Rule{testRule("rule-timeout")}}
+ store := NewMemoryLeaseStore()
+ evaluator := &trackingEvaluator{wait: time.Second}
+ now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
+ worker, err := New(source, store, evaluator, Config{MaxConcurrent: 1, MaxBatch: 1, AttemptTimeout: 10 * time.Millisecond, LeaseTTL: time.Second, Owner: "worker-timeout", Now: func() time.Time { return now }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ report, err := worker.RunOnce(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.Failed != 1 || len(report.Jobs) != 1 || report.Jobs[0].ErrorCode != "timeout" {
+ t.Fatalf("timeout report=%#v", report)
+ }
+}
+
+func TestShutdownCancelsOutstandingEvaluationSafely(t *testing.T) {
+ source := fakeSource{rules: []alert.Rule{testRule("rule-shutdown")}}
+ store := NewMemoryLeaseStore()
+ started := make(chan struct{})
+ evaluator := EvaluateFunc(func(ctx context.Context, _ alert.Rule) error {
+ close(started)
+ <-ctx.Done()
+ return ctx.Err()
+ })
+ now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
+ worker := testWorker(t, source, store, evaluator, "worker-shutdown", now, 1)
+ ctx, cancel := context.WithCancel(context.Background())
+ result := make(chan RunReport, 1)
+ go func() {
+ report, _ := worker.RunOnce(ctx)
+ result <- report
+ }()
+ <-started
+ cancel()
+ report := <-result
+ if report.Canceled != 1 || len(report.Jobs) != 1 || report.Jobs[0].ErrorCode != "shutdown" {
+ t.Fatalf("shutdown report=%#v", report)
+ }
+}
+
+func TestLoadIsBoundedByMaxConcurrentAndBatch(t *testing.T) {
+ rules := make([]alert.Rule, 20)
+ for i := range rules {
+ rules[i] = testRule(alert.NewID())
+ }
+ store := NewMemoryLeaseStore()
+ evaluator := &trackingEvaluator{wait: 2 * time.Millisecond}
+ now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
+ worker := testWorker(t, fakeSource{rules: rules}, store, evaluator, "worker-scale", now, 3)
+ report, err := worker.RunOnce(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.Scheduled != 20 || report.Started != 20 || report.Completed != 20 || evaluator.max.Load() > 3 {
+ t.Fatalf("bounded report=%#v max=%d", report, evaluator.max.Load())
+ }
+ if metrics := worker.Metrics(); metrics.JobsStarted != 20 || metrics.JobsCompleted != 20 {
+ t.Fatalf("metrics=%#v", metrics)
+ }
+}
+
+func TestInvalidWorkerConfigurationAndEvaluationError(t *testing.T) {
+ _, err := New(fakeSource{}, NewMemoryLeaseStore(), EvaluateFunc(func(context.Context, alert.Rule) error { return nil }), Config{MaxConcurrent: 0, MaxBatch: 1, AttemptTimeout: time.Second, LeaseTTL: time.Second, Owner: "x", Now: time.Now})
+ if !errors.Is(err, ErrInvalidConfig) {
+ t.Fatalf("config error=%v", err)
+ }
+ source := fakeSource{rules: []alert.Rule{testRule("rule-error")}}
+ store := NewMemoryLeaseStore()
+ worker := testWorker(t, source, store, EvaluateFunc(func(context.Context, alert.Rule) error { return errors.New("upstream failed") }), "worker-error", time.Now().UTC(), 1)
+ report, err := worker.RunOnce(context.Background())
+ if err != nil || report.Failed != 1 || report.Jobs[0].ErrorCode != "evaluation_failed" {
+ t.Fatalf("error report=%#v err=%v", report, err)
+ }
+}
+
+func TestFailedSlotIsVisibleAndNotRetried(t *testing.T) {
+ source := fakeSource{rules: []alert.Rule{testRule("rule-failed")}}
+ store := NewMemoryLeaseStore()
+ evaluator := &trackingEvaluator{err: errors.New("upstream failed")}
+ now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
+ worker := testWorker(t, source, store, evaluator, "worker-failed", now, 1)
+ first, err := worker.RunOnce(context.Background())
+ if err != nil || first.Failed != 1 || first.Jobs[0].ErrorCode != "evaluation_failed" {
+ t.Fatalf("first report=%#v err=%v", first, err)
+ }
+ second, err := worker.RunOnce(context.Background())
+ if err != nil || second.Skipped != 1 || evaluator.calls.Load() != 1 {
+ t.Fatalf("second report=%#v calls=%d err=%v", second, evaluator.calls.Load(), err)
+ }
+}
diff --git a/internal/application/types.go b/internal/application/types.go
new file mode 100644
index 0000000..522c272
--- /dev/null
+++ b/internal/application/types.go
@@ -0,0 +1,244 @@
+package application
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/reconciliation"
+)
+
+const ContractVersion = "v1"
+
+// SourceID is the stable logical source used for derived application IDs. It
+// is deliberately not a database datasource UUID: the observations remain
+// owned by the underlying container datasource while IDs stay stable across
+// API and worker projections.
+const SourceID = "applications"
+
+type State string
+
+const (
+ StateHealthy State = "healthy"
+ StateDegraded State = "degraded"
+ StateUnknown State = "unknown"
+ StateDown State = "down"
+)
+
+type Source struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Freshness string `json:"freshness"`
+ State string `json:"state"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type ComponentInput struct {
+ ID string
+ Name string
+ Kind string
+ ContainerState State
+ ServiceState State
+ Critical bool
+}
+type DiscoveredApplication struct {
+ ID string
+ Name string
+ Components []ComponentInput
+}
+type ApplicationOverride struct {
+ ApplicationID string
+ Name string
+ CriticalByComponent map[string]bool
+}
+
+type Reason struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ ComponentID string `json:"componentId,omitempty"`
+ Critical bool `json:"critical"`
+}
+type Component struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Critical bool `json:"critical"`
+ ContainerState State `json:"containerState"`
+ ServiceState State `json:"serviceState"`
+ Status State `json:"status"`
+ Reason string `json:"reason,omitempty"`
+}
+type Application struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Status State `json:"status"`
+ Overridden bool `json:"overridden"`
+ Components []Component `json:"components"`
+ Reasons []Reason `json:"reasons,omitempty"`
+}
+type Snapshot struct {
+ ContractVersion string `json:"contractVersion"`
+ Source Source `json:"source"`
+ Applications []Application `json:"applications"`
+ Total int `json:"total"`
+}
+type Provider interface {
+ Snapshot(context.Context) (Snapshot, error)
+}
+
+func UnknownSnapshot(now time.Time, id, typ, reason string) Snapshot {
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: id, Type: typ, ReceivedAt: now.UTC(), Freshness: "unavailable", State: "unknown", Reason: reason}, Applications: []Application{}}
+}
+func StableApplicationID(sourceID, groupKey string) string {
+ return reconciliation.StableEntityID(sourceID, "application", groupKey)
+}
+func BuildSnapshot(source Source, discovered []DiscoveredApplication, overrides []ApplicationOverride, now time.Time) (Snapshot, error) {
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ now = now.UTC()
+ if strings.TrimSpace(source.ID) == "" {
+ return Snapshot{}, errors.New("application source id is required")
+ }
+ if len(discovered) > 150 {
+ return Snapshot{}, errors.New("application count exceeds bounds")
+ }
+ if source.ReceivedAt.IsZero() {
+ source.ReceivedAt = now
+ }
+ if source.ObservedAt.IsZero() {
+ source.ObservedAt = source.ReceivedAt
+ }
+ source.ObservedAt, source.ReceivedAt = source.ObservedAt.UTC(), source.ReceivedAt.UTC()
+ if source.ObservedAt.After(now.Add(time.Minute)) {
+ return Snapshot{}, errors.New("application observation is materially in the future")
+ }
+ source.Freshness, source.State = "fresh", "healthy"
+ if now.Sub(source.ObservedAt) > time.Minute {
+ source.Freshness, source.State, source.Reason = "stale", "unknown", "stale_source"
+ }
+ overrideByID := make(map[string]ApplicationOverride, len(overrides))
+ for _, override := range overrides {
+ if strings.TrimSpace(override.ApplicationID) == "" {
+ return Snapshot{}, errors.New("application override id is required")
+ }
+ if _, exists := overrideByID[override.ApplicationID]; exists {
+ return Snapshot{}, errors.New("duplicate application override")
+ }
+ overrideByID[override.ApplicationID] = override
+ }
+ apps := make([]Application, 0, len(discovered))
+ seenApps := make(map[string]struct{}, len(discovered))
+ componentCount := 0
+ for _, group := range discovered {
+ if strings.TrimSpace(group.ID) == "" || strings.TrimSpace(group.Name) == "" {
+ return Snapshot{}, errors.New("application identity is required")
+ }
+ if _, exists := seenApps[group.ID]; exists {
+ return Snapshot{}, errors.New("duplicate application identity")
+ }
+ seenApps[group.ID] = struct{}{}
+ componentCount += len(group.Components)
+ if componentCount > 1000 {
+ return Snapshot{}, errors.New("application component count exceeds bounds")
+ }
+ override, overridden := overrideByID[group.ID]
+ name := group.Name
+ if strings.TrimSpace(override.Name) != "" {
+ name = strings.TrimSpace(override.Name)
+ }
+ components := make([]Component, 0, len(group.Components))
+ seenComponents := make(map[string]struct{}, len(group.Components))
+ for _, input := range group.Components {
+ if strings.TrimSpace(input.ID) == "" || strings.TrimSpace(input.Name) == "" {
+ return Snapshot{}, errors.New("application component identity is required")
+ }
+ if _, exists := seenComponents[input.ID]; exists {
+ return Snapshot{}, errors.New("duplicate application component")
+ }
+ seenComponents[input.ID] = struct{}{}
+ critical := input.Critical
+ if value, ok := override.CriticalByComponent[input.ID]; ok {
+ critical = value
+ }
+ components = append(components, evaluateComponent(input, critical))
+ }
+ sort.Slice(components, func(i, j int) bool { return components[i].ID < components[j].ID })
+ status, reasons := aggregate(components)
+ apps = append(apps, Application{ID: group.ID, Name: name, Status: status, Overridden: overridden, Components: components, Reasons: reasons})
+ }
+ sort.Slice(apps, func(i, j int) bool { return apps[i].ID < apps[j].ID })
+ if source.State == "unknown" {
+ for i := range apps {
+ apps[i].Status = StateUnknown
+ apps[i].Reasons = append(apps[i].Reasons, Reason{Code: "source_unknown", Message: "Applicatiebron is onbekend.", Critical: true})
+ }
+ }
+ return Snapshot{ContractVersion: ContractVersion, Source: source, Applications: apps, Total: len(apps)}, nil
+}
+func evaluateComponent(input ComponentInput, critical bool) Component {
+ container, service := normalizeState(input.ContainerState), normalizeState(input.ServiceState)
+ status, reason := container, ""
+ if container == StateHealthy && service != "" {
+ status = service
+ }
+ if status != StateHealthy {
+ if service != "" && service != StateHealthy && container == StateHealthy {
+ reason = "service_" + string(service)
+ } else {
+ reason = "container_" + string(status)
+ }
+ }
+ return Component{ID: input.ID, Name: input.Name, Kind: input.Kind, Critical: critical, ContainerState: container, ServiceState: service, Status: status, Reason: reason}
+}
+func aggregate(components []Component) (State, []Reason) {
+ reasons := make([]Reason, 0)
+ criticalFailure, criticalUnknown, optionalFailure := false, false, false
+ for _, component := range components {
+ if component.Status == StateHealthy {
+ continue
+ }
+ reasons = append(reasons, Reason{Code: component.Reason, Message: component.Name + " is " + string(component.Status) + ".", ComponentID: component.ID, Critical: component.Critical})
+ if component.Critical {
+ if component.Status == StateUnknown {
+ criticalUnknown = true
+ } else {
+ criticalFailure = true
+ }
+ } else {
+ optionalFailure = true
+ }
+ }
+ sort.Slice(reasons, func(i, j int) bool {
+ if reasons[i].ComponentID != reasons[j].ComponentID {
+ return reasons[i].ComponentID < reasons[j].ComponentID
+ }
+ return reasons[i].Code < reasons[j].Code
+ })
+ if criticalFailure {
+ return StateDegraded, reasons
+ }
+ if criticalUnknown {
+ return StateUnknown, reasons
+ }
+ if optionalFailure {
+ return StateDegraded, reasons
+ }
+ return StateHealthy, reasons
+}
+func normalizeState(state State) State {
+ state = State(strings.ToLower(strings.TrimSpace(string(state))))
+ switch state {
+ case StateHealthy, StateDegraded, StateUnknown, StateDown:
+ return state
+ default:
+ return StateUnknown
+ }
+}
diff --git a/internal/application/types_test.go b/internal/application/types_test.go
new file mode 100644
index 0000000..c22f8e1
--- /dev/null
+++ b/internal/application/types_test.go
@@ -0,0 +1,81 @@
+package application
+
+import (
+ "testing"
+ "time"
+)
+
+func appSource(now time.Time) Source {
+ return Source{ID: "agent", Type: "agent", ObservedAt: now, ReceivedAt: now}
+}
+
+func TestCriticalAndOptionalAggregationAndReasons(t *testing.T) {
+ now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
+ snapshot, err := BuildSnapshot(appSource(now), []DiscoveredApplication{{ID: "app-1", Name: "Pulse", Components: []ComponentInput{
+ {ID: "db", Name: "Database", Kind: "container", ContainerState: StateHealthy, ServiceState: StateHealthy, Critical: true},
+ {ID: "worker", Name: "Worker", Kind: "container", ContainerState: StateDown, ServiceState: StateDown, Critical: false},
+ }}}, nil, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Applications[0].Status != StateDegraded || len(snapshot.Applications[0].Reasons) != 1 || snapshot.Applications[0].Reasons[0].Critical {
+ t.Fatalf("unexpected optional aggregation: %+v", snapshot.Applications[0])
+ }
+}
+func TestServiceDownWhileContainerRunningDegradesCriticalApplication(t *testing.T) {
+ now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
+ snapshot, err := BuildSnapshot(appSource(now), []DiscoveredApplication{{ID: "app-1", Name: "Pulse", Components: []ComponentInput{
+ {ID: "api", Name: "API", Kind: "container", ContainerState: StateHealthy, ServiceState: StateDown, Critical: true},
+ }}}, nil, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ app := snapshot.Applications[0]
+ if app.Status != StateDegraded || len(app.Reasons) != 1 || app.Reasons[0].Code != "service_down" || !app.Reasons[0].Critical {
+ t.Fatalf("service failure not explained: %+v", app)
+ }
+}
+func TestUnknownCriticalIsNotHealthy(t *testing.T) {
+ now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
+ snapshot, err := BuildSnapshot(appSource(now), []DiscoveredApplication{{ID: "app-1", Name: "Pulse", Components: []ComponentInput{{ID: "db", Name: "Database", Critical: true, ContainerState: StateUnknown}}}}, nil, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Applications[0].Status != StateUnknown {
+ t.Fatalf("unknown critical became healthy: %+v", snapshot.Applications[0])
+ }
+}
+
+func TestStateNormalizationIsCaseInsensitive(t *testing.T) {
+ now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
+ snapshot, err := BuildSnapshot(appSource(now), []DiscoveredApplication{{ID: "app-1", Name: "Pulse", Components: []ComponentInput{{ID: "api", Name: "API", Critical: true, ContainerState: "HEALTHY", ServiceState: "HEALTHY"}}}}, nil, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Applications[0].Status != StateHealthy {
+ t.Fatalf("uppercase healthy state was not normalized: %+v", snapshot.Applications[0])
+ }
+}
+func TestUserOverridePersistsAcrossDiscoveryProjection(t *testing.T) {
+ now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
+ discovered := []DiscoveredApplication{{ID: "app-1", Name: "Discovered name", Components: []ComponentInput{{ID: "worker", Name: "Worker", Critical: true, ContainerState: StateHealthy}}}}
+ override := []ApplicationOverride{{ApplicationID: "app-1", Name: "Handmatige naam", CriticalByComponent: map[string]bool{"worker": false}}}
+ first, err := BuildSnapshot(appSource(now), discovered, override, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := BuildSnapshot(appSource(now.Add(time.Minute)), discovered, override, now.Add(time.Minute))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, snapshot := range []Snapshot{first, second} {
+ if snapshot.Applications[0].Name != "Handmatige naam" || !snapshot.Applications[0].Overridden || snapshot.Applications[0].Components[0].Critical {
+ t.Fatalf("override not retained: %+v", snapshot.Applications[0])
+ }
+ }
+}
+func TestStableApplicationIDIsDeterministic(t *testing.T) {
+ if StableApplicationID("agent", "pulse") != StableApplicationID("agent", "pulse") {
+ t.Fatal("stable application id changed")
+ }
+}
diff --git a/internal/applicationapi/handler.go b/internal/applicationapi/handler.go
new file mode 100644
index 0000000..2563701
--- /dev/null
+++ b/internal/applicationapi/handler.go
@@ -0,0 +1,68 @@
+package applicationapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/application"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Handler struct {
+ Provider interface {
+ Snapshot(context.Context) (application.Snapshot, error)
+ }
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || (r.URL.Path != "/api/v1/applications" && !strings.HasPrefix(r.URL.Path, "/api/v1/applications/")) {
+ http.NotFound(w, r)
+ return
+ }
+ if _, ok := auth.PrincipalFromContext(r.Context()); !ok {
+ problem.Write(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required", "Authentication is required to read applications.", nil)
+ return
+ }
+ snapshot, err := h.snapshot(r)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return
+ }
+ problem.Write(w, r, http.StatusServiceUnavailable, "APPLICATIONS_UNAVAILABLE", "Applications not available", "Applicatiegegevens konden niet worden gelezen.", nil)
+ return
+ }
+ if r.URL.Path != "/api/v1/applications" {
+ id := strings.TrimPrefix(r.URL.Path, "/api/v1/applications/")
+ for _, item := range snapshot.Applications {
+ if item.ID == id {
+ writeJSON(w, struct {
+ Source application.Source `json:"source"`
+ Application application.Application `json:"application"`
+ }{Source: snapshot.Source, Application: item})
+ return
+ }
+ }
+ http.NotFound(w, r)
+ return
+ }
+ if len(snapshot.Applications) > 100 {
+ snapshot.Applications = snapshot.Applications[:100]
+ }
+ writeJSON(w, snapshot)
+}
+func (h Handler) snapshot(r *http.Request) (application.Snapshot, error) {
+ if h.Provider == nil {
+ return application.UnknownSnapshot(time.Now().UTC(), "applications", "agent", "source_unavailable"), nil
+ }
+ return h.Provider.Snapshot(r.Context())
+}
+func writeJSON(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, max-age=5")
+ _ = json.NewEncoder(w).Encode(value)
+}
diff --git a/internal/applicationapi/handler_test.go b/internal/applicationapi/handler_test.go
new file mode 100644
index 0000000..d1db988
--- /dev/null
+++ b/internal/applicationapi/handler_test.go
@@ -0,0 +1,46 @@
+package applicationapi
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/itworx/pulse/internal/application"
+ "github.com/itworx/pulse/internal/auth"
+)
+
+type provider struct{ value application.Snapshot }
+
+func (p provider) Snapshot(context.Context) (application.Snapshot, error) { return p.value, nil }
+func request(method, path string) *http.Request {
+ r := httptest.NewRequest(method, path, nil)
+ return r.WithContext(auth.WithPrincipal(r.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
+}
+func TestHandlerListDetailAndAuth(t *testing.T) {
+ snapshot := application.Snapshot{ContractVersion: application.ContractVersion, Applications: []application.Application{{ID: "app-1", Name: "Pulse", Status: application.StateHealthy}}}
+ handler := Handler{Provider: provider{value: snapshot}}
+ list := httptest.NewRecorder()
+ handler.ServeHTTP(list, request(http.MethodGet, "/api/v1/applications"))
+ if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "\"name\":\"Pulse\"") {
+ t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
+ }
+ detail := httptest.NewRecorder()
+ handler.ServeHTTP(detail, request(http.MethodGet, "/api/v1/applications/app-1"))
+ if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "\"id\":\"app-1\"") {
+ t.Fatalf("detail status=%d body=%s", detail.Code, detail.Body.String())
+ }
+ mutation := httptest.NewRecorder()
+ handler.ServeHTTP(mutation, request(http.MethodPost, "/api/v1/applications/app-1"))
+ if mutation.Code != http.StatusNotFound {
+ t.Fatalf("mutation status=%d", mutation.Code)
+ }
+}
+func TestHandlerRequiresAuthentication(t *testing.T) {
+ response := httptest.NewRecorder()
+ Handler{}.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/applications", nil))
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("status=%d", response.Code)
+ }
+}
diff --git a/internal/array/types.go b/internal/array/types.go
new file mode 100644
index 0000000..671a22d
--- /dev/null
+++ b/internal/array/types.go
@@ -0,0 +1,398 @@
+package array
+
+import (
+ "context"
+ "errors"
+ "math"
+ "reflect"
+ "sort"
+ "strings"
+ "time"
+)
+
+const ContractVersion = "v1"
+
+const (
+ StateOperational = "operational"
+ StateDegraded = "degraded"
+ StateMissing = "missing"
+ StateUnknown = "unknown"
+ Fresh = "fresh"
+ Stale = "stale"
+ Unavailable = "unavailable"
+)
+
+type Limits struct {
+ MaxMembers int
+ MaxHistory int
+ MaxErrors int
+}
+
+func (l Limits) withDefaults() Limits {
+ if l.MaxMembers == 0 {
+ l.MaxMembers = 64
+ }
+ if l.MaxHistory == 0 {
+ l.MaxHistory = 64
+ }
+ if l.MaxErrors == 0 {
+ l.MaxErrors = 20
+ }
+ return l
+}
+
+func (l Limits) Validate() error {
+ if l.MaxMembers < 1 || l.MaxMembers > 256 || l.MaxHistory < 1 || l.MaxHistory > 256 || l.MaxErrors < 1 || l.MaxErrors > 100 {
+ return errors.New("array limits are outside safe bounds")
+ }
+ return nil
+}
+
+type Policy struct{ FreshnessMaxAge time.Duration }
+
+func (p Policy) withDefaults() Policy {
+ if p.FreshnessMaxAge == 0 {
+ p.FreshnessMaxAge = 60 * time.Second
+ }
+ return p
+}
+func (p Policy) Validate() error {
+ if p.FreshnessMaxAge <= 0 || p.FreshnessMaxAge > 24*time.Hour {
+ return errors.New("array policy is outside safe bounds")
+ }
+ return nil
+}
+
+type Source struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ CapabilityVersion string `json:"capabilityVersion"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Freshness string `json:"freshness"`
+ State string `json:"state"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type RawMember struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ State string `json:"state"`
+ CapacityBytes uint64 `json:"capacityBytes"`
+ ReadBytes uint64 `json:"readBytes"`
+ WriteBytes uint64 `json:"writeBytes"`
+}
+
+type Member struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ State string `json:"state"`
+ CapacityBytes uint64 `json:"capacityBytes"`
+ ReadBytes uint64 `json:"readBytes"`
+ WriteBytes uint64 `json:"writeBytes"`
+}
+
+type RawParity struct {
+ Present bool `json:"present"`
+ State string `json:"state"`
+ Errors uint64 `json:"errors"`
+}
+type Parity struct {
+ Present bool `json:"present"`
+ State string `json:"state"`
+ Errors uint64 `json:"errors"`
+}
+
+type RawCheck struct {
+ ID string `json:"id"`
+ State string `json:"state"`
+ ProgressPercent float64 `json:"progressPercent"`
+ SpeedBytesPerSecond uint64 `json:"speedBytesPerSecond"`
+ Errors uint64 `json:"errors"`
+ StartedAt *time.Time `json:"startedAt,omitempty"`
+ CompletedAt *time.Time `json:"completedAt,omitempty"`
+}
+type Check struct {
+ ID string `json:"id"`
+ State string `json:"state"`
+ ProgressPercent float64 `json:"progressPercent"`
+ SpeedBytesPerSecond uint64 `json:"speedBytesPerSecond"`
+ Errors uint64 `json:"errors"`
+ StartedAt *time.Time `json:"startedAt,omitempty"`
+ CompletedAt *time.Time `json:"completedAt,omitempty"`
+}
+
+type RawSnapshot struct {
+ Source Source `json:"source"`
+ State string `json:"state"`
+ Parity RawParity `json:"parity"`
+ Members []RawMember `json:"members"`
+ CurrentCheck *RawCheck `json:"currentCheck,omitempty"`
+ History []RawCheck `json:"history,omitempty"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+}
+
+type Snapshot struct {
+ ContractVersion string `json:"contractVersion"`
+ Source Source `json:"source"`
+ State string `json:"state"`
+ Parity Parity `json:"parity"`
+ Members []Member `json:"members"`
+ CurrentCheck *Check `json:"currentCheck,omitempty"`
+ History []Check `json:"history"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+}
+
+type Provider interface {
+ Snapshot(context.Context) (Snapshot, error)
+}
+type RawProvider interface {
+ Snapshot(context.Context) (RawSnapshot, error)
+}
+type Adapter struct {
+ Source RawProvider
+ Limits Limits
+ Policy Policy
+ Now func() time.Time
+}
+
+func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return Snapshot{}, err
+ }
+ if a.Source == nil {
+ return UnknownSnapshot(time.Now().UTC(), "array", "unraid", "source_unavailable"), nil
+ }
+ raw, err := a.Source.Snapshot(ctx)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ now := time.Now().UTC()
+ if a.Now != nil {
+ now = a.Now()
+ }
+ return Normalize(raw, now, a.Limits, a.Policy)
+}
+
+func UnknownSnapshot(now time.Time, id, typ, reason string) Snapshot {
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: id, Type: typ, CapabilityVersion: ContractVersion, ReceivedAt: now.UTC(), Freshness: Unavailable, State: StateUnknown, Reason: reason}, State: StateUnknown, Members: []Member{}, History: []Check{}, ObservedAt: now.UTC(), ReceivedAt: now.UTC()}
+}
+
+func Normalize(raw RawSnapshot, now time.Time, limits Limits, policy Policy) (Snapshot, error) {
+ limits = limits.withDefaults()
+ policy = policy.withDefaults()
+ if err := limits.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ if err := policy.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ if raw.ReceivedAt.IsZero() {
+ raw.ReceivedAt = now
+ }
+ if raw.ObservedAt.IsZero() {
+ raw.ObservedAt = raw.ReceivedAt
+ }
+ if raw.ObservedAt.After(now.Add(time.Minute)) {
+ return Snapshot{}, errors.New("array observation is materially in the future")
+ }
+ if len(raw.Members) > limits.MaxMembers || len(raw.History) > limits.MaxHistory {
+ return Snapshot{}, errors.New("array payload exceeds bounds")
+ }
+ if raw.CurrentCheck != nil {
+ if err := validateCheck(*raw.CurrentCheck); err != nil {
+ return Snapshot{}, err
+ }
+ }
+ for _, item := range raw.History {
+ if err := validateCheck(item); err != nil {
+ return Snapshot{}, err
+ }
+ }
+ members := make([]Member, 0, len(raw.Members))
+ seenMembers := make(map[string]RawMember, len(raw.Members))
+ missing := 0
+ for _, item := range raw.Members {
+ item.ID = canonicalIdentity(item.ID)
+ if previous, ok := seenMembers[item.ID]; ok {
+ if reflect.DeepEqual(previous, item) {
+ continue
+ }
+ return Snapshot{}, errors.New("conflicting duplicate array member identity")
+ }
+ seenMembers[item.ID] = item
+ if strings.TrimSpace(item.ID) == "" || strings.TrimSpace(item.Name) == "" || len(item.ID) > 128 || len(item.Name) > 255 {
+ return Snapshot{}, errors.New("array member identity is invalid")
+ }
+ state := bounded(item.State, StateUnknown)
+ role := bounded(item.Role, "data")
+ if state == "missing" || state == "disabled" || state == "emulated" {
+ missing++
+ }
+ members = append(members, Member{ID: item.ID, Name: item.Name, Role: role, State: state, CapacityBytes: item.CapacityBytes, ReadBytes: item.ReadBytes, WriteBytes: item.WriteBytes})
+ }
+ sort.Slice(members, func(i, j int) bool {
+ if members[i].Role != members[j].Role {
+ return members[i].Role < members[j].Role
+ }
+ if members[i].Name != members[j].Name {
+ return members[i].Name < members[j].Name
+ }
+ return members[i].ID < members[j].ID
+ })
+ parity := Parity{Present: raw.Parity.Present, State: bounded(raw.Parity.State, StateUnknown), Errors: raw.Parity.Errors}
+ state := bounded(raw.State, StateUnknown)
+ if state != StateOperational && state != StateDegraded && state != StateMissing && state != StateUnknown {
+ state = StateUnknown
+ }
+ if missing > 0 && state == StateOperational {
+ state = StateDegraded
+ }
+ if parity.Errors > 0 && state == StateOperational {
+ state = StateDegraded
+ }
+ source := raw.Source
+ if source.ID == "" {
+ source.ID = "array"
+ }
+ if source.Type == "" {
+ source.Type = "unraid"
+ }
+ if source.CapabilityVersion == "" {
+ source.CapabilityVersion = ContractVersion
+ }
+ source.ObservedAt = raw.ObservedAt.UTC()
+ source.ReceivedAt = raw.ReceivedAt.UTC()
+ source.Freshness = Fresh
+ source.State = state
+ if now.Sub(raw.ObservedAt) > policy.FreshnessMaxAge {
+ source.Freshness = Stale
+ source.State = StateUnknown
+ source.Reason = "stale_source"
+ state = StateUnknown
+ for i := range members {
+ members[i].State = StateUnknown
+ }
+ }
+ result := Snapshot{ContractVersion: ContractVersion, Source: source, State: state, Parity: parity, Members: members, ObservedAt: raw.ObservedAt.UTC(), ReceivedAt: raw.ReceivedAt.UTC()}
+ if raw.CurrentCheck != nil {
+ current := normalizeCheck(*raw.CurrentCheck)
+ result.CurrentCheck = ¤t
+ }
+ result.History = make([]Check, 0, len(raw.History))
+ for _, item := range raw.History {
+ result.History = append(result.History, normalizeCheck(item))
+ }
+ sort.SliceStable(result.History, func(i, j int) bool { return checkTime(result.History[i]).After(checkTime(result.History[j])) })
+ return result, nil
+}
+func canonicalIdentity(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
+
+func validateCheck(item RawCheck) error {
+ if strings.TrimSpace(item.ID) == "" || len(item.ID) > 128 || item.ProgressPercent < 0 || item.ProgressPercent > 100 || math.IsNaN(item.ProgressPercent) || math.IsInf(item.ProgressPercent, 0) {
+ return errors.New("invalid parity check")
+ }
+ if item.CompletedAt != nil && item.StartedAt != nil && item.CompletedAt.Before(*item.StartedAt) {
+ return errors.New("parity check completed before start")
+ }
+ return nil
+}
+func normalizeCheck(item RawCheck) Check {
+ result := Check{ID: item.ID, State: bounded(item.State, StateUnknown), ProgressPercent: item.ProgressPercent, SpeedBytesPerSecond: item.SpeedBytesPerSecond, Errors: item.Errors}
+ if item.StartedAt != nil {
+ value := item.StartedAt.UTC()
+ result.StartedAt = &value
+ }
+ if item.CompletedAt != nil {
+ value := item.CompletedAt.UTC()
+ result.CompletedAt = &value
+ }
+ return result
+}
+func checkTime(item Check) time.Time {
+ if item.CompletedAt != nil {
+ return *item.CompletedAt
+ }
+ if item.StartedAt != nil {
+ return *item.StartedAt
+ }
+ return time.Time{}
+}
+func bounded(value, fallback string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return fallback
+ }
+ if len(value) > 64 {
+ return value[:64]
+ }
+ return value
+}
+
+type Event struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Severity string `json:"severity"`
+ EntityID string `json:"entityId"`
+ OccurredAt time.Time `json:"occurredAt"`
+ Attributes map[string]string `json:"attributes,omitempty"`
+}
+
+func TransitionEvents(previous, current Snapshot) []Event {
+ var events []Event
+ at := current.ObservedAt
+ if at.IsZero() {
+ at = current.ReceivedAt
+ }
+ if at.IsZero() {
+ at = time.Now().UTC()
+ }
+ at = at.UTC()
+ add := func(kind, severity string, attrs map[string]string) {
+ events = append(events, Event{ID: eventID(kind, current, at), Type: kind, Severity: severity, EntityID: "array", OccurredAt: at, Attributes: attrs})
+ }
+ if previous.State != current.State {
+ switch current.State {
+ case StateDegraded:
+ add("array.degraded", "warning", map[string]string{"state": current.State})
+ case StateMissing:
+ add("array.missing", "critical", map[string]string{"state": current.State})
+ case StateOperational:
+ if previous.State == StateDegraded || previous.State == StateMissing {
+ add("array.recovered", "info", map[string]string{"state": current.State})
+ }
+ }
+ }
+ if previous.Parity.State != current.Parity.State || previous.Parity.Errors != current.Parity.Errors {
+ severity := "info"
+ if current.Parity.Errors > 0 || current.Parity.State == "failed" {
+ severity = "critical"
+ }
+ add("array.parity_changed", severity, map[string]string{"state": current.Parity.State, "errors": formatUint(current.Parity.Errors)})
+ }
+ return events
+}
+func eventID(kind string, snapshot Snapshot, at time.Time) string {
+ return kind + ":" + snapshot.Source.ID + ":" + at.Format(time.RFC3339Nano)
+}
+func formatUint(value uint64) string {
+ if value == 0 {
+ return "0"
+ }
+ digits := make([]byte, 0, 20)
+ for value > 0 {
+ digits = append([]byte{byte('0' + value%10)}, digits...)
+ value /= 10
+ }
+ return string(digits)
+}
diff --git a/internal/array/types_test.go b/internal/array/types_test.go
new file mode 100644
index 0000000..68678a6
--- /dev/null
+++ b/internal/array/types_test.go
@@ -0,0 +1,126 @@
+package array
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+type rawProvider struct {
+ snapshot RawSnapshot
+ err error
+}
+
+func (p rawProvider) Snapshot(context.Context) (RawSnapshot, error) { return p.snapshot, p.err }
+
+func baseRaw(now time.Time) RawSnapshot {
+ return RawSnapshot{Source: Source{ID: "fixture-array", Type: "fixture"}, State: StateOperational, Parity: RawParity{Present: true, State: "idle"}, Members: []RawMember{{ID: "disk1", Name: "Disk 1", Role: "data", State: "online", CapacityBytes: 100}, {ID: "parity", Name: "Parity", Role: "parity", State: "online", CapacityBytes: 100}}, ObservedAt: now, ReceivedAt: now}
+}
+
+func TestNormalizeOperationalDegradedAndMissingFixtures(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ operational, err := Normalize(baseRaw(now), now, Limits{}, Policy{})
+ if err != nil || operational.State != StateOperational || operational.Source.State != StateOperational {
+ t.Fatalf("operational=%+v err=%v", operational, err)
+ }
+ degradedRaw := baseRaw(now)
+ degradedRaw.Members[0].State = "emulated"
+ degraded, err := Normalize(degradedRaw, now, Limits{}, Policy{})
+ if err != nil || degraded.State != StateDegraded {
+ t.Fatalf("degraded=%+v err=%v", degraded, err)
+ }
+ missingRaw := baseRaw(now)
+ missingRaw.State = StateMissing
+ missingRaw.Members[0].State = "missing"
+ missing, err := Normalize(missingRaw, now, Limits{}, Policy{})
+ if err != nil || missing.State != StateMissing {
+ t.Fatalf("missing=%+v err=%v", missing, err)
+ }
+}
+
+func TestNormalizeProgressSpeedHistoryAndDeterministicOrder(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ start := now.Add(-10 * time.Minute)
+ older := now.Add(-2 * time.Hour)
+ raw := baseRaw(now)
+ raw.Members = append(raw.Members, RawMember{ID: "disk0", Name: "Disk 0", Role: "data", State: "online"})
+ raw.CurrentCheck = &RawCheck{ID: "check-current", State: "running", ProgressPercent: 42.5, SpeedBytesPerSecond: 123456, StartedAt: &start}
+ raw.History = []RawCheck{{ID: "old", State: "completed", ProgressPercent: 100, CompletedAt: &older}, {ID: "new", State: "failed", ProgressPercent: 87, Errors: 2, CompletedAt: &start}}
+ got, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.CurrentCheck == nil || got.CurrentCheck.ProgressPercent != 42.5 || got.CurrentCheck.SpeedBytesPerSecond != 123456 {
+ t.Fatalf("current check=%+v", got.CurrentCheck)
+ }
+ if len(got.History) != 2 || got.History[0].ID != "new" {
+ t.Fatalf("history=%+v", got.History)
+ }
+ if got.Members[0].ID != "disk0" || got.Members[1].Role != "data" {
+ t.Fatalf("members=%+v", got.Members)
+ }
+}
+
+func TestNormalizeStaleIsUnknownAndContextCancellation(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ raw := baseRaw(now.Add(-2 * time.Minute))
+ got, err := Normalize(raw, now, Limits{}, Policy{FreshnessMaxAge: time.Minute})
+ if err != nil || got.State != StateUnknown || got.Source.State != StateUnknown || got.Source.Freshness != Stale || got.Members[0].State != StateUnknown {
+ t.Fatalf("stale=%+v err=%v", got, err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err = (Adapter{}).Snapshot(ctx)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("err=%v", err)
+ }
+}
+
+func TestTransitionEventsAreBoundedAndDeterministic(t *testing.T) {
+ at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ previous := UnknownSnapshot(at, "fixture-array", "fixture", "initial")
+ current := previous
+ current.ObservedAt = at
+ current.Source.State = StateDegraded
+ current.State = StateDegraded
+ current.Parity.State = "failed"
+ current.Parity.Errors = 3
+ events := TransitionEvents(previous, current)
+ if len(events) != 2 {
+ t.Fatalf("events=%+v", events)
+ }
+ if events[0].Type != "array.degraded" || events[1].Type != "array.parity_changed" {
+ t.Fatalf("events=%+v", events)
+ }
+ if events[0].ID == "" || !events[1].OccurredAt.Equal(at) {
+ t.Fatalf("events=%+v", events)
+ }
+}
+
+func TestAdapterUnknownFallbackAndProviderError(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ unknown, err := (Adapter{Now: func() time.Time { return now }}).Snapshot(context.Background())
+ if err != nil || unknown.State != StateUnknown || unknown.Source.Reason != "source_unavailable" {
+ t.Fatalf("unknown=%+v err=%v", unknown, err)
+ }
+ expected := errors.New("fixture")
+ _, err = (Adapter{Source: rawProvider{err: expected}}).Snapshot(context.Background())
+ if !errors.Is(err, expected) {
+ t.Fatalf("err=%v", err)
+ }
+}
+
+func TestArrayMembersDeduplicateByCanonicalPhysicalIdentity(t *testing.T) {
+ now := time.Date(2026, 8, 11, 23, 45, 0, 0, time.UTC)
+ raw := baseRaw(now)
+ raw.Members = []RawMember{{ID: " DISK-1 ", Name: "disk1", Role: "data", State: "online", CapacityBytes: 100}, {ID: " DISK-1 ", Name: "disk1", Role: "data", State: "online", CapacityBytes: 100}}
+ got, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil || len(got.Members) != 1 || got.Members[0].ID != "disk-1" {
+ t.Fatalf("members=%+v err=%v", got.Members, err)
+ }
+ raw.Members[1].Role = "parity"
+ if _, err := Normalize(raw, now, Limits{}, Policy{}); err == nil {
+ t.Fatal("conflicting physical roles must fail closed")
+ }
+}
diff --git a/internal/arrayapi/handler.go b/internal/arrayapi/handler.go
new file mode 100644
index 0000000..5fb8887
--- /dev/null
+++ b/internal/arrayapi/handler.go
@@ -0,0 +1,52 @@
+package arrayapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/itworx/pulse/internal/array"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Handler struct{ Provider array.Provider }
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/array" {
+ http.NotFound(w, r)
+ return
+ }
+ if _, ok := auth.PrincipalFromContext(r.Context()); !ok {
+ problem.Write(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required", "Authentication is required to read array status.", nil)
+ return
+ }
+ if err := r.Context().Err(); err != nil {
+ return
+ }
+ var snapshot array.Snapshot
+ var err error
+ if h.Provider == nil {
+ snapshot = array.UnknownSnapshot(time.Now().UTC(), "array", "unraid", "source_unavailable")
+ } else {
+ snapshot, err = h.Provider.Snapshot(r.Context())
+ }
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(r.Context().Err(), context.Canceled) {
+ return
+ }
+ problem.Write(w, r, http.StatusServiceUnavailable, "ARRAY_UNAVAILABLE", "Array status unavailable", "De arraystatus kon niet worden gelezen.", nil)
+ return
+ }
+ if snapshot.Members == nil {
+ snapshot.Members = []array.Member{}
+ }
+ if snapshot.History == nil {
+ snapshot.History = []array.Check{}
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, max-age=5")
+ _ = json.NewEncoder(w).Encode(snapshot)
+}
diff --git a/internal/arrayapi/handler_test.go b/internal/arrayapi/handler_test.go
new file mode 100644
index 0000000..79f3062
--- /dev/null
+++ b/internal/arrayapi/handler_test.go
@@ -0,0 +1,63 @@
+package arrayapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/array"
+ "github.com/itworx/pulse/internal/auth"
+)
+
+type provider struct{ snapshot array.Snapshot }
+
+func (p provider) Snapshot(context.Context) (array.Snapshot, error) { return p.snapshot, nil }
+func authenticatedRequest(method, path string) *http.Request {
+ request := httptest.NewRequest(method, path, nil)
+ return request.WithContext(auth.WithPrincipal(request.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
+}
+
+func TestHandlerRequiresAuthenticationAndReturnsUnknown(t *testing.T) {
+ unauthenticated := httptest.NewRecorder()
+ Handler{}.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/v1/array", nil))
+ if unauthenticated.Code != http.StatusUnauthorized {
+ t.Fatalf("status=%d", unauthenticated.Code)
+ }
+ response := httptest.NewRecorder()
+ Handler{}.ServeHTTP(response, authenticatedRequest(http.MethodGet, "/api/v1/array"))
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"state":"unknown"`) {
+ t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
+ }
+}
+func TestHandlerReturnsSnapshotAndRejectsMutations(t *testing.T) {
+ snapshot := array.UnknownSnapshot(time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), "fixture-array", "fixture", "test")
+ response := httptest.NewRecorder()
+ Handler{Provider: provider{snapshot: snapshot}}.ServeHTTP(response, authenticatedRequest(http.MethodGet, "/api/v1/array"))
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"id":"fixture-array"`) {
+ t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
+ }
+ mutation := httptest.NewRecorder()
+ Handler{}.ServeHTTP(mutation, authenticatedRequest(http.MethodPost, "/api/v1/array/check"))
+ if mutation.Code != http.StatusNotFound {
+ t.Fatalf("status=%d", mutation.Code)
+ }
+}
+
+func TestHandlerEncodesEmptyCollectionsAsArrays(t *testing.T) {
+ response := httptest.NewRecorder()
+ Handler{Provider: provider{snapshot: array.Snapshot{}}}.ServeHTTP(response, authenticatedRequest(http.MethodGet, "/api/v1/array"))
+ var body struct {
+ Members []array.Member `json:"members"`
+ History []array.Check `json:"history"`
+ }
+ if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Members == nil || body.History == nil {
+ t.Fatalf("empty collections must be JSON arrays: %s", response.Body.String())
+ }
+}
diff --git a/internal/audit/audit.go b/internal/audit/audit.go
new file mode 100644
index 0000000..bc5d90f
--- /dev/null
+++ b/internal/audit/audit.go
@@ -0,0 +1,85 @@
+package audit
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Event struct {
+ ID string
+ Actor string
+ Action string
+ ResourceType string
+ ResourceID string
+ Result string
+ OccurredAt time.Time
+ CorrelationID string
+ Before map[string]any
+ After map[string]any
+}
+
+type Store interface {
+ Append(context.Context, Event) error
+}
+
+type MemoryStore struct {
+ Events []Event
+}
+
+func (store *MemoryStore) Append(_ context.Context, event Event) error {
+ if event.ID == "" {
+ event.ID = newID()
+ }
+ if event.OccurredAt.IsZero() {
+ event.OccurredAt = time.Now().UTC()
+ }
+ store.Events = append(store.Events, event)
+ return nil
+}
+
+type PostgresStore struct{ Pool *pgxpool.Pool }
+
+func (store PostgresStore) Append(ctx context.Context, event Event) error {
+ if store.Pool == nil {
+ return errors.New("audit database pool is nil")
+ }
+ if event.ID == "" {
+ event.ID = newID()
+ }
+ if event.OccurredAt.IsZero() {
+ event.OccurredAt = time.Now().UTC()
+ }
+ before, err := json.Marshal(event.Before)
+ if err != nil {
+ return errors.New("marshal audit before diff")
+ }
+ after, err := json.Marshal(event.After)
+ if err != nil {
+ return errors.New("marshal audit after diff")
+ }
+ _, err = store.Pool.Exec(ctx, `INSERT INTO audit_events (id, actor, action, resource_type, resource_id, result, occurred_at, correlation_id, before_diff, after_diff) VALUES ($1::uuid, $2, $3, $4, NULLIF($5, '')::uuid, $6, $7, $8, $9::jsonb, $10::jsonb)`, event.ID, event.Actor, event.Action, event.ResourceType, event.ResourceID, event.Result, event.OccurredAt, event.CorrelationID, before, after)
+ if err != nil {
+ return errors.New("write audit event")
+ }
+ return nil
+}
+
+func newID() string {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "00000000-0000-4000-8000-000000000000"
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16])
+}
+
+func RecordSecurityAction(ctx context.Context, store Store, actor, action, result, correlationID string) error {
+ return store.Append(ctx, Event{Actor: actor, Action: action, ResourceType: "security", Result: result, CorrelationID: correlationID})
+}
diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go
new file mode 100644
index 0000000..ec23530
--- /dev/null
+++ b/internal/audit/audit_test.go
@@ -0,0 +1,19 @@
+package audit
+
+import (
+ "context"
+ "testing"
+)
+
+func TestSecurityActionsAreStoredWithCorrelation(t *testing.T) {
+ store := &MemoryStore{}
+ if err := RecordSecurityAction(context.Background(), store, "user-1", "config.update", "success", "corr-1234"); err != nil {
+ t.Fatal(err)
+ }
+ if len(store.Events) != 1 || store.Events[0].CorrelationID != "corr-1234" || store.Events[0].Action != "config.update" {
+ t.Fatalf("unexpected audit event: %#v", store.Events)
+ }
+ if store.Events[0].ID == "" || store.Events[0].OccurredAt.IsZero() {
+ t.Fatal("audit event lacks identity or timestamp")
+ }
+}
diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go
new file mode 100644
index 0000000..da18af3
--- /dev/null
+++ b/internal/auth/oidc.go
@@ -0,0 +1,293 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "golang.org/x/oauth2"
+)
+
+const (
+ defaultFlowLifetime = 10 * time.Minute
+)
+
+type OIDCConfig struct {
+ Issuer string
+ ClientID string
+ ClientSecret string
+ RedirectURL string
+ Scopes []string
+}
+
+type Authorization struct {
+ URL string
+ State string
+ Nonce string
+ CodeVerifier string
+ ExpiresAt time.Time
+}
+
+func BeginAuthorization(endpoint oauth2.Endpoint, config OIDCConfig, now time.Time) (Authorization, error) {
+ if endpoint.AuthURL == "" || config.ClientID == "" || config.RedirectURL == "" {
+ return Authorization{}, errors.New("OIDC authorization configuration is incomplete")
+ }
+ state, err := randomToken()
+ if err != nil {
+ return Authorization{}, errors.New("generate authorization state")
+ }
+ nonce, err := randomToken()
+ if err != nil {
+ return Authorization{}, errors.New("generate authorization nonce")
+ }
+ verifier, err := randomToken()
+ if err != nil {
+ return Authorization{}, errors.New("generate PKCE verifier")
+ }
+ scopes := config.Scopes
+ if len(scopes) == 0 {
+ scopes = []string{oidc.ScopeOpenID, "profile", "email"}
+ }
+ oauthConfig := oauth2.Config{
+ ClientID: config.ClientID,
+ ClientSecret: config.ClientSecret,
+ Endpoint: endpoint,
+ RedirectURL: config.RedirectURL,
+ Scopes: scopes,
+ }
+ authURL := oauthConfig.AuthCodeURL(state,
+ oauth2.SetAuthURLParam("nonce", nonce),
+ oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)),
+ oauth2.SetAuthURLParam("code_challenge_method", "S256"),
+ )
+ return Authorization{URL: authURL, State: state, Nonce: nonce, CodeVerifier: verifier, ExpiresAt: now.Add(defaultFlowLifetime)}, nil
+}
+
+func ValidateCallback(flow Authorization, state, code string, now time.Time) error {
+ if flow.State == "" || subtle.ConstantTimeCompare([]byte(flow.State), []byte(state)) != 1 {
+ return errors.New("OIDC state validation failed")
+ }
+ if flow.CodeVerifier == "" || flow.Nonce == "" {
+ return errors.New("OIDC flow is incomplete")
+ }
+ if now.After(flow.ExpiresAt) {
+ return errors.New("OIDC authorization expired")
+ }
+ if strings.TrimSpace(code) == "" {
+ return errors.New("OIDC authorization code is required")
+ }
+ return nil
+}
+
+func Exchange(ctx context.Context, flow Authorization, config OIDCConfig, endpoint oauth2.Endpoint, state, code string) (*oauth2.Token, error) {
+ if err := ValidateCallback(flow, state, code, time.Now()); err != nil {
+ return nil, err
+ }
+ oauthConfig := oauth2.Config{ClientID: config.ClientID, ClientSecret: config.ClientSecret, Endpoint: endpoint, RedirectURL: config.RedirectURL}
+ return oauthConfig.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", flow.CodeVerifier))
+}
+
+// Discovery is the provider metadata required to run one authorization code flow:
+// the authorization/token endpoints for BeginAuthorization and Exchange, and the
+// ID token verifier for VerifyIDToken. Resolve it once and reuse it.
+type Discovery struct {
+ Endpoint oauth2.Endpoint
+ Verifier *oidc.IDTokenVerifier
+}
+
+func Discover(ctx context.Context, config OIDCConfig) (Discovery, error) {
+ if config.Issuer == "" || config.ClientID == "" {
+ return Discovery{}, errors.New("OIDC issuer and client ID are required")
+ }
+ provider, err := oidc.NewProvider(ctx, config.Issuer)
+ if err != nil {
+ return Discovery{}, fmt.Errorf("OIDC discovery failed")
+ }
+ return Discovery{Endpoint: provider.Endpoint(), Verifier: provider.Verifier(&oidc.Config{ClientID: config.ClientID})}, nil
+}
+
+func NewVerifier(ctx context.Context, config OIDCConfig) (*oidc.IDTokenVerifier, error) {
+ discovery, err := Discover(ctx, config)
+ if err != nil {
+ return nil, err
+ }
+ return discovery.Verifier, nil
+}
+
+func VerifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, rawToken, expectedNonce string) (*oidc.IDToken, error) {
+ if verifier == nil || strings.TrimSpace(rawToken) == "" || expectedNonce == "" {
+ return nil, errors.New("OIDC token verification input is incomplete")
+ }
+ token, err := verifier.Verify(ctx, rawToken)
+ if err != nil {
+ return nil, errors.New("OIDC token verification failed")
+ }
+ var claims struct {
+ Nonce string `json:"nonce"`
+ }
+ if err := token.Claims(&claims); err != nil || subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(expectedNonce)) != 1 {
+ return nil, errors.New("OIDC nonce validation failed")
+ }
+ return token, nil
+}
+
+const (
+ defaultGroupsClaim = "groups"
+ maxIdentityGroups = 128
+)
+
+// Identity is the bounded subset of verified ID token claims Pulse consumes.
+type Identity struct {
+ Subject string
+ Groups []string
+}
+
+// ExtractIdentity reads the subject and the configured role claim from an already
+// verified ID token. The claim may be a list of strings or a single string; values
+// are trimmed, empty values dropped and the list bounded.
+func ExtractIdentity(token *oidc.IDToken, groupsClaim string) (Identity, error) {
+ if token == nil {
+ return Identity{}, errors.New("OIDC identity token is required")
+ }
+ if groupsClaim == "" {
+ groupsClaim = defaultGroupsClaim
+ }
+ subject := strings.TrimSpace(token.Subject)
+ if subject == "" {
+ return Identity{}, errors.New("OIDC subject claim is required")
+ }
+ var claims map[string]json.RawMessage
+ if err := token.Claims(&claims); err != nil {
+ return Identity{}, errors.New("OIDC claims could not be read")
+ }
+ raw, ok := claims[groupsClaim]
+ if !ok {
+ return Identity{Subject: subject}, nil
+ }
+ groups, err := normalizeGroupClaim(raw)
+ if err != nil {
+ return Identity{}, err
+ }
+ return Identity{Subject: subject, Groups: groups}, nil
+}
+
+func normalizeGroupClaim(raw json.RawMessage) ([]string, error) {
+ var values []string
+ if err := json.Unmarshal(raw, &values); err != nil {
+ var single string
+ if err := json.Unmarshal(raw, &single); err != nil {
+ return nil, errors.New("OIDC role claim is malformed")
+ }
+ values = []string{single}
+ }
+ groups := make([]string, 0, len(values))
+ for _, value := range values {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" || len(groups) >= maxIdentityGroups {
+ continue
+ }
+ groups = append(groups, trimmed)
+ }
+ return groups, nil
+}
+
+func randomToken() (string, error) {
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(bytes), nil
+}
+
+func pkceChallenge(verifier string) string {
+ digest := sha256.Sum256([]byte(verifier))
+ return base64.RawURLEncoding.EncodeToString(digest[:])
+}
+
+type Role string
+
+const (
+ RoleViewer Role = "viewer"
+ RoleOperator Role = "operator"
+ RoleEditor Role = "editor"
+ RoleAdministrator Role = "administrator"
+)
+
+type Permission string
+
+const (
+ PermissionView Permission = "view"
+ PermissionOperate Permission = "operate"
+ PermissionEdit Permission = "edit"
+ PermissionAdmin Permission = "admin"
+)
+
+type Principal struct {
+ Subject string
+ Role Role
+}
+
+func MapRoles(claims []string, mapping map[string]Role) (Role, error) {
+ priority := map[Role]int{RoleViewer: 1, RoleOperator: 2, RoleEditor: 3, RoleAdministrator: 4}
+ var selected Role
+ for _, claim := range claims {
+ role, ok := mapping[claim]
+ if !ok || priority[role] <= priority[selected] {
+ continue
+ }
+ selected = role
+ }
+ if selected == "" {
+ return "", errors.New("no authorized Pulse role")
+ }
+ return selected, nil
+}
+
+func Allows(role Role, permission Permission) bool {
+ level := map[Role]int{RoleViewer: 1, RoleOperator: 2, RoleEditor: 3, RoleAdministrator: 4}[role]
+ required := map[Permission]int{PermissionView: 1, PermissionOperate: 2, PermissionEdit: 3, PermissionAdmin: 4}[permission]
+ return level > 0 && required > 0 && level >= required
+}
+
+func Require(permission Permission, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ principal, ok := PrincipalFromContext(request.Context())
+ if !ok {
+ response.Header().Set("Cache-Control", "private, no-store")
+ http.Error(response, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if !Allows(principal.Role, permission) {
+ response.Header().Set("Cache-Control", "private, no-store")
+ http.Error(response, "forbidden", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(response, request)
+ })
+}
+
+type contextKey struct{}
+
+func WithPrincipal(ctx context.Context, principal Principal) context.Context {
+ return context.WithValue(ctx, contextKey{}, principal)
+}
+
+func PrincipalFromContext(ctx context.Context) (Principal, bool) {
+ principal, ok := ctx.Value(contextKey{}).(Principal)
+ return principal, ok && principal.Subject != ""
+}
+
+type BreakGlassPolicy struct {
+ Enabled bool
+}
+
+func (policy BreakGlassPolicy) Allows() bool { return policy.Enabled }
diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go
new file mode 100644
index 0000000..412dfae
--- /dev/null
+++ b/internal/auth/oidc_test.go
@@ -0,0 +1,227 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+func TestBeginAuthorizationUsesStateNonceAndPKCE(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ flow, err := BeginAuthorization(oauth2.Endpoint{AuthURL: "https://auth.example/authorize"}, OIDCConfig{ClientID: "pulse", RedirectURL: "https://pulse.example/callback"}, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := url.Parse(flow.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ query := parsed.Query()
+ for _, key := range []string{"state", "nonce", "code_challenge", "code_challenge_method"} {
+ if query.Get(key) == "" {
+ t.Fatalf("authorization URL missing %s", key)
+ }
+ }
+ if query.Get("state") != flow.State || query.Get("nonce") != flow.Nonce || query.Get("code_challenge_method") != "S256" {
+ t.Fatalf("authorization URL does not match flow: %s", flow.URL)
+ }
+ if query.Get("code_challenge") != pkceChallenge(flow.CodeVerifier) {
+ t.Fatal("authorization URL has incorrect PKCE challenge")
+ }
+}
+
+func TestValidateCallbackRejectsStateNonceFlowAbuse(t *testing.T) {
+ now := time.Now()
+ flow := Authorization{State: "expected", Nonce: "nonce", CodeVerifier: "verifier", ExpiresAt: now.Add(time.Minute)}
+ if err := ValidateCallback(flow, "wrong", "code", now); err == nil {
+ t.Fatal("wrong state was accepted")
+ }
+ if err := ValidateCallback(flow, flow.State, "", now); err == nil {
+ t.Fatal("empty code was accepted")
+ }
+ flow.ExpiresAt = now.Add(-time.Second)
+ if err := ValidateCallback(flow, flow.State, "code", now); err == nil {
+ t.Fatal("expired flow was accepted")
+ }
+}
+
+func TestRoleMappingAndAuthorizationMatrix(t *testing.T) {
+ mapping := map[string]Role{"pulse-view": RoleViewer, "pulse-operator": RoleOperator, "pulse-admin": RoleAdministrator}
+ role, err := MapRoles([]string{"unrelated", "pulse-operator", "pulse-view"}, mapping)
+ if err != nil || role != RoleOperator {
+ t.Fatalf("role mapping = %q, %v", role, err)
+ }
+ if _, err := MapRoles([]string{"unrelated"}, mapping); err == nil {
+ t.Fatal("unmapped claims were authorized")
+ }
+ for _, test := range []struct {
+ role Role
+ permission Permission
+ allowed bool
+ }{
+ {RoleViewer, PermissionView, true}, {RoleViewer, PermissionEdit, false},
+ {RoleOperator, PermissionOperate, true}, {RoleOperator, PermissionAdmin, false},
+ {RoleEditor, PermissionEdit, true}, {RoleEditor, PermissionAdmin, false},
+ {RoleAdministrator, PermissionAdmin, true},
+ } {
+ if got := Allows(test.role, test.permission); got != test.allowed {
+ t.Errorf("Allows(%s, %s) = %v, want %v", test.role, test.permission, got, test.allowed)
+ }
+ }
+}
+
+func TestUnauthorizedPathsAreDenied(t *testing.T) {
+ handler := Require(PermissionEdit, http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusNoContent) }))
+ for _, test := range []struct {
+ name string
+ ctx context.Context
+ status int
+ }{
+ {"anonymous", context.Background(), http.StatusUnauthorized},
+ {"viewer", WithPrincipal(context.Background(), Principal{Subject: "user-1", Role: RoleViewer}), http.StatusForbidden},
+ {"editor", WithPrincipal(context.Background(), Principal{Subject: "user-1", Role: RoleEditor}), http.StatusNoContent},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/protected", nil).WithContext(test.ctx)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != test.status {
+ t.Fatalf("status = %d, want %d", response.Code, test.status)
+ }
+ if test.status == http.StatusUnauthorized || test.status == http.StatusForbidden {
+ if response.Header().Get("Cache-Control") != "private, no-store" {
+ t.Fatalf("cache control = %q", response.Header().Get("Cache-Control"))
+ }
+ }
+ })
+ }
+}
+
+func TestBreakGlassIsDisabledByDefault(t *testing.T) {
+ if (BreakGlassPolicy{}).Allows() {
+ t.Fatal("break-glass unexpectedly enabled")
+ }
+}
+
+func TestDiscoverResolvesEndpointAndVerifier(t *testing.T) {
+ var issuer string
+ server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ if request.URL.Path != "/.well-known/openid-configuration" {
+ response.WriteHeader(http.StatusNotFound)
+ return
+ }
+ response.Header().Set("Content-Type", "application/json")
+ _, _ = response.Write([]byte(`{"issuer":"` + issuer + `","authorization_endpoint":"` + issuer + `/authorize","token_endpoint":"` + issuer + `/token","jwks_uri":"` + issuer + `/jwks","id_token_signing_alg_values_supported":["RS256"]}`))
+ }))
+ defer server.Close()
+ issuer = server.URL
+
+ discovery, err := Discover(context.Background(), OIDCConfig{Issuer: issuer, ClientID: "pulse"})
+ if err != nil {
+ t.Fatalf("Discover: %v", err)
+ }
+ if discovery.Endpoint.AuthURL != issuer+"/authorize" || discovery.Endpoint.TokenURL != issuer+"/token" || discovery.Verifier == nil {
+ t.Fatalf("discovery = %#v", discovery.Endpoint)
+ }
+ if _, err := NewVerifier(context.Background(), OIDCConfig{Issuer: issuer, ClientID: "pulse"}); err != nil {
+ t.Fatalf("NewVerifier: %v", err)
+ }
+}
+
+func TestDiscoverRejectsIncompleteOrUnreachableIssuer(t *testing.T) {
+ unreachable := httptest.NewServer(http.NewServeMux())
+ unreachable.Close()
+ for _, test := range []struct {
+ name string
+ config OIDCConfig
+ }{
+ {"missing issuer", OIDCConfig{ClientID: "pulse"}},
+ {"missing client id", OIDCConfig{Issuer: "https://idp.example"}},
+ {"unreachable issuer", OIDCConfig{Issuer: unreachable.URL, ClientID: "pulse"}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ discovery, err := Discover(context.Background(), test.config)
+ if err == nil {
+ t.Fatal("incomplete configuration was accepted")
+ }
+ if discovery.Verifier != nil {
+ t.Fatal("a verifier was returned with an error")
+ }
+ if strings.Contains(err.Error(), test.config.Issuer) && test.config.Issuer != "" {
+ t.Fatalf("error leaks the issuer: %v", err)
+ }
+ })
+ }
+}
+
+func TestExtractIdentityRequiresToken(t *testing.T) {
+ if _, err := ExtractIdentity(nil, "groups"); err == nil {
+ t.Fatal("nil token was accepted")
+ }
+}
+
+func TestNormalizeGroupClaimBoundsAndShapes(t *testing.T) {
+ many, err := json.Marshal(make([]string, maxIdentityGroups+50))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range []struct {
+ name string
+ raw string
+ want []string
+ wantErr bool
+ }{
+ {name: "list", raw: `["pulse-admin"," pulse-view ",""]`, want: []string{"pulse-admin", "pulse-view"}},
+ {name: "single string", raw: `"pulse-admin"`, want: []string{"pulse-admin"}},
+ {name: "empty list", raw: `[]`, want: []string{}},
+ {name: "object", raw: `{"groups":["pulse-admin"]}`, wantErr: true},
+ {name: "number", raw: `7`, wantErr: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ groups, err := normalizeGroupClaim(json.RawMessage(test.raw))
+ if (err != nil) != test.wantErr {
+ t.Fatalf("err = %v, wantErr = %v", err, test.wantErr)
+ }
+ if err != nil {
+ return
+ }
+ if len(groups) != len(test.want) {
+ t.Fatalf("groups = %#v, want %#v", groups, test.want)
+ }
+ for index, value := range test.want {
+ if groups[index] != value {
+ t.Fatalf("groups = %#v, want %#v", groups, test.want)
+ }
+ }
+ })
+ }
+ bounded, err := normalizeGroupClaim(many)
+ if err != nil {
+ t.Fatalf("normalizeGroupClaim: %v", err)
+ }
+ if len(bounded) != 0 {
+ t.Fatalf("blank group values were kept: %d", len(bounded))
+ }
+ filled := make([]string, maxIdentityGroups+50)
+ for index := range filled {
+ filled[index] = "group"
+ }
+ encoded, err := json.Marshal(filled)
+ if err != nil {
+ t.Fatal(err)
+ }
+ capped, err := normalizeGroupClaim(encoded)
+ if err != nil {
+ t.Fatalf("normalizeGroupClaim: %v", err)
+ }
+ if len(capped) != maxIdentityGroups {
+ t.Fatalf("groups = %d, want %d", len(capped), maxIdentityGroups)
+ }
+}
diff --git a/internal/auth/session.go b/internal/auth/session.go
new file mode 100644
index 0000000..6aa9fac
--- /dev/null
+++ b/internal/auth/session.go
@@ -0,0 +1,208 @@
+package auth
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net/http"
+ "sync"
+ "time"
+)
+
+type session struct {
+ principal Principal
+ issuedAt time.Time
+ expiresAt time.Time
+ absoluteExpiresAt time.Time
+ context context.Context
+ cancel context.CancelFunc
+}
+
+// SessionAuthentication carries the principal and the revocable lifetime of
+// the authenticated browser session. Long-lived transports must derive their
+// lifecycle from Context so logout and the absolute deadline remain effective
+// after an HTTP upgrade.
+type SessionAuthentication struct {
+ Principal Principal
+ Context context.Context
+}
+
+type SessionManager struct {
+ mu sync.Mutex
+ sessions map[string]session
+ CookieName string
+ TTL time.Duration
+ AbsoluteTTL time.Duration
+ RenewBefore time.Duration
+ Secure bool
+ MaxSessions int
+ MaxSessionsPerSubject int
+}
+
+func NewSessionManager(cookieName string, ttl time.Duration, secure bool) *SessionManager {
+ if cookieName == "" {
+ cookieName = "pulse_session"
+ }
+ if ttl <= 0 {
+ ttl = 8 * time.Hour
+ }
+ return &SessionManager{sessions: make(map[string]session), CookieName: cookieName, TTL: ttl, AbsoluteTTL: ttl, Secure: secure, MaxSessions: 4096, MaxSessionsPerSubject: 8}
+}
+
+// NewSlidingSessionManager creates an idle-expiring browser session with a
+// separate absolute lifetime. Successful authenticated requests renew the idle
+// deadline once half of the idle lifetime has elapsed, but never beyond the
+// absolute deadline. Both lifetimes remain finite and the opaque token stays in
+// an HttpOnly cookie.
+func NewSlidingSessionManager(cookieName string, idleTTL, absoluteTTL time.Duration, secure bool) *SessionManager {
+ manager := NewSessionManager(cookieName, idleTTL, secure)
+ if absoluteTTL < idleTTL {
+ absoluteTTL = idleTTL
+ }
+ manager.AbsoluteTTL = absoluteTTL
+ manager.RenewBefore = idleTTL / 2
+ return manager
+}
+
+func (manager *SessionManager) Issue(response http.ResponseWriter, principal Principal, now time.Time) error {
+ if principal.Subject == "" || !Allows(principal.Role, PermissionView) {
+ return errors.New("session principal is invalid")
+ }
+ token, err := randomToken()
+ if err != nil {
+ return err
+ }
+ absoluteExpiresAt := now.Add(manager.AbsoluteTTL)
+ expiresAt := earliest(now.Add(manager.TTL), absoluteExpiresAt)
+ sessionContext, cancel := context.WithDeadline(context.Background(), absoluteExpiresAt)
+ manager.mu.Lock()
+ manager.purgeExpiredLocked(now)
+ manager.enforceSubjectLimitLocked(principal.Subject)
+ if manager.MaxSessions > 0 && len(manager.sessions) >= manager.MaxSessions {
+ manager.mu.Unlock()
+ cancel()
+ return errors.New("session capacity reached")
+ }
+ manager.sessions[hashToken(token)] = session{principal: principal, issuedAt: now, expiresAt: expiresAt, absoluteExpiresAt: absoluteExpiresAt, context: sessionContext, cancel: cancel}
+ manager.mu.Unlock()
+ manager.setCookie(response, token, now, expiresAt)
+ return nil
+}
+
+func (manager *SessionManager) Principal(request *http.Request, now time.Time) (Principal, bool) {
+ authentication, ok := manager.authenticate(nil, request, now)
+ return authentication.Principal, ok
+}
+
+// Authenticate validates the session and renews an active sliding session when
+// it enters its renewal window. The token is deliberately stable: concurrent
+// API requests cannot invalidate each other, while Clear still revokes it
+// immediately server-side.
+func (manager *SessionManager) Authenticate(response http.ResponseWriter, request *http.Request, now time.Time) (Principal, bool) {
+ authentication, ok := manager.authenticate(response, request, now)
+ return authentication.Principal, ok
+}
+
+// AuthenticateSession validates and renews the cookie while exposing the
+// revocable session context to middleware that serves long-lived transports.
+func (manager *SessionManager) AuthenticateSession(response http.ResponseWriter, request *http.Request, now time.Time) (SessionAuthentication, bool) {
+ return manager.authenticate(response, request, now)
+}
+
+func (manager *SessionManager) authenticate(response http.ResponseWriter, request *http.Request, now time.Time) (SessionAuthentication, bool) {
+ cookie, err := request.Cookie(manager.CookieName)
+ if err != nil || cookie.Value == "" {
+ return SessionAuthentication{}, false
+ }
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ manager.purgeExpiredLocked(now)
+ stored, ok := manager.sessions[hashToken(cookie.Value)]
+ if !ok {
+ return SessionAuthentication{}, false
+ }
+ if !now.Before(stored.expiresAt) || !now.Before(stored.absoluteExpiresAt) {
+ stored.cancel()
+ delete(manager.sessions, hashToken(cookie.Value))
+ return SessionAuthentication{}, false
+ }
+ if response != nil && manager.RenewBefore > 0 && stored.expiresAt.Sub(now) <= manager.RenewBefore {
+ renewed := earliest(now.Add(manager.TTL), stored.absoluteExpiresAt)
+ if renewed.After(stored.expiresAt) {
+ stored.expiresAt = renewed
+ manager.sessions[hashToken(cookie.Value)] = stored
+ manager.setCookie(response, cookie.Value, now, renewed)
+ }
+ }
+ return SessionAuthentication{Principal: stored.principal, Context: stored.context}, true
+}
+
+func (manager *SessionManager) Clear(response http.ResponseWriter, request *http.Request) {
+ if cookie, err := request.Cookie(manager.CookieName); err == nil {
+ manager.mu.Lock()
+ key := hashToken(cookie.Value)
+ if stored, ok := manager.sessions[key]; ok {
+ stored.cancel()
+ delete(manager.sessions, key)
+ }
+ manager.mu.Unlock()
+ }
+ http.SetCookie(response, &http.Cookie{Name: manager.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: manager.Secure, SameSite: http.SameSiteLaxMode})
+}
+
+func (manager *SessionManager) purgeExpiredLocked(now time.Time) {
+ for key, stored := range manager.sessions {
+ if !now.Before(stored.expiresAt) || !now.Before(stored.absoluteExpiresAt) {
+ stored.cancel()
+ delete(manager.sessions, key)
+ }
+ }
+}
+
+func (manager *SessionManager) enforceSubjectLimitLocked(subject string) {
+ if manager.MaxSessionsPerSubject <= 0 {
+ return
+ }
+ for {
+ count := 0
+ oldestKey := ""
+ var oldest time.Time
+ for key, stored := range manager.sessions {
+ if stored.principal.Subject != subject {
+ continue
+ }
+ count++
+ if oldestKey == "" || stored.issuedAt.Before(oldest) {
+ oldestKey = key
+ oldest = stored.issuedAt
+ }
+ }
+ if count < manager.MaxSessionsPerSubject || oldestKey == "" {
+ return
+ }
+ stored := manager.sessions[oldestKey]
+ stored.cancel()
+ delete(manager.sessions, oldestKey)
+ }
+}
+
+func hashToken(token string) string {
+ digest := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(digest[:])
+}
+
+func (manager *SessionManager) setCookie(response http.ResponseWriter, token string, now, expiresAt time.Time) {
+ maxAge := int(expiresAt.Sub(now).Seconds())
+ if maxAge < 1 {
+ maxAge = 1
+ }
+ http.SetCookie(response, &http.Cookie{Name: manager.CookieName, Value: token, Path: "/", Expires: expiresAt, MaxAge: maxAge, HttpOnly: true, Secure: manager.Secure, SameSite: http.SameSiteLaxMode})
+}
+
+func earliest(first, second time.Time) time.Time {
+ if first.Before(second) {
+ return first
+ }
+ return second
+}
diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go
new file mode 100644
index 0000000..c955855
--- /dev/null
+++ b/internal/auth/session_test.go
@@ -0,0 +1,197 @@
+package auth
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestSessionIssueReadAndClear(t *testing.T) {
+ manager := NewSessionManager("pulse_test_session", time.Hour, true)
+ now := time.Now()
+ response := httptest.NewRecorder()
+ principal := Principal{Subject: "subject-1", Role: RoleViewer}
+ if err := manager.Issue(response, principal, now); err != nil {
+ t.Fatal(err)
+ }
+ if response.Header().Get("Set-Cookie") == "" {
+ t.Fatal("session cookie was not set")
+ }
+ if !response.Result().Cookies()[0].HttpOnly || !response.Result().Cookies()[0].Secure {
+ t.Fatal("session cookie is not hardened")
+ }
+ request := httptest.NewRequest("GET", "/", nil)
+ for _, cookie := range response.Result().Cookies() {
+ request.AddCookie(cookie)
+ }
+ got, ok := manager.Principal(request, now.Add(time.Minute))
+ if !ok || got != principal {
+ t.Fatalf("session principal = %#v, %v", got, ok)
+ }
+ clearResponse := httptest.NewRecorder()
+ manager.Clear(clearResponse, request)
+ if _, ok := manager.Principal(request, now.Add(time.Minute)); ok {
+ t.Fatal("cleared session remained valid")
+ }
+}
+
+func TestSessionExpires(t *testing.T) {
+ manager := NewSessionManager("pulse_test_session", time.Minute, false)
+ now := time.Now()
+ response := httptest.NewRecorder()
+ if err := manager.Issue(response, Principal{Subject: "subject-1", Role: RoleViewer}, now); err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest("GET", "/", nil)
+ request.AddCookie(response.Result().Cookies()[0])
+ if _, ok := manager.Principal(request, now.Add(2*time.Minute)); ok {
+ t.Fatal("expired session remained valid")
+ }
+}
+
+func TestSlidingSessionRenewsIdleDeadlineButHonorsAbsoluteExpiry(t *testing.T) {
+ manager := NewSlidingSessionManager("pulse_test_session", time.Minute, 3*time.Minute, true)
+ now := time.Now().UTC().Truncate(time.Second)
+ issued := httptest.NewRecorder()
+ if err := manager.Issue(issued, Principal{Subject: "wallboard", Role: RoleViewer}, now); err != nil {
+ t.Fatal(err)
+ }
+ cookie := issued.Result().Cookies()[0]
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
+ request.AddCookie(cookie)
+
+ beforeWindow := httptest.NewRecorder()
+ if _, ok := manager.Authenticate(beforeWindow, request, now.Add(20*time.Second)); !ok {
+ t.Fatal("active session was rejected before renewal window")
+ }
+ if beforeWindow.Header().Get("Set-Cookie") != "" {
+ t.Fatal("session renewed before entering the bounded renewal window")
+ }
+
+ for _, offset := range []time.Duration{40 * time.Second, 80 * time.Second, 130 * time.Second} {
+ response := httptest.NewRecorder()
+ if _, ok := manager.Authenticate(response, request, now.Add(offset)); !ok {
+ t.Fatalf("active session was rejected at %s", offset)
+ }
+ renewed := response.Result().Cookies()
+ if len(renewed) != 1 || renewed[0].Value != cookie.Value || renewed[0].Expires.After(now.Add(3*time.Minute)) {
+ t.Fatalf("unsafe renewal at %s: %#v", offset, renewed)
+ }
+ }
+
+ if _, ok := manager.Authenticate(httptest.NewRecorder(), request, now.Add(3*time.Minute)); ok {
+ t.Fatal("sliding session exceeded its absolute expiry")
+ }
+}
+
+func TestSlidingSessionConcurrentRenewalKeepsTokenUsable(t *testing.T) {
+ manager := NewSlidingSessionManager("pulse_test_session", time.Minute, time.Hour, false)
+ now := time.Now().UTC()
+ issued := httptest.NewRecorder()
+ if err := manager.Issue(issued, Principal{Subject: "wallboard", Role: RoleViewer}, now); err != nil {
+ t.Fatal(err)
+ }
+ cookie := issued.Result().Cookies()[0]
+ const workers = 24
+ var wait sync.WaitGroup
+ errors := make(chan string, workers)
+ for index := 0; index < workers; index++ {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboards", nil)
+ request.AddCookie(cookie)
+ if _, ok := manager.Authenticate(httptest.NewRecorder(), request, now.Add(40*time.Second)); !ok {
+ errors <- "concurrent renewal rejected a valid token"
+ }
+ }()
+ }
+ wait.Wait()
+ close(errors)
+ for message := range errors {
+ t.Error(message)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboards", nil)
+ request.AddCookie(cookie)
+ if _, ok := manager.Principal(request, now.Add(90*time.Second)); !ok {
+ t.Fatal("stable token was invalidated by concurrent renewal")
+ }
+}
+
+func TestSessionAuthenticationContextIsRevokedByClear(t *testing.T) {
+ manager := NewSlidingSessionManager("pulse_test_session", time.Minute, time.Hour, true)
+ now := time.Now().UTC()
+ issued := httptest.NewRecorder()
+ if err := manager.Issue(issued, Principal{Subject: "viewer", Role: RoleViewer}, now); err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/live", nil)
+ request.AddCookie(issued.Result().Cookies()[0])
+ authentication, ok := manager.AuthenticateSession(httptest.NewRecorder(), request, now.Add(time.Second))
+ if !ok || authentication.Context == nil {
+ t.Fatal("session authentication context was not returned")
+ }
+ manager.Clear(httptest.NewRecorder(), request)
+ select {
+ case <-authentication.Context.Done():
+ case <-time.After(time.Second):
+ t.Fatal("cleared session context remained active")
+ }
+}
+
+func TestSessionAuthenticationContextEndsAtAbsoluteExpiry(t *testing.T) {
+ manager := NewSlidingSessionManager("pulse_test_session", 25*time.Millisecond, 25*time.Millisecond, true)
+ now := time.Now().UTC()
+ issued := httptest.NewRecorder()
+ if err := manager.Issue(issued, Principal{Subject: "viewer", Role: RoleViewer}, now); err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/live", nil)
+ request.AddCookie(issued.Result().Cookies()[0])
+ authentication, ok := manager.AuthenticateSession(httptest.NewRecorder(), request, now)
+ if !ok {
+ t.Fatal("new session was rejected")
+ }
+ select {
+ case <-authentication.Context.Done():
+ case <-time.After(time.Second):
+ t.Fatal("session context exceeded its absolute deadline")
+ }
+}
+
+func TestSessionStoreEvictsOldestSessionsPerSubject(t *testing.T) {
+ manager := NewSlidingSessionManager("pulse_test_session", time.Hour, 24*time.Hour, true)
+ manager.MaxSessionsPerSubject = 3
+ now := time.Now().UTC()
+ for index := 0; index < 12; index++ {
+ if err := manager.Issue(httptest.NewRecorder(), Principal{Subject: "viewer", Role: RoleViewer}, now.Add(time.Duration(index)*time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := len(manager.sessions); got != 3 {
+ t.Fatalf("session store size = %d, want 3", got)
+ }
+}
+
+func TestSessionIssuePurgesExpiredEntriesAndHonorsGlobalCapacity(t *testing.T) {
+ manager := NewSessionManager("pulse_test_session", time.Minute, true)
+ manager.MaxSessions = 2
+ manager.MaxSessionsPerSubject = 2
+ now := time.Now().UTC()
+ for _, subject := range []string{"viewer-1", "viewer-2"} {
+ if err := manager.Issue(httptest.NewRecorder(), Principal{Subject: subject, Role: RoleViewer}, now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := manager.Issue(httptest.NewRecorder(), Principal{Subject: "viewer-3", Role: RoleViewer}, now); err == nil {
+ t.Fatal("session capacity was not enforced")
+ }
+ if err := manager.Issue(httptest.NewRecorder(), Principal{Subject: "viewer-3", Role: RoleViewer}, now.Add(2*time.Minute)); err != nil {
+ t.Fatalf("expired sessions were not purged: %v", err)
+ }
+ if got := len(manager.sessions); got != 1 {
+ t.Fatalf("session store size after purge = %d, want 1", got)
+ }
+}
diff --git a/internal/authapi/fakeidp_test.go b/internal/authapi/fakeidp_test.go
new file mode 100644
index 0000000..ba9fe73
--- /dev/null
+++ b/internal/authapi/fakeidp_test.go
@@ -0,0 +1,159 @@
+package authapi
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "math/big"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// signingKey is generated once per test binary; RSA generation is expensive.
+var signingKey = sync.OnceValue(func() *rsa.PrivateKey {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ panic(err)
+ }
+ return key
+})
+
+// fakeIdP is a minimal OIDC provider: discovery document, JWKS and token endpoint.
+// Tests drive its behaviour through the exported fields before calling the callback.
+type fakeIdP struct {
+ server *httptest.Server
+ key *rsa.PrivateKey
+ clientID string
+
+ mu sync.Mutex
+ expectedChallenge string
+ nonce string
+ subject string
+ groups []string
+ tokenFails bool
+ omitIDToken bool
+ issuerOverride string
+ verifierSeen string
+}
+
+func newFakeIdP(t *testing.T, clientID string) *fakeIdP {
+ t.Helper()
+ idp := &fakeIdP{key: signingKey(), clientID: clientID, subject: "user-1", groups: []string{"pulse-operator"}}
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", idp.discovery)
+ mux.HandleFunc("/jwks", idp.jwks)
+ mux.HandleFunc("/token", idp.token)
+ mux.HandleFunc("/authorize", func(response http.ResponseWriter, _ *http.Request) {
+ response.WriteHeader(http.StatusOK)
+ })
+ idp.server = httptest.NewServer(mux)
+ t.Cleanup(idp.server.Close)
+ return idp
+}
+
+func (idp *fakeIdP) configure(mutate func(*fakeIdP)) {
+ idp.mu.Lock()
+ defer idp.mu.Unlock()
+ mutate(idp)
+}
+
+func (idp *fakeIdP) codeVerifier() string {
+ idp.mu.Lock()
+ defer idp.mu.Unlock()
+ return idp.verifierSeen
+}
+
+func (idp *fakeIdP) discovery(response http.ResponseWriter, _ *http.Request) {
+ writeJSON(response, http.StatusOK, map[string]any{
+ "issuer": idp.server.URL,
+ "authorization_endpoint": idp.server.URL + "/authorize",
+ "token_endpoint": idp.server.URL + "/token",
+ "jwks_uri": idp.server.URL + "/jwks",
+ "response_types_supported": []string{"code"},
+ "subject_types_supported": []string{"public"},
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ })
+}
+
+func (idp *fakeIdP) jwks(response http.ResponseWriter, _ *http.Request) {
+ public := &idp.key.PublicKey
+ writeJSON(response, http.StatusOK, map[string]any{"keys": []map[string]any{{
+ "kty": "RSA",
+ "kid": "test-key",
+ "alg": "RS256",
+ "use": "sig",
+ "n": base64.RawURLEncoding.EncodeToString(public.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(public.E)).Bytes()),
+ }}})
+}
+
+func (idp *fakeIdP) token(response http.ResponseWriter, request *http.Request) {
+ if err := request.ParseForm(); err != nil {
+ writeJSON(response, http.StatusBadRequest, map[string]any{"error": "invalid_request"})
+ return
+ }
+ idp.mu.Lock()
+ defer idp.mu.Unlock()
+ idp.verifierSeen = request.PostForm.Get("code_verifier")
+ if idp.tokenFails {
+ writeJSON(response, http.StatusBadRequest, map[string]any{"error": "invalid_grant"})
+ return
+ }
+ if idp.expectedChallenge != "" {
+ digest := sha256.Sum256([]byte(idp.verifierSeen))
+ if base64.RawURLEncoding.EncodeToString(digest[:]) != idp.expectedChallenge {
+ writeJSON(response, http.StatusBadRequest, map[string]any{"error": "invalid_grant"})
+ return
+ }
+ }
+ body := map[string]any{"access_token": "opaque-access-token", "token_type": "Bearer", "expires_in": 3600}
+ if !idp.omitIDToken {
+ issuer := idp.server.URL
+ if idp.issuerOverride != "" {
+ issuer = idp.issuerOverride
+ }
+ now := time.Now()
+ body["id_token"] = idp.sign(map[string]any{
+ "iss": issuer,
+ "aud": idp.clientID,
+ "sub": idp.subject,
+ "iat": now.Unix(),
+ "exp": now.Add(5 * time.Minute).Unix(),
+ "nonce": idp.nonce,
+ "groups": idp.groups,
+ })
+ }
+ writeJSON(response, http.StatusOK, body)
+}
+
+func (idp *fakeIdP) sign(claims map[string]any) string {
+ segments := []string{encodeSegment(map[string]any{"alg": "RS256", "typ": "JWT", "kid": "test-key"}), encodeSegment(claims)}
+ input := strings.Join(segments, ".")
+ digest := sha256.Sum256([]byte(input))
+ signature, err := rsa.SignPKCS1v15(rand.Reader, idp.key, crypto.SHA256, digest[:])
+ if err != nil {
+ panic(err)
+ }
+ return input + "." + base64.RawURLEncoding.EncodeToString(signature)
+}
+
+func encodeSegment(value map[string]any) string {
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ panic(err)
+ }
+ return base64.RawURLEncoding.EncodeToString(encoded)
+}
+
+func writeJSON(response http.ResponseWriter, status int, body any) {
+ response.Header().Set("Content-Type", "application/json")
+ response.WriteHeader(status)
+ _ = json.NewEncoder(response).Encode(body)
+}
diff --git a/internal/authapi/flowstore.go b/internal/authapi/flowstore.go
new file mode 100644
index 0000000..ad3cbe2
--- /dev/null
+++ b/internal/authapi/flowstore.go
@@ -0,0 +1,116 @@
+package authapi
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+)
+
+const (
+ defaultFlowTTL = 10 * time.Minute
+ defaultMaxFlows = 1024
+ flowIDByteLength = 32
+)
+
+// flow is the server-side state of one in-progress authorization code flow. Only
+// an opaque identifier for it ever reaches the browser.
+type flow struct {
+ authorization auth.Authorization
+ redirect string
+ createdAt time.Time
+}
+
+// flowStore keeps pending flows in memory. It is bounded by TTL and by a maximum
+// entry count so an unauthenticated caller cannot grow it without limit, and it is
+// safe for concurrent use.
+type flowStore struct {
+ mu sync.Mutex
+ flows map[string]flow
+ ttl time.Duration
+ max int
+}
+
+func newFlowStore(ttl time.Duration, max int) *flowStore {
+ if ttl <= 0 {
+ ttl = defaultFlowTTL
+ }
+ if max <= 0 {
+ max = defaultMaxFlows
+ }
+ return &flowStore{flows: make(map[string]flow), ttl: ttl, max: max}
+}
+
+// create stores one pending flow and returns its opaque identifier. Expired entries
+// are removed first; if the store is still at capacity the oldest entry is dropped
+// so a flood of abandoned flows cannot deny logins permanently.
+func (store *flowStore) create(entry flow, now time.Time) (string, error) {
+ id, err := randomFlowID()
+ if err != nil {
+ return "", errors.New("generate authorization flow identifier")
+ }
+ entry.createdAt = now
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ store.purge(now)
+ for len(store.flows) >= store.max && store.evictOldest() {
+ }
+ store.flows[id] = entry
+ return id, nil
+}
+
+// take returns a pending flow and always removes it, so a flow identifier can be
+// used at most once. An unknown, replayed or expired identifier returns false.
+func (store *flowStore) take(id string, now time.Time) (flow, bool) {
+ if id == "" {
+ return flow{}, false
+ }
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ entry, ok := store.flows[id]
+ delete(store.flows, id)
+ if !ok || !now.Before(entry.createdAt.Add(store.ttl)) {
+ return flow{}, false
+ }
+ return entry, true
+}
+
+func (store *flowStore) size() int {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ return len(store.flows)
+}
+
+func (store *flowStore) purge(now time.Time) {
+ for id, entry := range store.flows {
+ if !now.Before(entry.createdAt.Add(store.ttl)) {
+ delete(store.flows, id)
+ }
+ }
+}
+
+func (store *flowStore) evictOldest() bool {
+ oldest := ""
+ var oldestAt time.Time
+ for id, entry := range store.flows {
+ if oldest == "" || entry.createdAt.Before(oldestAt) {
+ oldest, oldestAt = id, entry.createdAt
+ }
+ }
+ if oldest == "" {
+ return false
+ }
+ delete(store.flows, oldest)
+ return true
+}
+
+func randomFlowID() (string, error) {
+ buffer := make([]byte, flowIDByteLength)
+ if _, err := rand.Read(buffer); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(buffer), nil
+}
diff --git a/internal/authapi/flowstore_test.go b/internal/authapi/flowstore_test.go
new file mode 100644
index 0000000..9c5cc8c
--- /dev/null
+++ b/internal/authapi/flowstore_test.go
@@ -0,0 +1,154 @@
+package authapi
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+)
+
+func testFlow(state string) flow {
+ return flow{authorization: auth.Authorization{State: state, Nonce: "nonce", CodeVerifier: "verifier"}, redirect: "/"}
+}
+
+func TestFlowStoreSingleUseAndExpiry(t *testing.T) {
+ now := time.Date(2026, 8, 4, 10, 0, 0, 0, time.UTC)
+ for _, test := range []struct {
+ name string
+ takeAt time.Time
+ twice bool
+ wantOK bool
+ wantAll int
+ }{
+ {name: "within ttl", takeAt: now.Add(time.Minute), wantOK: true},
+ {name: "at ttl boundary", takeAt: now.Add(defaultFlowTTL), wantOK: false},
+ {name: "after ttl", takeAt: now.Add(defaultFlowTTL + time.Second), wantOK: false},
+ {name: "replayed", takeAt: now.Add(time.Minute), twice: true, wantOK: false},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ store := newFlowStore(0, 0)
+ id, err := store.create(testFlow("state-1"), now)
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if test.twice {
+ if _, ok := store.take(id, test.takeAt); !ok {
+ t.Fatal("first take failed")
+ }
+ }
+ entry, ok := store.take(id, test.takeAt)
+ if ok != test.wantOK {
+ t.Fatalf("take ok = %v, want %v", ok, test.wantOK)
+ }
+ if ok && entry.authorization.State != "state-1" {
+ t.Fatalf("state = %q", entry.authorization.State)
+ }
+ if store.size() != 0 {
+ t.Fatalf("take left %d entries behind", store.size())
+ }
+ })
+ }
+}
+
+func TestFlowStoreRejectsUnknownIdentifiers(t *testing.T) {
+ store := newFlowStore(0, 0)
+ for _, id := range []string{"", "unknown", " "} {
+ if _, ok := store.take(id, time.Now()); ok {
+ t.Fatalf("identifier %q was accepted", id)
+ }
+ }
+}
+
+func TestFlowStoreIsBounded(t *testing.T) {
+ now := time.Now().UTC()
+ store := newFlowStore(time.Minute, 4)
+ for index := range 50 {
+ if _, err := store.create(testFlow("state"), now.Add(time.Duration(index)*time.Second)); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ }
+ if store.size() != 4 {
+ t.Fatalf("size = %d, want 4", store.size())
+ }
+
+ expired, err := store.create(testFlow("expired"), now)
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, err := store.create(testFlow("fresh"), now.Add(2*time.Minute)); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, ok := store.take(expired, now.Add(2*time.Minute)); ok {
+ t.Fatal("expired flow survived the purge")
+ }
+ if store.size() > 4 {
+ t.Fatalf("size = %d, want at most 4", store.size())
+ }
+}
+
+func TestFlowStoreConcurrentAccess(t *testing.T) {
+ const workers = 128
+ store := newFlowStore(time.Minute, 64)
+ now := time.Now().UTC()
+ identifiers := make([]string, workers)
+ var wait sync.WaitGroup
+ for index := range workers {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ id, err := store.create(testFlow("state"), now)
+ if err != nil {
+ t.Errorf("create: %v", err)
+ return
+ }
+ identifiers[index] = id
+ }()
+ }
+ wait.Wait()
+
+ unique := make(map[string]struct{}, workers)
+ for _, id := range identifiers {
+ if id == "" {
+ t.Fatal("empty flow identifier")
+ }
+ unique[id] = struct{}{}
+ }
+ if len(unique) != workers {
+ t.Fatalf("unique identifiers = %d, want %d", len(unique), workers)
+ }
+ if store.size() > 64 {
+ t.Fatalf("size = %d, want at most 64", store.size())
+ }
+
+ var taken sync.WaitGroup
+ results := make(chan bool, 2*workers)
+ for _, id := range identifiers {
+ taken.Add(1)
+ go func() {
+ defer taken.Done()
+ _, ok := store.take(id, now)
+ results <- ok
+ }()
+ taken.Add(1)
+ go func() {
+ defer taken.Done()
+ _, ok := store.take(id, now)
+ results <- ok
+ }()
+ }
+ taken.Wait()
+ close(results)
+ accepted := 0
+ for ok := range results {
+ if ok {
+ accepted++
+ }
+ }
+ if accepted > 64 {
+ t.Fatalf("accepted %d flows, want at most the store capacity", accepted)
+ }
+ if store.size() != 0 {
+ t.Fatalf("size = %d, want 0", store.size())
+ }
+}
diff --git a/internal/authapi/handler.go b/internal/authapi/handler.go
new file mode 100644
index 0000000..9b4698d
--- /dev/null
+++ b/internal/authapi/handler.go
@@ -0,0 +1,347 @@
+// Package authapi exposes the two browser-facing OIDC endpoints that complete the
+// authorization code flow implemented in internal/auth: GET /auth/login starts a
+// flow and GET /auth/callback finishes it by issuing a Pulse session.
+//
+// Flow state (state, nonce, PKCE verifier and the post-login path) never leaves the
+// server; the browser only carries a short-lived opaque flow identifier cookie.
+// Every failure path destroys the identified flow, issues no session and redirects
+// to a fixed in-app error route with a reason code from a closed set. A flow whose
+// identifier never comes back simply expires. Provider-supplied
+// text is never reflected into a response, and tokens, codes and PKCE verifiers are
+// never logged.
+//
+// Wiring in cmd/api/main.go, after the session manager exists:
+//
+// oidcAuth, err := authapi.New(authapi.Options{
+// OIDC: auth.OIDCConfig{
+// Issuer: application.OIDCIssuer,
+// ClientID: application.OIDCClientID,
+// ClientSecret: application.OIDCClientSecret,
+// RedirectURL: application.OIDCRedirectURL,
+// },
+// RoleMapping: map[string]auth.Role{
+// "pulse-viewer": auth.RoleViewer, "pulse-operator": auth.RoleOperator,
+// "pulse-editor": auth.RoleEditor, "pulse-admin": auth.RoleAdministrator,
+// },
+// Sessions: sessions,
+// Secure: application.Environment == config.Production,
+// Logger: logger,
+// Audit: func(ctx context.Context, actor, result string) error {
+// if pool == nil {
+// return nil
+// }
+// return audit.RecordSecurityAction(ctx, audit.PostgresStore{Pool: pool}, actor, "auth.login", result, correlation.FromContext(ctx))
+// },
+// })
+// if err != nil {
+// return err
+// }
+// mux.Handle("/auth/login", oidcAuth.LoginHandler())
+// mux.Handle("/auth/callback", oidcAuth.CallbackHandler())
+//
+// New only fails on incomplete configuration, so registration is safe when
+// PULSE_AUTH_MODE is oidc; guard it with `if application.AuthMode == "oidc"` so a
+// mock-mode development run keeps working. The callback path registered here must
+// equal the path of PULSE_OIDC_REDIRECT_URL. Provider discovery happens lazily on
+// the first login and is cached, so a temporarily unreachable IdP does not prevent
+// the API from starting.
+//
+// The runtime mapping is supplied by PULSE_OIDC_ROLE_MAPPING through
+// internal/config. An empty mapping authorizes nobody, and production startup
+// rejects it before the handlers are registered.
+package authapi
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/correlation"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+const (
+ flowCookieName = "pulse_auth_flow"
+ defaultErrorPath = "/login/error"
+ defaultRedirect = "/"
+ maxRedirectLength = 512
+ discoveryTimeout = 10 * time.Second
+ tokenTimeout = 15 * time.Second
+)
+
+// Reason codes are a closed set; the provider never influences their value.
+const (
+ reasonInvalidRequest = "invalid_request"
+ reasonExpired = "expired"
+ reasonDenied = "denied"
+ reasonProviderUnavailable = "provider_unavailable"
+ reasonNotAuthorized = "not_authorized"
+ reasonUnavailable = "unavailable"
+)
+
+// SessionIssuer is the part of *auth.SessionManager the callback needs.
+type SessionIssuer interface {
+ Issue(response http.ResponseWriter, principal auth.Principal, now time.Time) error
+}
+
+type Options struct {
+ // OIDC is the provider configuration; issuer, client ID and redirect URL are required.
+ OIDC auth.OIDCConfig
+ // RoleMapping maps IdP group claim values to Pulse roles. Empty means nobody can log in.
+ RoleMapping map[string]auth.Role
+ // GroupsClaim is the ID token claim holding role values; defaults to "groups".
+ GroupsClaim string
+ // Sessions issues the Pulse session cookie after a verified login.
+ Sessions SessionIssuer
+ // Secure marks the flow cookie Secure; set it in production.
+ Secure bool
+ // FlowTTL bounds how long a started flow stays valid; defaults to 10 minutes.
+ FlowTTL time.Duration
+ // MaxFlows caps concurrently pending flows; defaults to 1024.
+ MaxFlows int
+ // DefaultRedirect is the post-login path when none was requested; defaults to "/".
+ DefaultRedirect string
+ // ErrorPath is the in-app route failures redirect to; defaults to "/login/error".
+ ErrorPath string
+ // Logger receives structured, secret-free flow events; optional.
+ Logger *slog.Logger
+ // Now overrides the clock; defaults to time.Now().UTC(). It must stay close to
+ // real time because the OIDC provider validates token freshness independently.
+ Now func() time.Time
+ // Audit records the security event before a session is issued. A returned error
+ // fails the login closed; optional.
+ Audit func(ctx context.Context, actor, result string) error
+}
+
+// Handler serves the login and callback endpoints. Create it with New.
+type Handler struct {
+ options Options
+ flows *flowStore
+
+ mu sync.Mutex
+ discovery auth.Discovery
+ resolved bool
+}
+
+func New(options Options) (*Handler, error) {
+ if strings.TrimSpace(options.OIDC.Issuer) == "" || strings.TrimSpace(options.OIDC.ClientID) == "" || strings.TrimSpace(options.OIDC.RedirectURL) == "" {
+ return nil, &configError{"OIDC issuer, client ID and redirect URL are required"}
+ }
+ if options.Sessions == nil {
+ return nil, &configError{"session issuer is required"}
+ }
+ if options.GroupsClaim == "" {
+ options.GroupsClaim = "groups"
+ }
+ options.DefaultRedirect = safePath(options.DefaultRedirect, defaultRedirect)
+ if strings.ContainsAny(options.ErrorPath, "?#") {
+ options.ErrorPath = ""
+ }
+ options.ErrorPath = safePath(options.ErrorPath, defaultErrorPath)
+ if options.Logger == nil {
+ options.Logger = slog.New(slog.DiscardHandler)
+ }
+ if options.Now == nil {
+ options.Now = func() time.Time { return time.Now().UTC() }
+ }
+ return &Handler{options: options, flows: newFlowStore(options.FlowTTL, options.MaxFlows)}, nil
+}
+
+type configError struct{ detail string }
+
+func (e *configError) Error() string { return "authapi configuration invalid: " + e.detail }
+
+// LoginHandler starts the authorization code flow. Register it on /auth/login.
+func (handler *Handler) LoginHandler() http.Handler { return http.HandlerFunc(handler.login) }
+
+// CallbackHandler completes the flow. Register it on the path of the configured
+// OIDC redirect URL, normally /auth/callback.
+func (handler *Handler) CallbackHandler() http.Handler { return http.HandlerFunc(handler.callback) }
+
+func (handler *Handler) login(response http.ResponseWriter, request *http.Request) {
+ if request.Method != http.MethodGet {
+ methodNotAllowed(response, request)
+ return
+ }
+ now := handler.options.Now()
+ discovery, err := handler.discover(request.Context())
+ if err != nil {
+ handler.reject(response, request, reasonProviderUnavailable, "discovery_failed")
+ return
+ }
+ authorization, err := auth.BeginAuthorization(discovery.Endpoint, handler.options.OIDC, now)
+ if err != nil {
+ handler.reject(response, request, reasonProviderUnavailable, "authorization_start_failed")
+ return
+ }
+ redirect := safePath(request.URL.Query().Get("redirect"), handler.options.DefaultRedirect)
+ id, err := handler.flows.create(flow{authorization: authorization, redirect: redirect}, now)
+ if err != nil {
+ handler.reject(response, request, reasonUnavailable, "flow_not_stored")
+ return
+ }
+ http.SetCookie(response, &http.Cookie{
+ Name: flowCookieName,
+ Value: id,
+ Path: "/",
+ MaxAge: int(handler.flows.ttl.Seconds()),
+ Expires: now.Add(handler.flows.ttl),
+ HttpOnly: true,
+ Secure: handler.options.Secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+ handler.options.Logger.Info("oidc login started", "correlation_id", correlation.FromContext(request.Context()), "pending_flows", handler.flows.size())
+ http.Redirect(response, request, authorization.URL, http.StatusFound)
+}
+
+func (handler *Handler) callback(response http.ResponseWriter, request *http.Request) {
+ if request.Method != http.MethodGet {
+ methodNotAllowed(response, request)
+ return
+ }
+ now := handler.options.Now()
+ cookie, err := request.Cookie(flowCookieName)
+ handler.clearFlowCookie(response)
+ if err != nil || cookie.Value == "" {
+ handler.reject(response, request, reasonInvalidRequest, "flow_cookie_missing")
+ return
+ }
+ pending, ok := handler.flows.take(cookie.Value, now)
+ if !ok {
+ handler.reject(response, request, reasonExpired, "flow_unknown_or_expired")
+ return
+ }
+ query := request.URL.Query()
+ if providerError := query.Get("error"); providerError != "" {
+ reason := reasonProviderUnavailable
+ if providerError == "access_denied" {
+ reason = reasonDenied
+ }
+ handler.reject(response, request, reason, "provider_reported_error")
+ return
+ }
+ state, code := query.Get("state"), query.Get("code")
+ if err := auth.ValidateCallback(pending.authorization, state, code, now); err != nil {
+ handler.reject(response, request, reasonInvalidRequest, "callback_validation_failed")
+ return
+ }
+ discovery, err := handler.discover(request.Context())
+ if err != nil {
+ handler.reject(response, request, reasonProviderUnavailable, "discovery_failed")
+ return
+ }
+ ctx, cancel := context.WithTimeout(request.Context(), tokenTimeout)
+ defer cancel()
+ token, err := auth.Exchange(ctx, pending.authorization, handler.options.OIDC, discovery.Endpoint, state, code)
+ if err != nil {
+ handler.reject(response, request, reasonProviderUnavailable, "token_exchange_failed")
+ return
+ }
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok || rawIDToken == "" {
+ handler.reject(response, request, reasonProviderUnavailable, "id_token_missing")
+ return
+ }
+ idToken, err := auth.VerifyIDToken(ctx, discovery.Verifier, rawIDToken, pending.authorization.Nonce)
+ if err != nil {
+ handler.reject(response, request, reasonInvalidRequest, "id_token_rejected")
+ return
+ }
+ identity, err := auth.ExtractIdentity(idToken, handler.options.GroupsClaim)
+ if err != nil {
+ handler.reject(response, request, reasonInvalidRequest, "identity_incomplete")
+ return
+ }
+ role, err := auth.MapRoles(identity.Groups, handler.options.RoleMapping)
+ if err != nil {
+ handler.reject(response, request, reasonNotAuthorized, "no_authorized_role")
+ return
+ }
+ principal := auth.Principal{Subject: identity.Subject, Role: role}
+ if handler.options.Audit != nil {
+ if err := handler.options.Audit(request.Context(), principal.Subject, "success"); err != nil {
+ handler.reject(response, request, reasonUnavailable, "audit_unavailable")
+ return
+ }
+ }
+ if err := handler.options.Sessions.Issue(response, principal, now); err != nil {
+ handler.reject(response, request, reasonUnavailable, "session_not_issued")
+ return
+ }
+ handler.options.Logger.Info("oidc login completed", "correlation_id", correlation.FromContext(request.Context()), "role", string(role))
+ http.Redirect(response, request, safePath(pending.redirect, handler.options.DefaultRedirect), http.StatusFound)
+}
+
+// discover resolves and caches the provider endpoints and verifier.
+func (handler *Handler) discover(ctx context.Context) (auth.Discovery, error) {
+ handler.mu.Lock()
+ defer handler.mu.Unlock()
+ if handler.resolved {
+ return handler.discovery, nil
+ }
+ discoveryContext, cancel := context.WithTimeout(ctx, discoveryTimeout)
+ defer cancel()
+ discovery, err := auth.Discover(discoveryContext, handler.options.OIDC)
+ if err != nil {
+ return auth.Discovery{}, err
+ }
+ handler.discovery, handler.resolved = discovery, true
+ return discovery, nil
+}
+
+// reject issues no session and sends the browser to the in-app error route with a
+// fixed reason code. The flow state is already removed by the time it is called.
+func (handler *Handler) reject(response http.ResponseWriter, request *http.Request, reason, event string) {
+ handler.options.Logger.Warn("oidc flow rejected", "correlation_id", correlation.FromContext(request.Context()), "reason", reason, "event", event)
+ target := handler.options.ErrorPath + "?" + url.Values{"reason": []string{reason}}.Encode()
+ http.Redirect(response, request, target, http.StatusFound)
+}
+
+func (handler *Handler) clearFlowCookie(response http.ResponseWriter) {
+ http.SetCookie(response, &http.Cookie{
+ Name: flowCookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: handler.options.Secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func methodNotAllowed(response http.ResponseWriter, request *http.Request) {
+ problem.Write(response, request, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", http.StatusText(http.StatusMethodNotAllowed), "This method is not supported.", nil)
+}
+
+// safePath accepts only in-app absolute paths: one leading slash, no scheme, no
+// authority, no backslash and no control characters. Anything else falls back.
+func safePath(candidate, fallback string) string {
+ target := strings.TrimSpace(candidate)
+ if target == "" || len(target) > maxRedirectLength {
+ return fallback
+ }
+ if !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") {
+ return fallback
+ }
+ if strings.Contains(target, "\\") {
+ return fallback
+ }
+ for _, character := range target {
+ if character < 0x20 || character == 0x7f {
+ return fallback
+ }
+ }
+ parsed, err := url.Parse(target)
+ if err != nil || parsed.Scheme != "" || parsed.Host != "" || parsed.Opaque != "" || parsed.User != nil {
+ return fallback
+ }
+ if !strings.HasPrefix(parsed.Path, "/") {
+ return fallback
+ }
+ return target
+}
diff --git a/internal/authapi/handler_test.go b/internal/authapi/handler_test.go
new file mode 100644
index 0000000..ad1f7d3
--- /dev/null
+++ b/internal/authapi/handler_test.go
@@ -0,0 +1,578 @@
+package authapi
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+)
+
+const testClientID = "pulse-test-client"
+
+type recordingSessions struct {
+ mu sync.Mutex
+ principals []auth.Principal
+ failure error
+}
+
+func (sessions *recordingSessions) Issue(response http.ResponseWriter, principal auth.Principal, _ time.Time) error {
+ sessions.mu.Lock()
+ defer sessions.mu.Unlock()
+ if sessions.failure != nil {
+ return sessions.failure
+ }
+ sessions.principals = append(sessions.principals, principal)
+ http.SetCookie(response, &http.Cookie{Name: "pulse_session", Value: "issued", Path: "/", HttpOnly: true})
+ return nil
+}
+
+func (sessions *recordingSessions) issued() []auth.Principal {
+ sessions.mu.Lock()
+ defer sessions.mu.Unlock()
+ return append([]auth.Principal(nil), sessions.principals...)
+}
+
+type harness struct {
+ handler *Handler
+ idp *fakeIdP
+ sessions *recordingSessions
+ clock func() time.Time
+ offset *time.Duration
+}
+
+func newHarness(t *testing.T, mutate func(*Options)) *harness {
+ t.Helper()
+ idp := newFakeIdP(t, testClientID)
+ sessions := &recordingSessions{}
+ offset := time.Duration(0)
+ options := Options{
+ OIDC: auth.OIDCConfig{
+ Issuer: idp.server.URL,
+ ClientID: testClientID,
+ ClientSecret: "test-secret",
+ RedirectURL: "https://pulse.example/auth/callback",
+ },
+ RoleMapping: map[string]auth.Role{"pulse-viewer": auth.RoleViewer, "pulse-operator": auth.RoleOperator, "pulse-admin": auth.RoleAdministrator},
+ Sessions: sessions,
+ Now: func() time.Time { return time.Now().UTC().Add(offset) },
+ }
+ if mutate != nil {
+ mutate(&options)
+ }
+ handler, err := New(options)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return &harness{handler: handler, idp: idp, sessions: sessions, clock: options.Now, offset: &offset}
+}
+
+// begin runs GET /auth/login and returns the flow cookie value and the query the
+// browser would have sent to the IdP.
+func (h *harness) begin(t *testing.T, target string) (string, url.Values, *httptest.ResponseRecorder) {
+ t.Helper()
+ request := httptest.NewRequest(http.MethodGet, target, nil)
+ response := httptest.NewRecorder()
+ h.handler.LoginHandler().ServeHTTP(response, request)
+ if response.Code != http.StatusFound {
+ t.Fatalf("login status = %d, want %d", response.Code, http.StatusFound)
+ }
+ authorizationURL, err := url.Parse(response.Header().Get("Location"))
+ if err != nil {
+ t.Fatalf("parse authorization URL: %v", err)
+ }
+ query := authorizationURL.Query()
+ h.idp.configure(func(idp *fakeIdP) {
+ idp.nonce = query.Get("nonce")
+ idp.expectedChallenge = query.Get("code_challenge")
+ })
+ return flowCookie(t, response), query, response
+}
+
+// complete runs GET /auth/callback with the supplied cookie and query.
+func (h *harness) complete(t *testing.T, cookie string, query url.Values) *httptest.ResponseRecorder {
+ t.Helper()
+ request := httptest.NewRequest(http.MethodGet, "/auth/callback?"+query.Encode(), nil)
+ if cookie != "" {
+ request.AddCookie(&http.Cookie{Name: flowCookieName, Value: cookie})
+ }
+ response := httptest.NewRecorder()
+ h.handler.CallbackHandler().ServeHTTP(response, request)
+ return response
+}
+
+func flowCookie(t *testing.T, response *httptest.ResponseRecorder) string {
+ t.Helper()
+ for _, cookie := range response.Result().Cookies() {
+ if cookie.Name == flowCookieName {
+ return cookie.Value
+ }
+ }
+ t.Fatal("flow cookie was not set")
+ return ""
+}
+
+func cookieByName(response *httptest.ResponseRecorder, name string) *http.Cookie {
+ for _, cookie := range response.Result().Cookies() {
+ if cookie.Name == name {
+ return cookie
+ }
+ }
+ return nil
+}
+
+func errorReason(t *testing.T, response *httptest.ResponseRecorder) string {
+ t.Helper()
+ if response.Code != http.StatusFound {
+ t.Fatalf("status = %d, want %d", response.Code, http.StatusFound)
+ }
+ location, err := url.Parse(response.Header().Get("Location"))
+ if err != nil {
+ t.Fatalf("parse location: %v", err)
+ }
+ if location.Path != defaultErrorPath {
+ t.Fatalf("location path = %q, want %q", location.Path, defaultErrorPath)
+ }
+ return location.Query().Get("reason")
+}
+
+func TestLoginStartsBoundedServerSideFlow(t *testing.T) {
+ harness := newHarness(t, func(options *Options) { options.Secure = true })
+ cookie, query, response := harness.begin(t, "/auth/login")
+
+ for _, key := range []string{"state", "nonce", "code_challenge", "client_id", "redirect_uri"} {
+ if query.Get(key) == "" {
+ t.Fatalf("authorization URL missing %s", key)
+ }
+ }
+ if query.Get("code_challenge_method") != "S256" {
+ t.Fatalf("code_challenge_method = %q", query.Get("code_challenge_method"))
+ }
+ if strings.Contains(response.Header().Get("Location"), cookie) {
+ t.Fatal("flow identifier leaked into the authorization URL")
+ }
+ if cookieByName(response, "pulse_session") != nil {
+ t.Fatal("login issued a session")
+ }
+ flowCookie := cookieByName(response, flowCookieName)
+ if !flowCookie.HttpOnly || !flowCookie.Secure || flowCookie.SameSite != http.SameSiteLaxMode {
+ t.Fatalf("flow cookie attributes are unsafe: %#v", flowCookie)
+ }
+ if flowCookie.MaxAge <= 0 || flowCookie.MaxAge > int(defaultFlowTTL.Seconds()) {
+ t.Fatalf("flow cookie MaxAge = %d", flowCookie.MaxAge)
+ }
+ if harness.handler.flows.size() != 1 {
+ t.Fatalf("pending flows = %d, want 1", harness.handler.flows.size())
+ }
+ pending, ok := harness.handler.flows.take(cookie, harness.clock())
+ if !ok || pending.authorization.State != query.Get("state") || pending.authorization.Nonce != query.Get("nonce") || pending.authorization.CodeVerifier == "" {
+ t.Fatal("flow state was not stored server-side")
+ }
+}
+
+func TestCallbackHappyPathIssuesSession(t *testing.T) {
+ harness := newHarness(t, nil)
+ cookie, query, _ := harness.begin(t, "/auth/login?redirect=%2Fincidents")
+ response := harness.complete(t, cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}})
+
+ if response.Code != http.StatusFound || response.Header().Get("Location") != "/incidents" {
+ t.Fatalf("status = %d, location = %q", response.Code, response.Header().Get("Location"))
+ }
+ issued := harness.sessions.issued()
+ if len(issued) != 1 || issued[0].Subject != "user-1" || issued[0].Role != auth.RoleOperator {
+ t.Fatalf("issued sessions = %#v", issued)
+ }
+ if verifier := harness.idp.codeVerifier(); verifier == "" {
+ t.Fatal("PKCE verifier was not sent to the token endpoint")
+ }
+ cleared := cookieByName(response, flowCookieName)
+ if cleared == nil || cleared.MaxAge >= 0 || cleared.Value != "" {
+ t.Fatalf("flow cookie was not cleared: %#v", cleared)
+ }
+ if harness.handler.flows.size() != 0 {
+ t.Fatal("flow state survived the callback")
+ }
+}
+
+func TestCallbackWorksWithRealSessionManager(t *testing.T) {
+ manager := auth.NewSessionManager("pulse_session", time.Hour, false)
+ harness := newHarness(t, func(options *Options) { options.Sessions = manager })
+ cookie, query, _ := harness.begin(t, "/auth/login")
+ response := harness.complete(t, cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}})
+
+ sessionCookie := cookieByName(response, "pulse_session")
+ if sessionCookie == nil {
+ t.Fatal("session cookie was not set")
+ }
+ next := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
+ next.AddCookie(sessionCookie)
+ principal, ok := manager.Principal(next, time.Now().UTC())
+ if !ok || principal.Subject != "user-1" || principal.Role != auth.RoleOperator {
+ t.Fatalf("principal = %#v, ok = %v", principal, ok)
+ }
+}
+
+func TestCallbackFailurePathsIssueNoSession(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ // arrange starts a flow and returns the callback cookie and query.
+ arrange func(t *testing.T, h *harness) (string, url.Values)
+ reason string
+ // pendingFlows is what may still be stored afterwards: an untouched flow
+ // stays pending until it expires, an identified one is always destroyed.
+ pendingFlows int
+ }{
+ {
+ name: "missing flow cookie",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ _, query, _ := h.begin(t, "/auth/login")
+ return "", url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonInvalidRequest,
+ pendingFlows: 1,
+ },
+ {
+ name: "unknown flow identifier",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ _, query, _ := h.begin(t, "/auth/login")
+ return "not-a-known-flow", url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonExpired,
+ pendingFlows: 1,
+ },
+ {
+ name: "replayed flow identifier",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ values := url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ if first := h.complete(t, cookie, values); first.Header().Get("Location") != "/" {
+ t.Fatalf("first callback did not succeed: %q", first.Header().Get("Location"))
+ }
+ h.sessions.mu.Lock()
+ h.sessions.principals = nil
+ h.sessions.mu.Unlock()
+ return cookie, values
+ },
+ reason: reasonExpired,
+ },
+ {
+ name: "expired flow",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ *h.offset = defaultFlowTTL + time.Minute
+ return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonExpired,
+ },
+ {
+ name: "state mismatch",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, _, _ := h.begin(t, "/auth/login")
+ return cookie, url.Values{"state": {"forged-state"}, "code": {"authorization-code"}}
+ },
+ reason: reasonInvalidRequest,
+ },
+ {
+ name: "missing authorization code",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ return cookie, url.Values{"state": {query.Get("state")}}
+ },
+ reason: reasonInvalidRequest,
+ },
+ {
+ name: "nonce mismatch",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ h.idp.configure(func(idp *fakeIdP) { idp.nonce = "replayed-nonce" })
+ return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonInvalidRequest,
+ },
+ {
+ name: "provider access denied",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ return cookie, url.Values{"state": {query.Get("state")}, "error": {"access_denied"}, "error_description": {" denied by policy"}}
+ },
+ reason: reasonDenied,
+ },
+ {
+ name: "provider error response",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ return cookie, url.Values{"state": {query.Get("state")}, "error": {"server_error"}}
+ },
+ reason: reasonProviderUnavailable,
+ },
+ {
+ name: "token exchange failure",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ h.idp.configure(func(idp *fakeIdP) { idp.tokenFails = true })
+ return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonProviderUnavailable,
+ },
+ {
+ name: "id token missing",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ h.idp.configure(func(idp *fakeIdP) { idp.omitIDToken = true })
+ return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonProviderUnavailable,
+ },
+ {
+ name: "id token from wrong issuer",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ h.idp.configure(func(idp *fakeIdP) { idp.issuerOverride = "https://attacker.example" })
+ return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonInvalidRequest,
+ },
+ {
+ name: "no mapped role",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ h.idp.configure(func(idp *fakeIdP) { idp.groups = []string{"some-other-group"} })
+ return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonNotAuthorized,
+ },
+ {
+ name: "session issue failure",
+ arrange: func(t *testing.T, h *harness) (string, url.Values) {
+ cookie, query, _ := h.begin(t, "/auth/login")
+ h.sessions.mu.Lock()
+ h.sessions.failure = errors.New("session store unavailable")
+ h.sessions.mu.Unlock()
+ return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
+ },
+ reason: reasonUnavailable,
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ harness := newHarness(t, nil)
+ cookie, query := test.arrange(t, harness)
+ response := harness.complete(t, cookie, query)
+
+ if reason := errorReason(t, response); reason != test.reason {
+ t.Fatalf("reason = %q, want %q", reason, test.reason)
+ }
+ if issued := harness.sessions.issued(); len(issued) != 0 {
+ t.Fatalf("a session was issued on a failure path: %#v", issued)
+ }
+ if cookieByName(response, "pulse_session") != nil {
+ t.Fatal("a session cookie was set on a failure path")
+ }
+ if harness.handler.flows.size() != test.pendingFlows {
+ t.Fatalf("pending flows = %d, want %d", harness.handler.flows.size(), test.pendingFlows)
+ }
+ body := response.Body.String()
+ for _, forbidden := range []string{"", "/"},
+ {"newline injection", "/incidents\r\nSet-Cookie: x=1", "/"},
+ {"userinfo authority", "//user:pass@evil.example/", "/"},
+ {"overlong", "/" + strings.Repeat("a", maxRedirectLength), "/"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ harness := newHarness(t, nil)
+ cookie, query, _ := harness.begin(t, "/auth/login?redirect="+url.QueryEscape(test.redirect))
+ response := harness.complete(t, cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}})
+ if response.Code != http.StatusFound {
+ t.Fatalf("status = %d", response.Code)
+ }
+ if location := response.Header().Get("Location"); location != test.want {
+ t.Fatalf("location = %q, want %q", location, test.want)
+ }
+ })
+ }
+}
+
+func TestConcurrentFlowCreationIsSafeAndBounded(t *testing.T) {
+ const workers = 64
+ harness := newHarness(t, func(options *Options) { options.MaxFlows = 16 })
+ var wait sync.WaitGroup
+ cookies := make([]string, workers)
+ for index := range workers {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ request := httptest.NewRequest(http.MethodGet, "/auth/login", nil)
+ response := httptest.NewRecorder()
+ harness.handler.LoginHandler().ServeHTTP(response, request)
+ for _, cookie := range response.Result().Cookies() {
+ if cookie.Name == flowCookieName {
+ cookies[index] = cookie.Value
+ }
+ }
+ }()
+ }
+ wait.Wait()
+
+ unique := make(map[string]struct{}, workers)
+ for _, cookie := range cookies {
+ if cookie == "" {
+ t.Fatal("a concurrent login produced no flow cookie")
+ }
+ unique[cookie] = struct{}{}
+ }
+ if len(unique) != workers {
+ t.Fatalf("unique flow identifiers = %d, want %d", len(unique), workers)
+ }
+ if size := harness.handler.flows.size(); size > 16 {
+ t.Fatalf("pending flows = %d, want at most 16", size)
+ }
+}
+
+func TestDiscoveryFailureRedirectsSafely(t *testing.T) {
+ closed := httptest.NewServer(http.NewServeMux())
+ issuer := closed.URL
+ closed.Close()
+ handler, err := New(Options{
+ OIDC: auth.OIDCConfig{Issuer: issuer, ClientID: testClientID, RedirectURL: "https://pulse.example/auth/callback"},
+ Sessions: &recordingSessions{},
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ response := httptest.NewRecorder()
+ handler.LoginHandler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
+ if reason := errorReason(t, response); reason != reasonProviderUnavailable {
+ t.Fatalf("reason = %q, want %q", reason, reasonProviderUnavailable)
+ }
+ if body := response.Body.String(); strings.Contains(body, issuer) {
+ t.Fatalf("response leaked the issuer: %s", body)
+ }
+}
+
+func TestNonGetMethodsAreRejected(t *testing.T) {
+ harness := newHarness(t, nil)
+ for _, test := range []struct {
+ name string
+ handler http.Handler
+ target string
+ }{
+ {"login", harness.handler.LoginHandler(), "/auth/login"},
+ {"callback", harness.handler.CallbackHandler(), "/auth/callback"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ response := httptest.NewRecorder()
+ test.handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, test.target, nil))
+ if response.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("status = %d, want %d", response.Code, http.StatusMethodNotAllowed)
+ }
+ if contentType := response.Header().Get("Content-Type"); contentType != "application/problem+json" {
+ t.Fatalf("content type = %q", contentType)
+ }
+ })
+ }
+}
+
+func TestNewValidatesOptions(t *testing.T) {
+ valid := auth.OIDCConfig{Issuer: "https://idp.example", ClientID: "pulse", RedirectURL: "https://pulse.example/auth/callback"}
+ for _, test := range []struct {
+ name string
+ options Options
+ wantErr bool
+ }{
+ {"complete", Options{OIDC: valid, Sessions: &recordingSessions{}}, false},
+ {"missing issuer", Options{OIDC: auth.OIDCConfig{ClientID: "pulse", RedirectURL: valid.RedirectURL}, Sessions: &recordingSessions{}}, true},
+ {"missing client id", Options{OIDC: auth.OIDCConfig{Issuer: valid.Issuer, RedirectURL: valid.RedirectURL}, Sessions: &recordingSessions{}}, true},
+ {"missing redirect url", Options{OIDC: auth.OIDCConfig{Issuer: valid.Issuer, ClientID: "pulse"}, Sessions: &recordingSessions{}}, true},
+ {"missing sessions", Options{OIDC: valid}, true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ handler, err := New(test.options)
+ if (err != nil) != test.wantErr {
+ t.Fatalf("err = %v, wantErr = %v", err, test.wantErr)
+ }
+ if err != nil {
+ if handler != nil {
+ t.Fatal("handler returned with an error")
+ }
+ return
+ }
+ if handler.options.ErrorPath != defaultErrorPath || handler.options.DefaultRedirect != defaultRedirect || handler.options.GroupsClaim != "groups" {
+ t.Fatalf("defaults not applied: %#v", handler.options)
+ }
+ })
+ }
+}
+
+func TestNewNormalizesUnsafePaths(t *testing.T) {
+ handler, err := New(Options{
+ OIDC: auth.OIDCConfig{Issuer: "https://idp.example", ClientID: "pulse", RedirectURL: "https://pulse.example/auth/callback"},
+ Sessions: &recordingSessions{},
+ DefaultRedirect: "//evil.example",
+ ErrorPath: "/login/error?reason=spoofed",
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if handler.options.DefaultRedirect != defaultRedirect || handler.options.ErrorPath != defaultErrorPath {
+ t.Fatalf("unsafe paths were kept: %#v", handler.options)
+ }
+}
diff --git a/internal/authapi/wiring_test.go b/internal/authapi/wiring_test.go
new file mode 100644
index 0000000..55f8c5d
--- /dev/null
+++ b/internal/authapi/wiring_test.go
@@ -0,0 +1,72 @@
+package authapi_test
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/authapi"
+ "github.com/itworx/pulse/internal/config"
+ "github.com/itworx/pulse/internal/correlation"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// TestDocumentedWiringCompilesAndRoutes mirrors the registration snippet in the
+// package documentation so cmd/api/main.go can copy it verbatim.
+func TestDocumentedWiringCompilesAndRoutes(t *testing.T) {
+ // The issuer points at a closed local server so the test stays offline: both
+ // endpoints then answer with the safe error redirect instead of a session.
+ unreachable := httptest.NewServer(http.NewServeMux())
+ unreachable.Close()
+ application := config.Config{
+ Environment: config.Development,
+ AuthMode: "oidc",
+ OIDCIssuer: unreachable.URL,
+ OIDCClientID: "pulse",
+ OIDCRedirectURL: "https://pulse.example/auth/callback",
+ }
+ sessions := auth.NewSessionManager("pulse_session", 8*time.Hour, application.Environment == config.Production)
+ logger := slog.New(slog.DiscardHandler)
+ var pool *pgxpool.Pool
+
+ oidcAuth, err := authapi.New(authapi.Options{
+ OIDC: auth.OIDCConfig{
+ Issuer: application.OIDCIssuer,
+ ClientID: application.OIDCClientID,
+ ClientSecret: application.OIDCClientSecret,
+ RedirectURL: application.OIDCRedirectURL,
+ },
+ RoleMapping: map[string]auth.Role{
+ "pulse-viewer": auth.RoleViewer, "pulse-operator": auth.RoleOperator,
+ "pulse-editor": auth.RoleEditor, "pulse-admin": auth.RoleAdministrator,
+ },
+ Sessions: sessions,
+ Secure: application.Environment == config.Production,
+ Logger: logger,
+ Audit: func(ctx context.Context, actor, result string) error {
+ if pool == nil {
+ return nil
+ }
+ return audit.RecordSecurityAction(ctx, audit.PostgresStore{Pool: pool}, actor, "auth.login", result, correlation.FromContext(ctx))
+ },
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ mux := http.NewServeMux()
+ mux.Handle("/auth/login", oidcAuth.LoginHandler())
+ mux.Handle("/auth/callback", oidcAuth.CallbackHandler())
+
+ for _, path := range []string{"/auth/login", "/auth/callback"} {
+ response := httptest.NewRecorder()
+ mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
+ if response.Code != http.StatusFound {
+ t.Fatalf("%s status = %d, want %d", path, response.Code, http.StatusFound)
+ }
+ }
+}
diff --git a/internal/backup/manager.go b/internal/backup/manager.go
new file mode 100644
index 0000000..1c303db
--- /dev/null
+++ b/internal/backup/manager.go
@@ -0,0 +1,573 @@
+package backup
+
+import (
+ "archive/zip"
+ "bufio"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+const (
+ formatVersion = 2
+ defaultRetention = 5
+ manifestName = "manifest.json"
+)
+
+var ErrNotConfigured = errors.New("backup destination is not configured")
+
+type Manager struct {
+ Pool *pgxpool.Pool
+ Directory string
+ Retention int
+ Now func() time.Time
+}
+
+type Manifest struct {
+ FormatVersion int `json:"formatVersion"`
+ BackupID string `json:"backupId"`
+ CreatedAt time.Time `json:"createdAt"`
+ SchemaVersion int64 `json:"schemaVersion"`
+ Tables []TableManifest `json:"tables"`
+ Excluded []string `json:"excluded"`
+}
+
+type TableManifest struct {
+ Name string `json:"name"`
+ File string `json:"file"`
+ Rows int64 `json:"rows"`
+ SHA256 string `json:"sha256"`
+}
+
+type Result struct {
+ BackupID string `json:"backupId"`
+ Path string `json:"path"`
+ SHA256 string `json:"sha256"`
+ Bytes int64 `json:"bytes"`
+ Rows int64 `json:"rows"`
+ Created time.Time `json:"createdAt"`
+}
+
+type tableSpec struct {
+ name string
+ columns string
+ restoreCols string
+ orderBy string
+}
+
+var tableSpecs = []tableSpec{
+ {name: "roles", columns: "id,name,created_at", restoreCols: "id,name,created_at", orderBy: "id"},
+ {name: "users", columns: "id,external_subject,display_name,email,status,created_at,updated_at,last_login_at", restoreCols: "id,external_subject,display_name,email,status,created_at,updated_at,last_login_at", orderBy: "id"},
+ {name: "user_roles", columns: "user_id,role_id,created_at", restoreCols: "user_id,role_id,created_at", orderBy: "user_id,role_id"},
+ {name: "data_sources", columns: "id,type,name,enabled,configuration_ref,capability_document,health_state,last_success_at,last_error_code,last_error_message,freshness_policy,created_at,updated_at", restoreCols: "id,type,name,enabled,configuration_ref,capability_document,health_state,last_success_at,last_error_code,last_error_message,freshness_policy,created_at,updated_at", orderBy: "id"},
+ {name: "collectors", columns: "id,datasource_id,kind,version,heartbeat,capabilities,status", restoreCols: "id,datasource_id,kind,version,heartbeat,capabilities,status", orderBy: "id"},
+ {name: "entities", columns: "id,entity_type,canonical_name,display_name,status,status_reasons,first_seen_at,last_seen_at,tombstoned_at,attributes", restoreCols: "id,entity_type,canonical_name,display_name,status,status_reasons,first_seen_at,last_seen_at,tombstoned_at,attributes", orderBy: "id"},
+ {name: "entity_aliases", columns: "entity_id,source_id,external_type,external_id", restoreCols: "entity_id,source_id,external_type,external_id", orderBy: "source_id,external_type,external_id"},
+ {name: "container_aliases", columns: "source_id,runtime_id,entity_id,name,project,service,image_digest,observed_state,observed_health,restart_count,intentional_stop,first_seen_at,last_seen_at,tombstoned_at", restoreCols: "source_id,runtime_id,entity_id,name,project,service,image_digest,observed_state,observed_health,restart_count,intentional_stop,first_seen_at,last_seen_at,tombstoned_at", orderBy: "source_id,runtime_id"},
+ {name: "entity_facts", columns: "entity_id,field_name,source_id,value,observed_at,confidence,valid_until", restoreCols: "entity_id,field_name,source_id,value,observed_at,confidence,valid_until", orderBy: "entity_id,field_name,source_id"},
+ {name: "entity_overrides", columns: "entity_id,field_name,value,user_id,updated_at", restoreCols: "entity_id,field_name,value,user_id,updated_at", orderBy: "entity_id,field_name"},
+ {name: "entity_relations", columns: "id,source_entity_id,relation_type,target_entity_id,source_id,confidence,confirmed,first_seen_at,last_seen_at,tombstoned_at", restoreCols: "id,source_entity_id,relation_type,target_entity_id,source_id,confidence,confirmed,first_seen_at,last_seen_at,tombstoned_at", orderBy: "id"},
+ {name: "dashboards", columns: "id,slug,name,description,owner_user_id,scope,archived_at,current_version_id,revision,created_at,updated_at", restoreCols: "id,slug,name,description,owner_user_id,scope,archived_at,revision,created_at,updated_at", orderBy: "id"},
+ {name: "dashboard_versions", columns: "id,dashboard_id,version_number,schema_version,document,change_summary,created_by,created_at", restoreCols: "id,dashboard_id,version_number,schema_version,document,change_summary,created_by,created_at", orderBy: "dashboard_id,version_number"},
+ {name: "events", columns: "id,event_type,severity,entity_id,source_id,occurred_at,received_at,dedup_key,summary,attributes,correlation_id", restoreCols: "id,event_type,severity,entity_id,source_id,occurred_at,received_at,dedup_key,summary,attributes,correlation_id", orderBy: "id"},
+ {name: "audit_events", columns: "id,actor,action,resource_type,resource_id,result,occurred_at,correlation_id,before_diff,after_diff", restoreCols: "id,actor,action,resource_type,resource_id,result,occurred_at,correlation_id,before_diff,after_diff", orderBy: "occurred_at,id"},
+ {name: "job_runs", columns: "id,job_type,job_key,scheduled_at,started_at,completed_at,status,counts,error_code,correlation_id,lease_owner,lease_until", restoreCols: "id,job_type,job_key,scheduled_at,started_at,completed_at,status,counts,error_code,correlation_id,lease_owner,lease_until", orderBy: "scheduled_at,id"},
+ {name: "services", columns: "id,entity_id,source_id,name,description,state,labels,revision,archived_at,created_by,created_at,updated_at", restoreCols: "id,entity_id,source_id,name,description,state,labels,revision,archived_at,created_by,created_at,updated_at", orderBy: "id"},
+ {name: "service_endpoints", columns: "id,service_id,source_id,name,endpoint_type,target,enabled,revision,archived_at,created_at,updated_at", restoreCols: "id,service_id,source_id,name,endpoint_type,target,enabled,revision,archived_at,created_at,updated_at", orderBy: "id"},
+ {name: "probes", columns: "id,service_id,endpoint_id,source_id,name,probe_type,target,interval_seconds,timeout_seconds,enabled,expected_status_codes,follow_redirects,verify_tls,content_assertion,network_policy_id,revision,archived_at,created_by,created_at,updated_at", restoreCols: "id,service_id,endpoint_id,source_id,name,probe_type,target,interval_seconds,timeout_seconds,enabled,expected_status_codes,follow_redirects,verify_tls,content_assertion,network_policy_id,revision,archived_at,created_by,created_at,updated_at", orderBy: "id"},
+ {name: "probe_results", columns: "id,probe_id,source_id,observed_at,completed_at,state,response_time_ms,status_code,error_class,error_message,attributes", restoreCols: "id,probe_id,source_id,observed_at,completed_at,state,response_time_ms,status_code,error_class,error_message,attributes", orderBy: "probe_id,observed_at"},
+ {name: "service_certificates", columns: "id,service_id,endpoint_id,source_id,observed_at,expires_at,issuer,subject,hostname_valid,verification_state,attributes", restoreCols: "id,service_id,endpoint_id,source_id,observed_at,expires_at,issuer,subject,hostname_valid,verification_state,attributes", orderBy: "id"},
+ {name: "service_dependencies", columns: "id,service_id,depends_on_service_id,source_id,relation_type,confidence,confirmed,first_seen_at,last_seen_at,archived_at", restoreCols: "id,service_id,depends_on_service_id,source_id,relation_type,confidence,confirmed,first_seen_at,last_seen_at,archived_at", orderBy: "id"},
+ {name: "service_permissions", columns: "service_id,role_id,permission,created_at", restoreCols: "service_id,role_id,permission,created_at", orderBy: "service_id,role_id,permission"},
+ {name: "alert_rules", columns: "id,schema_version,name,enabled,severity,scope,condition,evaluation_interval_seconds,pending_seconds,resolve_seconds,unknown_behavior,group_by,suppress_when,message,current_version_id,revision,created_by,created_at,updated_at,cooldown_seconds", restoreCols: "id,schema_version,name,enabled,severity,scope,condition,evaluation_interval_seconds,pending_seconds,resolve_seconds,unknown_behavior,group_by,suppress_when,message,revision,created_by,created_at,updated_at,cooldown_seconds", orderBy: "id"},
+ {name: "alert_rule_versions", columns: "id,rule_id,version_number,document,change_summary,created_by,created_at", restoreCols: "id,rule_id,version_number,document,change_summary,created_by,created_at", orderBy: "rule_id,version_number"},
+ {name: "alert_instances", columns: "id,rule_id,rule_version_id,fingerprint,entity_id,current_state,retained_state,active_since,recovery_since,last_evaluated_at,last_known_at,last_value,reason,source_health,acknowledged_by,acknowledged_at,revision,created_at,updated_at,cooldown_until", restoreCols: "id,rule_id,rule_version_id,fingerprint,entity_id,current_state,retained_state,active_since,recovery_since,last_evaluated_at,last_known_at,last_value,reason,source_health,acknowledged_by,acknowledged_at,revision,created_at,updated_at,cooldown_until", orderBy: "id"},
+ {name: "alert_occurrences", columns: "id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at", restoreCols: "id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at", orderBy: "instance_id,observed_at,id"},
+ {name: "alert_silences", columns: "id,name,reason,owner,matchers,starts_at,expires_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision", restoreCols: "id,name,reason,owner,matchers,starts_at,expires_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision", orderBy: "id"},
+ {name: "maintenance_windows", columns: "id,name,reason,selector,starts_at,ends_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision", restoreCols: "id,name,reason,selector,starts_at,ends_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision", orderBy: "id"},
+ {name: "incidents", columns: "id,correlation_key,title,summary,severity,status,started_at,resolved_at,owner_user_id,correlation_method,confidence,revision,created_at,updated_at", restoreCols: "id,correlation_key,title,summary,severity,status,started_at,resolved_at,owner_user_id,correlation_method,confidence,revision,created_at,updated_at", orderBy: "id"},
+ {name: "incident_alerts", columns: "incident_id,alert_id,rationale,confidence,correlation_method,is_manual,added_by,created_at", restoreCols: "incident_id,alert_id,rationale,confidence,correlation_method,is_manual,added_by,created_at", orderBy: "incident_id,alert_id"},
+ {name: "incident_entities", columns: "incident_id,entity_id,rationale,confidence,created_at", restoreCols: "incident_id,entity_id,rationale,confidence,created_at", orderBy: "incident_id,entity_id"},
+ {name: "incident_notes", columns: "id,incident_id,author,body,created_at", restoreCols: "id,incident_id,author,body,created_at", orderBy: "incident_id,created_at,id"},
+}
+
+// backupExcludedTables classifies application tables that deliberately do not
+// belong in a portable archive. The PostgreSQL integration test requires every
+// migrated application table to appear either here or in tableSpecs, so adding a
+// migration cannot silently make restore incomplete.
+var backupExcludedTables = map[string]string{
+ "agent_snapshots": "bounded runtime telemetry is republished by the agent after restart",
+ "capacity_samples": "bounded forecast telemetry is republished by the agent after restart",
+ "notification_channels": "secret references and channel configuration must be reattached",
+ "notification_deliveries": "runtime notification delivery history is intentionally excluded",
+ "notification_outbox": "runtime notification delivery state is intentionally excluded",
+ "system_settings": "runtime configuration and secret-bearing values must be reattached",
+}
+
+func backupExclusions() []string {
+ names := make([]string, 0, len(backupExcludedTables))
+ for name := range backupExcludedTables {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ result := make([]string, 0, len(names))
+ for _, name := range names {
+ result = append(result, name+" ("+backupExcludedTables[name]+")")
+ }
+ return result
+}
+
+func (m Manager) Create(ctx context.Context) (Result, error) {
+ if m.Pool == nil || strings.TrimSpace(m.Directory) == "" {
+ return Result{}, ErrNotConfigured
+ }
+ if err := os.MkdirAll(m.Directory, 0o700); err != nil {
+ return Result{}, fmt.Errorf("create backup directory: %w", err)
+ }
+ now := time.Now().UTC()
+ if m.Now != nil {
+ now = m.Now().UTC()
+ }
+ id, err := newID()
+ if err != nil {
+ return Result{}, fmt.Errorf("generate backup id: %w", err)
+ }
+ schemaVersion, err := currentSchemaVersion(ctx, m.Pool)
+ if err != nil {
+ return Result{}, err
+ }
+ temp, err := os.CreateTemp(m.Directory, ".pulse-backup-*.tmp")
+ if err != nil {
+ return Result{}, fmt.Errorf("create temporary backup: %w", err)
+ }
+ tempName := temp.Name()
+ defer os.Remove(tempName)
+ archive := zip.NewWriter(temp)
+ manifest := Manifest{FormatVersion: formatVersion, BackupID: id, CreatedAt: now, SchemaVersion: schemaVersion, Excluded: backupExclusions()}
+ for _, spec := range tableSpecs {
+ entry, err := archive.Create("data/" + spec.name + ".jsonl")
+ if err != nil {
+ return Result{}, fmt.Errorf("create archive entry %s: %w", spec.name, err)
+ }
+ hash := sha256.New()
+ writer := io.MultiWriter(entry, hash)
+ rows, err := exportTable(ctx, m.Pool, spec, writer)
+ if err != nil {
+ return Result{}, fmt.Errorf("export %s: %w", spec.name, err)
+ }
+ manifest.Tables = append(manifest.Tables, TableManifest{Name: spec.name, File: "data/" + spec.name + ".jsonl", Rows: rows, SHA256: hex.EncodeToString(hash.Sum(nil))})
+ }
+ manifestEntry, err := archive.Create(manifestName)
+ if err != nil {
+ return Result{}, fmt.Errorf("create manifest: %w", err)
+ }
+ if err := json.NewEncoder(manifestEntry).Encode(manifest); err != nil {
+ return Result{}, fmt.Errorf("write manifest: %w", err)
+ }
+ if err := archive.Close(); err != nil {
+ return Result{}, fmt.Errorf("close backup archive: %w", err)
+ }
+ if err := temp.Sync(); err != nil {
+ return Result{}, fmt.Errorf("sync backup archive: %w", err)
+ }
+ if err := temp.Close(); err != nil {
+ return Result{}, fmt.Errorf("close backup file: %w", err)
+ }
+ finalPath := filepath.Join(m.Directory, "pulse-backup-"+id+".zip")
+ if err := os.Rename(tempName, finalPath); err != nil {
+ return Result{}, fmt.Errorf("finalize backup: %w", err)
+ }
+ checksum, size, err := fileChecksum(finalPath)
+ if err != nil {
+ return Result{}, err
+ }
+ if err := os.WriteFile(finalPath+".sha256", []byte(checksum+" "+filepath.Base(finalPath)+"\n"), 0o600); err != nil {
+ return Result{}, fmt.Errorf("write backup checksum: %w", err)
+ }
+ if err := m.prune(ctx, finalPath); err != nil {
+ return Result{}, err
+ }
+ var rows int64
+ for _, table := range manifest.Tables {
+ rows += table.Rows
+ }
+ return Result{BackupID: id, Path: finalPath, SHA256: checksum, Bytes: size, Rows: rows, Created: now}, nil
+}
+
+func (m Manager) Verify(ctx context.Context, path string) (Manifest, error) {
+ if strings.TrimSpace(path) == "" {
+ return Manifest{}, errors.New("backup path is required")
+ }
+ archive, err := zip.OpenReader(path)
+ if err != nil {
+ return Manifest{}, fmt.Errorf("open backup: %w", err)
+ }
+ defer archive.Close()
+ entries := make(map[string]*zip.File, len(archive.File))
+ for _, entry := range archive.File {
+ if _, exists := entries[entry.Name]; exists {
+ return Manifest{}, fmt.Errorf("duplicate archive entry %q", entry.Name)
+ }
+ entries[entry.Name] = entry
+ }
+ manifestFile, ok := entries[manifestName]
+ if !ok {
+ return Manifest{}, errors.New("backup manifest is missing")
+ }
+ manifestReader, err := manifestFile.Open()
+ if err != nil {
+ return Manifest{}, fmt.Errorf("open manifest: %w", err)
+ }
+ var manifest Manifest
+ err = json.NewDecoder(manifestReader).Decode(&manifest)
+ _ = manifestReader.Close()
+ if err != nil || manifest.FormatVersion != formatVersion || manifest.BackupID == "" || manifest.SchemaVersion <= 0 {
+ return Manifest{}, errors.New("backup manifest is invalid")
+ }
+ if len(manifest.Tables) != len(tableSpecs) {
+ return Manifest{}, fmt.Errorf("backup table set is incomplete: got %d, want %d", len(manifest.Tables), len(tableSpecs))
+ }
+ expectedFiles := map[string]bool{manifestName: true}
+ for _, spec := range tableSpecs {
+ expectedFiles["data/"+spec.name+".jsonl"] = true
+ }
+ for name := range entries {
+ if !expectedFiles[name] {
+ return Manifest{}, fmt.Errorf("unexpected backup archive entry %q", name)
+ }
+ }
+ seen := make(map[string]bool, len(manifest.Tables))
+ for _, table := range manifest.Tables {
+ if seen[table.Name] || table.Rows < 0 || table.SHA256 == "" || table.File != "data/"+table.Name+".jsonl" || !expectedFiles[table.File] {
+ return Manifest{}, fmt.Errorf("backup table entry %q is invalid", table.Name)
+ }
+ seen[table.Name] = true
+ entry, ok := entries[table.File]
+ if !ok {
+ return Manifest{}, fmt.Errorf("backup table file %q is missing", table.File)
+ }
+ if err := verifyTable(entry, table); err != nil {
+ return Manifest{}, err
+ }
+ }
+ for _, spec := range tableSpecs {
+ if !seen[spec.name] {
+ return Manifest{}, fmt.Errorf("backup table %q is missing", spec.name)
+ }
+ }
+ checksum, _, err := fileChecksum(path)
+ if err != nil {
+ return Manifest{}, err
+ }
+ sidecar, err := os.ReadFile(path + ".sha256")
+ if err != nil {
+ return Manifest{}, fmt.Errorf("read backup checksum: %w", err)
+ }
+ if !strings.HasPrefix(string(sidecar), checksum+" ") {
+ return Manifest{}, errors.New("backup archive checksum does not match sidecar")
+ }
+ return manifest, nil
+}
+
+func (m Manager) List(ctx context.Context) ([]Result, error) {
+ if strings.TrimSpace(m.Directory) == "" {
+ return nil, ErrNotConfigured
+ }
+ entries, err := os.ReadDir(m.Directory)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []Result{}, nil
+ }
+ return nil, fmt.Errorf("list backups: %w", err)
+ }
+ results := make([]Result, 0)
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pulse-backup-") || !strings.HasSuffix(entry.Name(), ".zip") {
+ continue
+ }
+ path := filepath.Join(m.Directory, entry.Name())
+ manifest, err := m.Verify(ctx, path)
+ if err != nil {
+ return nil, fmt.Errorf("verify listed backup %s: %w", entry.Name(), err)
+ }
+ checksum, size, err := fileChecksum(path)
+ if err != nil {
+ return nil, err
+ }
+ var rows int64
+ for _, table := range manifest.Tables {
+ rows += table.Rows
+ }
+ results = append(results, Result{BackupID: manifest.BackupID, Path: path, SHA256: checksum, Bytes: size, Rows: rows, Created: manifest.CreatedAt})
+ }
+ sort.Slice(results, func(i, j int) bool { return results[i].Created.After(results[j].Created) })
+ return results, nil
+}
+func (m Manager) Restore(ctx context.Context, path string) (Manifest, error) {
+ if m.Pool == nil {
+ return Manifest{}, ErrNotConfigured
+ }
+ manifest, err := m.Verify(ctx, path)
+ if err != nil {
+ return Manifest{}, err
+ }
+ for _, spec := range tableSpecs {
+ var count int64
+ if err := m.Pool.QueryRow(ctx, "SELECT count(*) FROM "+spec.name).Scan(&count); err != nil {
+ return Manifest{}, fmt.Errorf("check restore target %s: %w", spec.name, err)
+ }
+ if count != 0 {
+ return Manifest{}, fmt.Errorf("restore target is not empty: %s has %d rows", spec.name, count)
+ }
+ }
+ archive, err := zip.OpenReader(path)
+ if err != nil {
+ return Manifest{}, fmt.Errorf("open restore archive: %w", err)
+ }
+ defer archive.Close()
+ entries := make(map[string]*zip.File, len(archive.File))
+ for _, entry := range archive.File {
+ entries[entry.Name] = entry
+ }
+ tx, err := m.Pool.Begin(ctx)
+ if err != nil {
+ return Manifest{}, fmt.Errorf("begin restore: %w", err)
+ }
+ defer tx.Rollback(ctx)
+ for _, spec := range tableSpecs {
+ entry := entries["data/"+spec.name+".jsonl"]
+ reader, err := entry.Open()
+ if err != nil {
+ return Manifest{}, fmt.Errorf("open restore table %s: %w", spec.name, err)
+ }
+ decoder := json.NewDecoder(bufio.NewReader(reader))
+ for {
+ var row json.RawMessage
+ if err := decoder.Decode(&row); errors.Is(err, io.EOF) {
+ break
+ } else if err != nil {
+ _ = reader.Close()
+ return Manifest{}, fmt.Errorf("decode restore table %s: %w", spec.name, err)
+ }
+ selectCols := spec.restoreCols
+ if spec.name == "alert_instances" {
+ selectCols = strings.Replace(selectCols, "last_value", "COALESCE(last_value, 'null'::jsonb)", 1)
+ } else if spec.name == "alert_occurrences" {
+ selectCols = strings.Replace(selectCols, "value", "COALESCE(value, 'null'::jsonb)", 1)
+ }
+ query := "INSERT INTO " + spec.name + " (" + spec.restoreCols + ") SELECT " + selectCols + " FROM jsonb_populate_record(NULL::" + spec.name + ", $1::jsonb)"
+ if _, err := tx.Exec(ctx, query, []byte(row)); err != nil {
+ _ = reader.Close()
+ return Manifest{}, fmt.Errorf("restore %s: %w", spec.name, err)
+ }
+ }
+ _ = reader.Close()
+ }
+ for _, deferred := range []struct{ table, id, column string }{{"dashboards", "id", "current_version_id"}, {"alert_rules", "id", "current_version_id"}} {
+ entry := entries["data/"+deferred.table+".jsonl"]
+ reader, err := entry.Open()
+ if err != nil {
+ return Manifest{}, fmt.Errorf("open deferred restore %s: %w", deferred.table, err)
+ }
+ decoder := json.NewDecoder(bufio.NewReader(reader))
+ for {
+ var row map[string]json.RawMessage
+ if err := decoder.Decode(&row); errors.Is(err, io.EOF) {
+ break
+ } else if err != nil {
+ _ = reader.Close()
+ return Manifest{}, fmt.Errorf("decode deferred restore %s: %w", deferred.table, err)
+ }
+ id, ok := row[deferred.id]
+ value, valueOK := row[deferred.column]
+ if !ok || !valueOK || string(value) == "null" {
+ continue
+ }
+ var valueID, rowID string
+ if err := json.Unmarshal(value, &valueID); err != nil {
+ _ = reader.Close()
+ return Manifest{}, fmt.Errorf("decode deferred %s id: %w", deferred.table, err)
+ }
+ if err := json.Unmarshal(id, &rowID); err != nil {
+ _ = reader.Close()
+ return Manifest{}, fmt.Errorf("decode deferred %s row id: %w", deferred.table, err)
+ }
+ if _, err := tx.Exec(ctx, "UPDATE "+deferred.table+" SET "+deferred.column+"=$1::uuid WHERE "+deferred.id+"=$2::uuid", valueID, rowID); err != nil {
+ _ = reader.Close()
+ return Manifest{}, fmt.Errorf("restore deferred %s: %w", deferred.table, err)
+ }
+ }
+ _ = reader.Close()
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return Manifest{}, fmt.Errorf("commit restore: %w", err)
+ }
+ return manifest, nil
+}
+
+func exportTable(ctx context.Context, pool *pgxpool.Pool, spec tableSpec, writer io.Writer) (int64, error) {
+ rows, err := pool.Query(ctx, "SELECT row_to_json(t) FROM (SELECT "+spec.columns+" FROM "+spec.name+" ORDER BY "+spec.orderBy+") t")
+ if err != nil {
+ return 0, err
+ }
+ defer rows.Close()
+ var count int64
+ for rows.Next() {
+ var raw []byte
+ if err := rows.Scan(&raw); err != nil {
+ return 0, err
+ }
+ if containsSensitiveKey(raw) {
+ return 0, fmt.Errorf("sensitive key detected in %s export", spec.name)
+ }
+ if _, err := writer.Write(append(raw, '\n')); err != nil {
+ return 0, err
+ }
+ count++
+ }
+ return count, rows.Err()
+}
+
+func verifyTable(entry *zip.File, expected TableManifest) error {
+ reader, err := entry.Open()
+ if err != nil {
+ return fmt.Errorf("open table %s: %w", expected.Name, err)
+ }
+ defer reader.Close()
+ hash := sha256.New()
+ decoder := json.NewDecoder(io.TeeReader(reader, hash))
+ var rows int64
+ for {
+ var raw json.RawMessage
+ if err := decoder.Decode(&raw); errors.Is(err, io.EOF) {
+ break
+ } else if err != nil {
+ return fmt.Errorf("validate table %s: %w", expected.Name, err)
+ }
+ if containsSensitiveKey(raw) {
+ return fmt.Errorf("sensitive key detected in %s archive", expected.Name)
+ }
+ rows++
+ }
+ if rows != expected.Rows || hex.EncodeToString(hash.Sum(nil)) != expected.SHA256 {
+ return fmt.Errorf("table %s checksum or row count mismatch", expected.Name)
+ }
+ return nil
+}
+
+func currentSchemaVersion(ctx context.Context, pool *pgxpool.Pool) (int64, error) {
+ var version int64
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&version); err != nil {
+ return 0, fmt.Errorf("read schema version: %w", err)
+ }
+ return version, nil
+}
+
+func fileChecksum(path string) (string, int64, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return "", 0, fmt.Errorf("open backup for checksum: %w", err)
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return "", 0, fmt.Errorf("stat backup: %w", err)
+ }
+ hash := sha256.New()
+ if _, err := io.Copy(hash, file); err != nil {
+ return "", 0, fmt.Errorf("checksum backup: %w", err)
+ }
+ return hex.EncodeToString(hash.Sum(nil)), info.Size(), nil
+}
+
+func (m Manager) prune(ctx context.Context, keepPath string) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ retention := m.Retention
+ if retention <= 0 {
+ retention = defaultRetention
+ }
+ entries, err := os.ReadDir(m.Directory)
+ if err != nil {
+ return fmt.Errorf("list backups: %w", err)
+ }
+ var archives []os.DirEntry
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasPrefix(entry.Name(), "pulse-backup-") && strings.HasSuffix(entry.Name(), ".zip") {
+ archives = append(archives, entry)
+ }
+ }
+ sort.Slice(archives, func(i, j int) bool { return archives[i].Name() > archives[j].Name() })
+ if len(archives) <= retention {
+ return nil
+ }
+ for _, entry := range archives[retention:] {
+ path := filepath.Join(m.Directory, entry.Name())
+ if path == keepPath {
+ continue
+ }
+ if err := os.Remove(path); err != nil {
+ return fmt.Errorf("prune backup %s: %w", entry.Name(), err)
+ }
+ _ = os.Remove(path + ".sha256")
+ }
+ return nil
+}
+
+func containsSensitiveKey(raw []byte) bool {
+ var value any
+ if json.Unmarshal(raw, &value) != nil {
+ return true
+ }
+ return sensitiveValue(value)
+}
+
+func sensitiveValue(value any) bool {
+ switch typed := value.(type) {
+ case map[string]any:
+ for key, nested := range typed {
+ lower := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), " ", "_"))
+ for _, part := range []string{"authorization", "cookie", "password", "passwd", "secret", "token", "api_key", "apikey", "client_secret"} {
+ if strings.Contains(lower, part) {
+ return true
+ }
+ }
+ if sensitiveValue(nested) {
+ return true
+ }
+ }
+ case []any:
+ for _, nested := range typed {
+ if sensitiveValue(nested) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func newID() (string, error) {
+ var bytes [16]byte
+ if _, err := rand.Read(bytes[:]); err != nil {
+ return "", err
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%s-%s-%s-%s-%s", hex.EncodeToString(bytes[0:4]), hex.EncodeToString(bytes[4:6]), hex.EncodeToString(bytes[6:8]), hex.EncodeToString(bytes[8:10]), hex.EncodeToString(bytes[10:16])), nil
+}
diff --git a/internal/backup/manager_integration_test.go b/internal/backup/manager_integration_test.go
new file mode 100644
index 0000000..ec46c1b
--- /dev/null
+++ b/internal/backup/manager_integration_test.go
@@ -0,0 +1,225 @@
+package backup
+
+import (
+ "context"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func TestPostgreSQLBackupRestoreCleanRoom(t *testing.T) {
+ sourceDSN := os.Getenv("PULSE_TEST_DATABASE_URL")
+ targetDSN := os.Getenv("PULSE_TEST_RESTORE_DATABASE_URL")
+ if sourceDSN == "" || targetDSN == "" {
+ if os.Getenv("PULSE_REQUIRE_BACKUP_INTEGRATION") == "true" {
+ t.Fatal("backup integration is required but both PostgreSQL DSNs are not configured")
+ }
+ t.Skip("PULSE_TEST_DATABASE_URL and PULSE_TEST_RESTORE_DATABASE_URL are required")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+ source, err := database.NewPool(ctx, database.Config{URL: sourceDSN})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer source.Close()
+ target, err := database.NewPool(ctx, database.Config{URL: targetDSN})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer target.Close()
+ if err := database.Migrate(ctx, source); err != nil {
+ t.Fatal(err)
+ }
+ if err := database.Migrate(ctx, target); err != nil {
+ t.Fatal(err)
+ }
+ assertBackupSchemaCoverage(t, ctx, source)
+ ids := testIDs()
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ seed := []struct {
+ query string
+ args []any
+ }{
+ {`INSERT INTO roles (id,name) VALUES ($1,'administrator')`, []any{ids.role}},
+ {`INSERT INTO users (id,external_subject,display_name,email) VALUES ($1,$2,'Backup Test','backup@example.invalid')`, []any{ids.user, ids.user}},
+ {`INSERT INTO user_roles (user_id,role_id) VALUES ($1,$2)`, []any{ids.user, ids.role}},
+ {`INSERT INTO data_sources (id,type,name,configuration_ref) VALUES ($1,'exporter','Backup source','source/ref')`, []any{ids.source}},
+ {`INSERT INTO entities (id,entity_type,canonical_name,display_name,first_seen_at) VALUES ($1,'container','backup-test','Backup test',$2)`, []any{ids.entity, now}},
+ {`INSERT INTO container_aliases (source_id,runtime_id,entity_id,name,project,service,image_digest,observed_state,observed_health,restart_count,first_seen_at,last_seen_at) VALUES ($1,'runtime-backup-test',$2,'backup-test','pulse','api','sha256:test','running','healthy',2,$3,$3)`, []any{ids.source, ids.entity, now}},
+ {`INSERT INTO dashboards (id,slug,name,description,owner_user_id,scope,current_version_id) VALUES ($1,'backup-test','Backup test dashboard','portable',$2,'shared',NULL)`, []any{ids.dashboard, ids.user}},
+ {`INSERT INTO dashboard_versions (id,dashboard_id,version_number,schema_version,document,created_by) VALUES ($1,$2,1,1,'{}',$3)`, []any{ids.dashboardVersion, ids.dashboard, ids.user}},
+ {`UPDATE dashboards SET current_version_id=$1 WHERE id=$2`, []any{ids.dashboardVersion, ids.dashboard}},
+ {`INSERT INTO alert_rules (id,schema_version,name,severity,condition,evaluation_interval_seconds,pending_seconds,resolve_seconds,unknown_behavior,group_by,suppress_when,message,current_version_id,created_by) VALUES ($1,1,'Backup test rule','critical','{}',60,0,0,'become-unknown','[]','[]','{}',NULL,$2)`, []any{ids.rule, ids.user}},
+ {`INSERT INTO alert_rule_versions (id,rule_id,version_number,document,created_by) VALUES ($1,$2,1,'{}',$3)`, []any{ids.ruleVersion, ids.rule, ids.user}},
+ {`UPDATE alert_rules SET current_version_id=$1 WHERE id=$2`, []any{ids.ruleVersion, ids.rule}},
+ {`INSERT INTO alert_instances (id,rule_id,rule_version_id,fingerprint,entity_id,last_evaluated_at) VALUES ($1,$2,$3,'backup-fingerprint',$4,$5)`, []any{ids.instance, ids.rule, ids.ruleVersion, ids.entity, now}},
+ {`INSERT INTO incidents (id,correlation_key,title,severity,started_at,owner_user_id,correlation_method,confidence) VALUES ($1,'backup-correlation','Backup test incident','critical',$2,$3,'deterministic',0.900)`, []any{ids.incident, now, ids.user}},
+ {`INSERT INTO incident_alerts (incident_id,alert_id,rationale,confidence,correlation_method,added_by) VALUES ($1,$2,'backup test rationale',0.900,'deterministic','test')`, []any{ids.incident, ids.instance}},
+ {`INSERT INTO incident_entities (incident_id,entity_id,rationale,confidence) VALUES ($1,$2,'backup test entity',0.900)`, []any{ids.incident, ids.entity}},
+ {`INSERT INTO incident_notes (id,incident_id,author,body) VALUES ($1,$2,'test','backup note')`, []any{ids.note, ids.incident}},
+ {`INSERT INTO audit_events (id,actor,action,resource_type,resource_id,result,after_diff) VALUES ($1,'backup-test','backup.seed','dashboard',$2,'success','{}')`, []any{ids.audit, ids.dashboard}},
+ }
+ for _, statement := range seed {
+ if _, err := source.Exec(ctx, statement.query, statement.args...); err != nil {
+ t.Fatal(err)
+ }
+ }
+ directory := t.TempDir()
+ manager := Manager{Pool: source, Directory: directory, Retention: 2, Now: func() time.Time { return now }}
+ created, err := manager.Create(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.Rows <= 0 || created.SHA256 == "" {
+ t.Fatalf("unexpected backup result: %#v", created)
+ }
+ verified, err := manager.Verify(ctx, created.Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if verified.FormatVersion != formatVersion {
+ t.Fatalf("backup format = %d, want %d", verified.FormatVersion, formatVersion)
+ }
+ containerAliasManifest := false
+ for _, table := range verified.Tables {
+ if table.Name == "container_aliases" && table.Rows == 1 && table.SHA256 != "" {
+ containerAliasManifest = true
+ }
+ }
+ if !containerAliasManifest {
+ t.Fatal("container_aliases is missing from the checksummed manifest")
+ }
+ if _, err := manager.List(ctx); err != nil {
+ t.Fatal(err)
+ }
+ restored, err := (Manager{Pool: target}).Restore(ctx, created.Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if restored.BackupID != created.BackupID {
+ t.Fatalf("restored manifest = %s, want %s", restored.BackupID, created.BackupID)
+ }
+ for _, check := range []struct {
+ table string
+ want int
+ }{
+ {"container_aliases", 1}, {"dashboards", 1}, {"dashboard_versions", 1}, {"alert_rules", 1}, {"alert_rule_versions", 1}, {"incidents", 1}, {"incident_alerts", 1}, {"incident_entities", 1}, {"incident_notes", 1}, {"audit_events", 1},
+ } {
+ var got int
+ if err := target.QueryRow(ctx, "SELECT count(*) FROM "+check.table).Scan(&got); err != nil {
+ t.Fatal(err)
+ }
+ if got != check.want {
+ t.Fatalf("%s count = %d, want %d", check.table, got, check.want)
+ }
+ }
+ var dashboardVersion, ruleVersion string
+ if err := target.QueryRow(ctx, `SELECT current_version_id::text FROM dashboards WHERE id=$1`, ids.dashboard).Scan(&dashboardVersion); err != nil {
+ t.Fatal(err)
+ }
+ if err := target.QueryRow(ctx, `SELECT current_version_id::text FROM alert_rules WHERE id=$1`, ids.rule).Scan(&ruleVersion); err != nil {
+ t.Fatal(err)
+ }
+ if dashboardVersion != ids.dashboardVersion || ruleVersion != ids.ruleVersion {
+ t.Fatalf("deferred links = %s/%s", dashboardVersion, ruleVersion)
+ }
+ var runtimeEntity string
+ if err := target.QueryRow(ctx, `SELECT entity_id::text FROM container_aliases WHERE source_id=$1 AND runtime_id='runtime-backup-test'`, ids.source).Scan(&runtimeEntity); err != nil {
+ t.Fatal(err)
+ }
+ if runtimeEntity != ids.entity {
+ t.Fatalf("restored container alias entity = %s, want %s", runtimeEntity, ids.entity)
+ }
+}
+
+func assertBackupSchemaCoverage(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
+ t.Helper()
+ rows, err := pool.Query(ctx, `SELECT c.table_name, c.column_name
+ FROM information_schema.columns c
+ JOIN information_schema.tables t
+ ON t.table_schema = c.table_schema AND t.table_name = c.table_name
+ WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE'
+ ORDER BY c.table_name, c.ordinal_position`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ columnsByTable := map[string]map[string]bool{}
+ for rows.Next() {
+ var table, column string
+ if err := rows.Scan(&table, &column); err != nil {
+ t.Fatal(err)
+ }
+ if columnsByTable[table] == nil {
+ columnsByTable[table] = map[string]bool{}
+ }
+ columnsByTable[table][column] = true
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+
+ classified := map[string]string{"schema_migrations": "migration metadata"}
+ for name, reason := range backupExcludedTables {
+ classified[name] = reason
+ }
+ for _, spec := range tableSpecs {
+ if previous, duplicate := classified[spec.name]; duplicate {
+ t.Fatalf("backup table %q is classified more than once (previous: %s)", spec.name, previous)
+ }
+ classified[spec.name] = "portable backup"
+ tableColumns, exists := columnsByTable[spec.name]
+ if !exists {
+ t.Fatalf("backup table %q does not exist after migrations", spec.name)
+ }
+ for _, list := range []string{spec.columns, spec.restoreCols} {
+ for _, column := range strings.Split(list, ",") {
+ column = strings.TrimSpace(column)
+ if column == "" || !tableColumns[column] {
+ t.Fatalf("backup table %q references missing column %q", spec.name, column)
+ }
+ }
+ }
+ }
+
+ var unclassified, missing []string
+ for table := range columnsByTable {
+ if _, ok := classified[table]; !ok {
+ unclassified = append(unclassified, table)
+ }
+ }
+ for table := range classified {
+ if _, ok := columnsByTable[table]; !ok {
+ missing = append(missing, table)
+ }
+ }
+ sort.Strings(unclassified)
+ sort.Strings(missing)
+ if len(unclassified) > 0 || len(missing) > 0 {
+ t.Fatalf("backup schema drift: unclassified=%v missing=%v", unclassified, missing)
+ }
+}
+
+type testIDSet struct {
+ role, user, source, entity, dashboard, dashboardVersion, rule, ruleVersion, instance, incident, note, audit string
+}
+
+func testIDs() testIDSet {
+ id := func() string {
+ value, err := newID()
+ if err != nil {
+ panic(err)
+ }
+ return value
+ }
+ return testIDSet{
+ role: id(), user: id(), source: id(), entity: id(), dashboard: id(), dashboardVersion: id(),
+ rule: id(), ruleVersion: id(), instance: id(), incident: id(), note: id(), audit: id(),
+ }
+}
diff --git a/internal/backup/manager_test.go b/internal/backup/manager_test.go
new file mode 100644
index 0000000..88131a3
--- /dev/null
+++ b/internal/backup/manager_test.go
@@ -0,0 +1,70 @@
+package backup
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSensitiveArchiveKeysAreRejected(t *testing.T) {
+ for _, raw := range []string{`{"before_diff":{"api_token":"value"}}`, `{"configuration":{"password":"value"}}`, `{"authorization":"Bearer value"}`} {
+ if !containsSensitiveKey([]byte(raw)) {
+ t.Fatalf("sensitive key was not detected in %s", raw)
+ }
+ }
+ if containsSensitiveKey([]byte(`{"display_name":"Pulse","configuration_ref":"source/ref"}`)) {
+ t.Fatal("safe reference fields were rejected")
+ }
+}
+
+func TestManagerListReturnsEmptyForMissingDirectory(t *testing.T) {
+ directory := filepath.Join(t.TempDir(), "backups")
+ results, err := (Manager{Directory: directory}).List(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(results) != 0 {
+ t.Fatalf("results = %d, want 0", len(results))
+ }
+}
+
+func TestManagerCreateRequiresConfiguredPoolAndDirectory(t *testing.T) {
+ if _, err := (Manager{}).Create(context.Background()); err != ErrNotConfigured {
+ t.Fatalf("error = %v, want ErrNotConfigured", err)
+ }
+}
+
+func TestPruneKeepsConfiguredRetentionAndSidecars(t *testing.T) {
+ directory := t.TempDir()
+ for _, name := range []string{"pulse-backup-00000001.zip", "pulse-backup-00000002.zip", "pulse-backup-00000003.zip"} {
+ if err := os.WriteFile(filepath.Join(directory, name), []byte(name), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(directory, name+".sha256"), []byte("checksum"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := (Manager{Directory: directory, Retention: 2}).prune(context.Background(), filepath.Join(directory, "pulse-backup-00000003.zip")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(directory, "pulse-backup-00000001.zip")); !os.IsNotExist(err) {
+ t.Fatalf("old backup still exists: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(directory, "pulse-backup-00000001.zip.sha256")); !os.IsNotExist(err) {
+ t.Fatalf("old sidecar still exists: %v", err)
+ }
+ if strings.TrimSpace(string(mustRead(t, filepath.Join(directory, "pulse-backup-00000003.zip.sha256")))) != "checksum" {
+ t.Fatal("kept sidecar was changed")
+ }
+}
+
+func mustRead(t *testing.T, path string) []byte {
+ t.Helper()
+ value, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return value
+}
diff --git a/internal/backupapi/handler.go b/internal/backupapi/handler.go
new file mode 100644
index 0000000..a9b74ec
--- /dev/null
+++ b/internal/backupapi/handler.go
@@ -0,0 +1,98 @@
+package backupapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/backup"
+)
+
+type Handler struct {
+ Manager *backup.Manager
+ Audit func(context.Context, string, string) error
+ // OnCreated invalidates derived status caches after the archive and its
+ // checksum have both been written successfully.
+ OnCreated func()
+}
+
+type publicResult struct {
+ BackupID string `json:"backupId"`
+ SHA256 string `json:"sha256"`
+ Bytes int64 `json:"bytes"`
+ Rows int64 `json:"rows"`
+ Created time.Time `json:"createdAt"`
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if h.Manager == nil {
+ writeError(w, http.StatusServiceUnavailable, "BACKUP_UNAVAILABLE", "Backup is not configured")
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ if r.URL.Path != "/api/v1/system/backups" {
+ writeError(w, http.StatusNotFound, "NOT_FOUND", "Not found")
+ return
+ }
+ result, err := h.Manager.List(r.Context())
+ if err != nil {
+ writeError(w, http.StatusServiceUnavailable, "BACKUP_UNAVAILABLE", "Backups are not available")
+ return
+ }
+ public := make([]publicResult, 0, len(result))
+ for _, item := range result {
+ public = append(public, toPublic(item))
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"backups": public})
+ case http.MethodPost:
+ if r.URL.Path != "/api/v1/system/backups" {
+ writeError(w, http.StatusNotFound, "NOT_FOUND", "Not found")
+ return
+ }
+ result, err := h.Manager.Create(r.Context())
+ principal, _ := auth.PrincipalFromContext(r.Context())
+ if err != nil {
+ if h.Audit != nil {
+ _ = h.Audit(r.Context(), principal.Subject, "failure")
+ }
+ status := http.StatusInternalServerError
+ code := "BACKUP_FAILED"
+ if errors.Is(err, backup.ErrNotConfigured) {
+ status = http.StatusServiceUnavailable
+ code = "BACKUP_UNAVAILABLE"
+ }
+ writeError(w, status, code, "Backup could not be created")
+ return
+ }
+ if h.Audit != nil {
+ _ = h.Audit(r.Context(), principal.Subject, "success")
+ }
+ if h.OnCreated != nil {
+ h.OnCreated()
+ }
+ writeJSON(w, http.StatusCreated, toPublic(result))
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed")
+ }
+}
+
+func toPublic(result backup.Result) publicResult {
+ return publicResult{BackupID: result.BackupID, SHA256: result.SHA256, Bytes: result.Bytes, Rows: result.Rows, Created: result.Created}
+}
+
+func writeJSON(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, no-store")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
+
+func writeError(w http.ResponseWriter, status int, code, detail string) {
+ writeJSON(w, status, map[string]string{"code": code, "detail": detail})
+}
+
+var _ http.Handler = Handler{}
diff --git a/internal/backupapi/handler_test.go b/internal/backupapi/handler_test.go
new file mode 100644
index 0000000..794c78c
--- /dev/null
+++ b/internal/backupapi/handler_test.go
@@ -0,0 +1,44 @@
+package backupapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/itworx/pulse/internal/backup"
+)
+
+func TestHandlerRejectsUnconfiguredBackupWithoutDisclosure(t *testing.T) {
+ handler := Handler{}
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/system/backups", nil)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != http.StatusServiceUnavailable || !strings.Contains(response.Body.String(), "BACKUP_UNAVAILABLE") {
+ t.Fatalf("response = %d %s", response.Code, response.Body.String())
+ }
+ if strings.Contains(response.Body.String(), "PULSE_") || strings.Contains(response.Body.String(), "password") {
+ t.Fatal("configuration detail leaked")
+ }
+}
+
+func TestHandlerRejectsUnsupportedMethod(t *testing.T) {
+ handler := Handler{Manager: &backup.Manager{}}
+ request := httptest.NewRequest(http.MethodDelete, "/api/v1/system/backups", nil)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("response = %d, want method not allowed", response.Code)
+ }
+}
+
+func TestPublicResultOmitsServerPath(t *testing.T) {
+ payload, err := json.Marshal(toPublic(backup.Result{BackupID: "id", Path: "C:/private/backups/pulse.zip", SHA256: "checksum"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(payload), "private/backups") || strings.Contains(string(payload), "path") {
+ t.Fatalf("server path leaked: %s", payload)
+ }
+}
diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go
new file mode 100644
index 0000000..8773723
--- /dev/null
+++ b/internal/buildinfo/buildinfo.go
@@ -0,0 +1,19 @@
+package buildinfo
+
+import "time"
+
+// These values are replaced with -ldflags by reproducible release builds.
+var (
+ Version = "development"
+ Commit = "unknown"
+ BuildTime = "unknown"
+)
+
+func BuiltAt() *time.Time {
+ value, err := time.Parse(time.RFC3339, BuildTime)
+ if err != nil {
+ return nil
+ }
+ value = value.UTC()
+ return &value
+}
diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go
new file mode 100644
index 0000000..ef66e3a
--- /dev/null
+++ b/internal/buildinfo/buildinfo_test.go
@@ -0,0 +1,25 @@
+package buildinfo
+
+import (
+ "testing"
+ "time"
+)
+
+func TestVersionIsNonEmpty(t *testing.T) {
+ if Version == "" {
+ t.Fatal("version must not be empty")
+ }
+}
+
+func TestBuiltAtRejectsPlaceholderAndNormalizesUTC(t *testing.T) {
+ original := BuildTime
+ t.Cleanup(func() { BuildTime = original })
+ BuildTime = "unknown"
+ if BuiltAt() != nil {
+ t.Fatal("placeholder build time must be absent")
+ }
+ BuildTime = "2026-08-12T04:00:00+02:00"
+ if got := BuiltAt(); got == nil || got.Format(time.RFC3339) != "2026-08-12T02:00:00Z" {
+ t.Fatalf("built at = %v", got)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..4964ba9
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,466 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "net/netip"
+ "net/url"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Environment string
+
+const (
+ Development Environment = "development"
+ Test Environment = "test"
+ Production Environment = "production"
+)
+
+type Config struct {
+ Environment Environment
+ Timezone string
+ DefaultLocale string
+ LogLevel string
+ PublicURL string
+ DatabaseURL string
+ PrometheusURL string
+ PrometheusTimeout time.Duration
+ UnraidURL string
+ UnraidAPIToken string
+ AuthMode string
+ OIDCIssuer string
+ OIDCClientID string
+ OIDCClientSecret string
+ OIDCRedirectURL string
+ OIDCGroupsClaim string
+ OIDCRoleMapping map[string]string
+ SessionIdleTTL time.Duration
+ SessionAbsoluteTTL time.Duration
+ BreakGlassEnabled bool
+ BackupDirectory string
+ BackupRetention int
+ // ContainerSourceID is the data_sources UUID the worker attributes container
+ // discovery to. Discovery stays disabled until a source is registered, so no
+ // inventory is ever written against an unknown origin.
+ ContainerSourceID string
+ // ProbeAllowedNetworks are the private/loopback CIDRs service probes may
+ // reach. The probe network policy blocks private space unless it is
+ // explicitly allowlisted here; link-local, multicast and cloud metadata
+ // addresses stay blocked regardless.
+ ProbeAllowedNetworks []string
+ NotificationWebhookURL string
+ NotificationWebhookToken string
+ NotificationWebhookTimeout time.Duration
+}
+
+type ValidationError struct {
+ Fields []string
+}
+
+func (e *ValidationError) Error() string {
+ return "invalid configuration: " + strings.Join(e.Fields, "; ")
+}
+
+func Load() (Config, error) {
+ return LoadFrom(os.LookupEnv)
+}
+
+func LoadFrom(lookup func(string) (string, bool)) (Config, error) {
+ config, err := parseFrom(lookup)
+ if err != nil {
+ return Config{}, err
+ }
+ return config, Validate(config)
+}
+
+// LoadWorker loads only the settings consumed by the background worker. API
+// authentication credentials are intentionally not part of that container's
+// privilege boundary.
+func LoadWorker() (Config, error) {
+ return LoadWorkerFrom(os.LookupEnv)
+}
+
+func LoadWorkerFrom(lookup func(string) (string, bool)) (Config, error) {
+ config, err := parseFrom(lookup)
+ if err != nil {
+ return Config{}, err
+ }
+ return config, ValidateWorker(config)
+}
+
+func parseFrom(lookup func(string) (string, bool)) (Config, error) {
+ get := func(key, fallback string) string {
+ if value, ok := lookup(key); ok {
+ return value
+ }
+ return fallback
+ }
+ config := Config{
+ Environment: Environment(get("PULSE_ENV", string(Development))),
+ Timezone: get("PULSE_TIMEZONE", "Europe/Brussels"),
+ DefaultLocale: get("PULSE_DEFAULT_LOCALE", "nl-BE"),
+ LogLevel: get("PULSE_LOG_LEVEL", "info"),
+ PublicURL: get("PULSE_PUBLIC_URL", ""),
+ DatabaseURL: get("PULSE_DATABASE_URL", ""),
+ PrometheusURL: get("PULSE_PROMETHEUS_URL", ""),
+ UnraidURL: get("PULSE_UNRAID_URL", ""),
+ UnraidAPIToken: get("PULSE_UNRAID_API_TOKEN", ""),
+ AuthMode: get("PULSE_AUTH_MODE", "oidc"),
+ OIDCIssuer: get("PULSE_OIDC_ISSUER", ""),
+ OIDCClientID: get("PULSE_OIDC_CLIENT_ID", ""),
+ OIDCClientSecret: get("PULSE_OIDC_CLIENT_SECRET", ""),
+ OIDCRedirectURL: get("PULSE_OIDC_REDIRECT_URL", ""),
+ OIDCGroupsClaim: get("PULSE_OIDC_GROUPS_CLAIM", "groups"),
+ SessionIdleTTL: 8 * time.Hour,
+ SessionAbsoluteTTL: 7 * 24 * time.Hour,
+ BackupDirectory: get("PULSE_BACKUP_DIR", ""),
+ BackupRetention: 5,
+
+ ContainerSourceID: strings.TrimSpace(get("PULSE_CONTAINER_SOURCE_ID", "")),
+ NotificationWebhookURL: strings.TrimSpace(get("PULSE_NOTIFICATION_WEBHOOK_URL", "")),
+ NotificationWebhookToken: get("PULSE_NOTIFICATION_WEBHOOK_TOKEN", ""),
+ }
+ networks, networksErr := parseAllowedNetworks(get("PULSE_PROBE_ALLOWED_NETWORKS", ""))
+ if networksErr != nil {
+ return Config{}, &ValidationError{Fields: []string{networksErr.Error()}}
+ }
+ config.ProbeAllowedNetworks = networks
+ mapping, mappingErr := parseRoleMapping(get("PULSE_OIDC_ROLE_MAPPING", ""))
+ if mappingErr != nil {
+ return Config{}, &ValidationError{Fields: []string{mappingErr.Error()}}
+ }
+ config.OIDCRoleMapping = mapping
+ config.PrometheusTimeout = 10 * time.Second
+ config.NotificationWebhookTimeout = 10 * time.Second
+ if raw := get("PULSE_SESSION_IDLE_TTL", ""); raw != "" {
+ parsed, err := time.ParseDuration(raw)
+ if err != nil {
+ return Config{}, &ValidationError{Fields: []string{"PULSE_SESSION_IDLE_TTL must be a duration"}}
+ }
+ config.SessionIdleTTL = parsed
+ }
+ if raw := get("PULSE_SESSION_ABSOLUTE_TTL", ""); raw != "" {
+ parsed, err := time.ParseDuration(raw)
+ if err != nil {
+ return Config{}, &ValidationError{Fields: []string{"PULSE_SESSION_ABSOLUTE_TTL must be a duration"}}
+ }
+ config.SessionAbsoluteTTL = parsed
+ }
+ if raw := get("PULSE_PROMETHEUS_TIMEOUT", ""); raw != "" {
+ parsed, err := time.ParseDuration(raw)
+ if err != nil {
+ return Config{}, &ValidationError{Fields: []string{"PULSE_PROMETHEUS_TIMEOUT must be a duration"}}
+ }
+ config.PrometheusTimeout = parsed
+ }
+ if raw := get("PULSE_NOTIFICATION_WEBHOOK_TIMEOUT", ""); raw != "" {
+ parsed, err := time.ParseDuration(raw)
+ if err != nil {
+ return Config{}, &ValidationError{Fields: []string{"PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be a duration"}}
+ }
+ config.NotificationWebhookTimeout = parsed
+ }
+ if raw := get("PULSE_BREAK_GLASS_ENABLED", "false"); raw != "" {
+ parsed, err := strconv.ParseBool(raw)
+ if err != nil {
+ return Config{}, &ValidationError{Fields: []string{"PULSE_BREAK_GLASS_ENABLED must be true or false"}}
+ }
+ config.BreakGlassEnabled = parsed
+ }
+ if raw := get("PULSE_BACKUP_RETENTION", ""); raw != "" {
+ parsed, err := strconv.Atoi(raw)
+ if err != nil {
+ return Config{}, &ValidationError{Fields: []string{"PULSE_BACKUP_RETENTION must be an integer"}}
+ }
+ config.BackupRetention = parsed
+ }
+ return config, nil
+}
+
+// ValidateWorker validates the worker's actual source and sink boundary. OIDC,
+// session, backup and Unraid settings belong to the API or agent and must not
+// be copied into the worker merely to satisfy unrelated validation.
+func ValidateWorker(config Config) error {
+ var fields []string
+ if config.Environment != Development && config.Environment != Test && config.Environment != Production {
+ fields = append(fields, "PULSE_ENV must be development, test, or production")
+ }
+ if strings.TrimSpace(config.DatabaseURL) == "" {
+ fields = append(fields, "PULSE_DATABASE_URL is required: every worker job is database-coordinated")
+ } else {
+ validateDatabaseURL(&fields, config.DatabaseURL)
+ }
+ if config.PrometheusTimeout <= 0 || config.PrometheusTimeout > time.Minute {
+ fields = append(fields, "PULSE_PROMETHEUS_TIMEOUT must be between 1ns and 1m")
+ }
+ if config.PrometheusURL != "" {
+ validateURL(&fields, "PULSE_PROMETHEUS_URL", config.PrometheusURL, config.Environment, false)
+ }
+ if config.ContainerSourceID != "" && !uuidPattern.MatchString(config.ContainerSourceID) {
+ fields = append(fields, "PULSE_CONTAINER_SOURCE_ID must be a UUID")
+ }
+ if config.NotificationWebhookTimeout < time.Second || config.NotificationWebhookTimeout > 30*time.Second {
+ fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be between 1s and 30s")
+ }
+ if config.NotificationWebhookURL != "" {
+ validateURL(&fields, "PULSE_NOTIFICATION_WEBHOOK_URL", config.NotificationWebhookURL, config.Environment, true)
+ parsed, err := url.Parse(config.NotificationWebhookURL)
+ if err == nil && (parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "") {
+ fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_URL may not contain credentials, query parameters, or a fragment")
+ }
+ if strings.TrimSpace(config.NotificationWebhookToken) == "" {
+ fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TOKEN is required when the webhook is configured")
+ }
+ }
+ if len(fields) > 0 {
+ return &ValidationError{Fields: fields}
+ }
+ return nil
+}
+
+func Validate(config Config) error {
+ var fields []string
+ if config.Environment != Development && config.Environment != Test && config.Environment != Production {
+ fields = append(fields, "PULSE_ENV must be development, test, or production")
+ }
+ if config.Timezone == "" {
+ fields = append(fields, "PULSE_TIMEZONE is required")
+ } else if _, err := time.LoadLocation(config.Timezone); err != nil {
+ fields = append(fields, "PULSE_TIMEZONE must be a valid IANA timezone")
+ }
+ if config.DefaultLocale == "" {
+ fields = append(fields, "PULSE_DEFAULT_LOCALE is required")
+ }
+ if !contains([]string{"debug", "info", "warn", "error"}, config.LogLevel) {
+ fields = append(fields, "PULSE_LOG_LEVEL must be debug, info, warn, or error")
+ }
+ if config.PrometheusTimeout <= 0 || config.PrometheusTimeout > time.Minute {
+ fields = append(fields, "PULSE_PROMETHEUS_TIMEOUT must be between 1ns and 1m")
+ }
+ if config.NotificationWebhookTimeout < time.Second || config.NotificationWebhookTimeout > 30*time.Second {
+ fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be between 1s and 30s")
+ }
+ minimumIdle := 5 * time.Second
+ minimumAbsolute := config.SessionIdleTTL
+ if config.Environment == Production {
+ minimumIdle = 10 * time.Minute
+ minimumAbsolute = 24 * time.Hour
+ }
+ if config.SessionIdleTTL < minimumIdle || config.SessionIdleTTL > 24*time.Hour {
+ fields = append(fields, fmt.Sprintf("PULSE_SESSION_IDLE_TTL must be between %s and 24h", minimumIdle))
+ }
+ if config.SessionAbsoluteTTL < minimumAbsolute || config.SessionAbsoluteTTL > 30*24*time.Hour || config.SessionAbsoluteTTL < config.SessionIdleTTL {
+ fields = append(fields, fmt.Sprintf("PULSE_SESSION_ABSOLUTE_TTL must be between %s and 720h and not shorter than PULSE_SESSION_IDLE_TTL", minimumAbsolute))
+ }
+ if config.NotificationWebhookURL != "" {
+ validateURL(&fields, "PULSE_NOTIFICATION_WEBHOOK_URL", config.NotificationWebhookURL, config.Environment, true)
+ parsed, err := url.Parse(config.NotificationWebhookURL)
+ if err == nil && (parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "") {
+ fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_URL may not contain credentials, query parameters, or a fragment")
+ }
+ if strings.TrimSpace(config.NotificationWebhookToken) == "" {
+ fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TOKEN is required when the webhook is configured")
+ }
+ }
+ if config.BackupRetention < 1 || config.BackupRetention > 100 {
+ fields = append(fields, "PULSE_BACKUP_RETENTION must be between 1 and 100")
+ }
+ if config.PublicURL != "" {
+ validateURL(&fields, "PULSE_PUBLIC_URL", config.PublicURL, config.Environment, true)
+ }
+ if config.DatabaseURL != "" {
+ validateDatabaseURL(&fields, config.DatabaseURL)
+ }
+ if config.PrometheusURL != "" {
+ validateURL(&fields, "PULSE_PROMETHEUS_URL", config.PrometheusURL, config.Environment, false)
+ }
+ if config.UnraidURL != "" {
+ validateURL(&fields, "PULSE_UNRAID_URL", config.UnraidURL, config.Environment, false)
+ }
+ if config.ContainerSourceID != "" && !uuidPattern.MatchString(config.ContainerSourceID) {
+ fields = append(fields, "PULSE_CONTAINER_SOURCE_ID must be a UUID")
+ }
+ if config.AuthMode != "oidc" && config.AuthMode != "mock" {
+ fields = append(fields, "PULSE_AUTH_MODE must be oidc or mock")
+ }
+ if config.OIDCIssuer != "" {
+ validateURL(&fields, "PULSE_OIDC_ISSUER", config.OIDCIssuer, config.Environment, true)
+ }
+ if config.OIDCRedirectURL != "" {
+ validateURL(&fields, "PULSE_OIDC_REDIRECT_URL", config.OIDCRedirectURL, config.Environment, true)
+ }
+ if config.Environment == Production {
+ for key, value := range map[string]string{
+ "PULSE_PUBLIC_URL": config.PublicURL,
+ "PULSE_DATABASE_URL": config.DatabaseURL,
+ "PULSE_OIDC_ISSUER": config.OIDCIssuer,
+ "PULSE_OIDC_CLIENT_ID": config.OIDCClientID,
+ "PULSE_OIDC_CLIENT_SECRET": config.OIDCClientSecret,
+ "PULSE_OIDC_REDIRECT_URL": config.OIDCRedirectURL,
+ } {
+ if strings.TrimSpace(value) == "" {
+ fields = append(fields, key+" is required in production")
+ }
+ }
+ if config.AuthMode != "oidc" {
+ fields = append(fields, "PULSE_AUTH_MODE=mock is forbidden in production")
+ }
+ if len(config.OIDCRoleMapping) == 0 {
+ fields = append(fields, "PULSE_OIDC_ROLE_MAPPING is required in production; without it no identity can be granted a role")
+ }
+ if config.BreakGlassEnabled {
+ fields = append(fields, "PULSE_BREAK_GLASS_ENABLED must remain false in production until secure initialization exists")
+ }
+ }
+ if len(fields) > 0 {
+ return &ValidationError{Fields: fields}
+ }
+ return nil
+}
+
+func (c Config) String() string {
+ return fmt.Sprintf("Config{environment=%s, public_url=%s, database_url=%s, oidc_issuer=%s, oidc_client_id=%s, oidc_client_secret=%s, unraid_api_token=%s, notification_webhook_url=%s, notification_webhook_token=%s}", c.Environment, redacted(c.PublicURL), redacted(c.DatabaseURL), redacted(c.OIDCIssuer), redacted(c.OIDCClientID), redacted(c.OIDCClientSecret), redacted(c.UnraidAPIToken), redacted(c.NotificationWebhookURL), redacted(c.NotificationWebhookToken))
+}
+
+func (c Config) Redacted() Config {
+ c.DatabaseURL = redacted(c.DatabaseURL)
+ c.OIDCClientSecret = redacted(c.OIDCClientSecret)
+ c.UnraidAPIToken = redacted(c.UnraidAPIToken)
+ c.NotificationWebhookToken = redacted(c.NotificationWebhookToken)
+ return c
+}
+
+func redacted(value string) string {
+ if strings.TrimSpace(value) == "" {
+ return ""
+ }
+ return ""
+}
+
+func validateDatabaseURL(fields *[]string, raw string) {
+ if err := ValidateDatabaseURL(raw); err != nil {
+ *fields = append(*fields, err.Error())
+ }
+}
+
+// ValidateDatabaseURL reports whether raw is a usable PostgreSQL URL. It is exported so
+// services that take only the database URL from the environment — pulse-agent, which
+// must not require the API's public URL or OIDC settings — apply the same rule as the
+// full configuration loader instead of inventing a second one.
+func ValidateDatabaseURL(raw string) error {
+ parsed, err := url.Parse(raw)
+ if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") || parsed.Host == "" {
+ return errors.New("PULSE_DATABASE_URL must be a PostgreSQL URL")
+ }
+ return nil
+}
+
+// ValidateURL is the reusable strict URL boundary for narrowly scoped runtime
+// components. The full application validation keeps field-specific messages; callers
+// such as pulse-agent need the same HTTPS/absolute-url policy without copying it.
+func ValidateURL(raw string, requireHTTPS bool) error {
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return errors.New("must be an absolute URL")
+ }
+ if requireHTTPS && parsed.Scheme != "https" {
+ return errors.New("must use https")
+ }
+ return nil
+}
+
+func validateURL(fields *[]string, key, raw string, environment Environment, requireHTTPS bool) {
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ *fields = append(*fields, key+" must be an absolute URL")
+ return
+ }
+ if requireHTTPS && environment == Production && parsed.Scheme != "https" {
+ *fields = append(*fields, key+" must use https in production")
+ }
+}
+
+func contains(values []string, target string) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+}
+
+// uuidPattern bounds identifiers that must reference a database row.
+var uuidPattern = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$")
+
+// maxAllowedNetworks mirrors the bound enforced by the probe network policy.
+const maxAllowedNetworks = 32
+
+// parseAllowedNetworks reads a comma-separated CIDR list. An invalid or
+// unbounded list fails startup rather than silently widening or narrowing what
+// probes may reach.
+func parseAllowedNetworks(raw string) ([]string, error) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return nil, nil
+ }
+ entries := strings.Split(trimmed, ",")
+ networks := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ entry = strings.TrimSpace(entry)
+ if entry == "" {
+ continue
+ }
+ if _, err := netip.ParsePrefix(entry); err != nil {
+ return nil, errors.New("PULSE_PROBE_ALLOWED_NETWORKS entries must be CIDR prefixes")
+ }
+ networks = append(networks, entry)
+ }
+ if len(networks) == 0 || len(networks) > maxAllowedNetworks {
+ return nil, fmt.Errorf("PULSE_PROBE_ALLOWED_NETWORKS must contain between 1 and %d CIDR prefixes", maxAllowedNetworks)
+ }
+ return networks, nil
+}
+
+// knownRoles bounds the Pulse role names accepted in PULSE_OIDC_ROLE_MAPPING. It
+// mirrors the roles defined in internal/auth without importing that package, so
+// configuration stays free of runtime dependencies.
+var knownRoles = []string{"viewer", "operator", "editor", "administrator"}
+
+// parseRoleMapping reads a comma-separated "claim=role" list mapping identity
+// provider group claim values onto Pulse roles, for example
+// "pulse-admin=administrator,pulse-staff=viewer". An empty value yields a nil map,
+// which means no group grants access.
+func parseRoleMapping(raw string) (map[string]string, error) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return nil, nil
+ }
+ mapping := make(map[string]string)
+ for _, entry := range strings.Split(trimmed, ",") {
+ entry = strings.TrimSpace(entry)
+ if entry == "" {
+ continue
+ }
+ claim, role, found := strings.Cut(entry, "=")
+ claim = strings.TrimSpace(claim)
+ role = strings.ToLower(strings.TrimSpace(role))
+ if !found || claim == "" || role == "" {
+ return nil, errors.New("PULSE_OIDC_ROLE_MAPPING entries must use claim=role")
+ }
+ if !contains(knownRoles, role) {
+ return nil, errors.New("PULSE_OIDC_ROLE_MAPPING role must be one of " + strings.Join(knownRoles, ", "))
+ }
+ if _, duplicate := mapping[claim]; duplicate {
+ return nil, errors.New("PULSE_OIDC_ROLE_MAPPING contains a duplicate claim")
+ }
+ mapping[claim] = role
+ }
+ if len(mapping) == 0 {
+ return nil, errors.New("PULSE_OIDC_ROLE_MAPPING must contain at least one claim=role entry")
+ }
+ return mapping, nil
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..71459fb
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,259 @@
+package config
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestProductionListsAllMissingMandatoryValues(t *testing.T) {
+ values := map[string]string{"PULSE_ENV": "production"}
+ _, err := LoadFrom(mapLookup(values))
+ if err == nil {
+ t.Fatal("expected production validation error")
+ }
+ message := err.Error()
+ for _, key := range []string{"PULSE_PUBLIC_URL", "PULSE_DATABASE_URL", "PULSE_OIDC_ISSUER", "PULSE_OIDC_CLIENT_ID", "PULSE_OIDC_CLIENT_SECRET", "PULSE_OIDC_REDIRECT_URL"} {
+ if !strings.Contains(message, key) {
+ t.Errorf("error %q does not mention %s", message, key)
+ }
+ }
+}
+
+func TestWebhookConfigurationIsSecureAndRedacted(t *testing.T) {
+ secret := "webhook-runtime-secret"
+ configuration, err := LoadFrom(mapLookup(map[string]string{
+ "PULSE_ENV": "development",
+ "PULSE_NOTIFICATION_WEBHOOK_URL": "http://127.0.0.1:8080/pulse",
+ "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret,
+ "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT": "3s",
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if configuration.NotificationWebhookTimeout != 3*time.Second {
+ t.Fatalf("timeout = %s", configuration.NotificationWebhookTimeout)
+ }
+ if strings.Contains(configuration.String(), secret) || strings.Contains(configuration.Redacted().NotificationWebhookToken, secret) {
+ t.Fatal("webhook credential leaked through config rendering")
+ }
+ for name, values := range map[string]map[string]string{
+ "missing token": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook"},
+ "query token": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook?token=value", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret},
+ "production http": {"PULSE_ENV": "production", "PULSE_NOTIFICATION_WEBHOOK_URL": "http://receiver.example/hook", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret},
+ "unbounded timeout": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT": "31s"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := LoadFrom(mapLookup(values)); err == nil {
+ t.Fatal("expected webhook configuration rejection")
+ }
+ })
+ }
+}
+
+func TestProductionRejectsMockAuth(t *testing.T) {
+ values := map[string]string{
+ "PULSE_ENV": "production", "PULSE_PUBLIC_URL": "https://pulse.example",
+ "PULSE_DATABASE_URL": "postgres://pulse@db/pulse", "PULSE_OIDC_ISSUER": "https://auth.example",
+ "PULSE_OIDC_CLIENT_ID": "pulse", "PULSE_OIDC_CLIENT_SECRET": "secret-value",
+ "PULSE_OIDC_REDIRECT_URL": "https://pulse.example/auth/callback", "PULSE_AUTH_MODE": "mock",
+ }
+ _, err := LoadFrom(mapLookup(values))
+ if err == nil || !strings.Contains(err.Error(), "mock") {
+ t.Fatalf("expected mock-auth rejection, got %v", err)
+ }
+}
+
+func TestValidationErrorsAndStringNeverExposeSecrets(t *testing.T) {
+ secret := "super-secret-token"
+ values := map[string]string{
+ "PULSE_ENV": "production", "PULSE_DATABASE_URL": "not-a-url", "PULSE_OIDC_CLIENT_SECRET": secret,
+ "PULSE_UNRAID_API_TOKEN": secret, "PULSE_AUTH_MODE": "mock",
+ }
+ config, err := LoadFrom(mapLookup(values))
+ if err == nil {
+ t.Fatal("expected validation error")
+ }
+ if strings.Contains(err.Error(), secret) {
+ t.Fatal("validation error leaked a secret")
+ }
+ if strings.Contains(config.String(), secret) {
+ t.Fatal("config String leaked a secret")
+ }
+}
+
+func TestDevelopmentDefaultsAllowExplicitMockMode(t *testing.T) {
+ values := map[string]string{"PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock"}
+ config, err := LoadFrom(mapLookup(values))
+ if err != nil {
+ t.Fatalf("development defaults rejected: %v", err)
+ }
+ if config.Environment != Development || config.AuthMode != "mock" {
+ t.Fatalf("unexpected config: %s", config)
+ }
+ if config.SessionIdleTTL != 8*time.Hour || config.SessionAbsoluteTTL != 7*24*time.Hour {
+ t.Fatalf("unexpected session defaults: idle=%s absolute=%s", config.SessionIdleTTL, config.SessionAbsoluteTTL)
+ }
+}
+
+func TestSessionLifetimeConfigurationIsBounded(t *testing.T) {
+ configured, err := LoadFrom(mapLookup(map[string]string{
+ "PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock",
+ "PULSE_SESSION_IDLE_TTL": "20s", "PULSE_SESSION_ABSOLUTE_TTL": "2m",
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if configured.SessionIdleTTL != 20*time.Second || configured.SessionAbsoluteTTL != 2*time.Minute {
+ t.Fatalf("unexpected session lifetimes: %#v", configured)
+ }
+
+ for name, values := range map[string]map[string]string{
+ "invalid duration": {"PULSE_SESSION_IDLE_TTL": "later"},
+ "idle too short": {"PULSE_SESSION_IDLE_TTL": "4s"},
+ "absolute shorter than idle": {"PULSE_SESSION_IDLE_TTL": "20s", "PULSE_SESSION_ABSOLUTE_TTL": "10s"},
+ "absolute unbounded": {"PULSE_SESSION_ABSOLUTE_TTL": "721h"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := LoadFrom(mapLookup(values)); err == nil {
+ t.Fatal("expected bounded session configuration rejection")
+ }
+ })
+ }
+}
+
+func TestProductionRequiresWallboardCapableAbsoluteSessionLifetime(t *testing.T) {
+ config := Config{
+ Environment: Production, Timezone: "Europe/Brussels", DefaultLocale: "nl-BE", LogLevel: "info",
+ PublicURL: "https://pulse.example", DatabaseURL: "postgres://pulse@db/pulse", AuthMode: "oidc",
+ OIDCIssuer: "https://auth.example", OIDCClientID: "pulse", OIDCClientSecret: "secret",
+ OIDCRedirectURL: "https://pulse.example/auth/callback", OIDCRoleMapping: map[string]string{"viewer": "viewer"},
+ PrometheusTimeout: 10 * time.Second, NotificationWebhookTimeout: 10 * time.Second, BackupRetention: 5,
+ SessionIdleTTL: 8 * time.Hour, SessionAbsoluteTTL: 23 * time.Hour,
+ }
+ if err := Validate(config); err == nil || !strings.Contains(err.Error(), "PULSE_SESSION_ABSOLUTE_TTL") {
+ t.Fatalf("production accepted a session unable to cover the wallboard budget: %v", err)
+ }
+ config.SessionAbsoluteTTL = 24 * time.Hour
+ config.SessionIdleTTL = 5 * time.Minute
+ if err := Validate(config); err == nil || !strings.Contains(err.Error(), "PULSE_SESSION_IDLE_TTL") {
+ t.Fatalf("production accepted an idle TTL that can race the five-minute wallboard refresh: %v", err)
+ }
+ config.SessionIdleTTL = 10 * time.Minute
+ if err := Validate(config); err != nil {
+ t.Fatalf("bounded 24-hour production session rejected: %v", err)
+ }
+}
+
+func TestBackupConfigurationIsBoundedAndOptional(t *testing.T) {
+ values := map[string]string{"PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock", "PULSE_BACKUP_DIR": "C:/pulse-backups", "PULSE_BACKUP_RETENTION": "12"}
+ config, err := LoadFrom(mapLookup(values))
+ if err != nil {
+ t.Fatalf("backup config rejected: %v", err)
+ }
+ if config.BackupDirectory != values["PULSE_BACKUP_DIR"] || config.BackupRetention != 12 {
+ t.Fatalf("unexpected backup config: %#v", config)
+ }
+ values["PULSE_BACKUP_RETENTION"] = "101"
+ if _, err := LoadFrom(mapLookup(values)); err == nil || !strings.Contains(err.Error(), "PULSE_BACKUP_RETENTION") {
+ t.Fatalf("expected bounded retention error, got %v", err)
+ }
+}
+func mapLookup(values map[string]string) func(string) (string, bool) {
+ return func(key string) (string, bool) { value, ok := values[key]; return value, ok }
+}
+
+func TestRoleMappingParsesClaimsOntoRoles(t *testing.T) {
+ config, err := LoadFrom(mapLookup(map[string]string{
+ "PULSE_OIDC_ROLE_MAPPING": "pulse-admin=administrator, pulse-staff =viewer,pulse-ops=Operator",
+ }))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ expected := map[string]string{"pulse-admin": "administrator", "pulse-staff": "viewer", "pulse-ops": "operator"}
+ if len(config.OIDCRoleMapping) != len(expected) {
+ t.Fatalf("expected %d mapped claims, got %d", len(expected), len(config.OIDCRoleMapping))
+ }
+ for claim, role := range expected {
+ if config.OIDCRoleMapping[claim] != role {
+ t.Fatalf("claim %q mapped to %q, want %q", claim, config.OIDCRoleMapping[claim], role)
+ }
+ }
+}
+
+func TestRoleMappingDefaultsToNoAccess(t *testing.T) {
+ config, err := LoadFrom(mapLookup(nil))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if config.OIDCRoleMapping != nil {
+ t.Fatal("an unset role mapping must grant nobody a role")
+ }
+ if config.OIDCGroupsClaim != "groups" {
+ t.Fatalf("groups claim defaulted to %q, want groups", config.OIDCGroupsClaim)
+ }
+}
+
+func TestRoleMappingRejectsMalformedInput(t *testing.T) {
+ for name, raw := range map[string]string{
+ "missing separator": "pulse-admin",
+ "empty claim": "=administrator",
+ "empty role": "pulse-admin=",
+ "unknown role": "pulse-admin=superuser",
+ "duplicate claim": "pulse-admin=viewer,pulse-admin=editor",
+ "only separators": ",,",
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := LoadFrom(mapLookup(map[string]string{"PULSE_OIDC_ROLE_MAPPING": raw})); err == nil {
+ t.Fatalf("expected %q to be rejected", raw)
+ }
+ })
+ }
+}
+
+func TestProductionRequiresARoleMapping(t *testing.T) {
+ base := map[string]string{
+ "PULSE_ENV": "production",
+ "PULSE_PUBLIC_URL": "https://pulse.example.test",
+ "PULSE_DATABASE_URL": "postgres://pulse@db:5432/pulse",
+ "PULSE_OIDC_ISSUER": "https://id.example.test",
+ "PULSE_OIDC_CLIENT_ID": "pulse",
+ "PULSE_OIDC_CLIENT_SECRET": "secret",
+ "PULSE_OIDC_REDIRECT_URL": "https://pulse.example.test/auth/callback",
+ }
+ if _, err := LoadFrom(mapLookup(base)); err == nil {
+ t.Fatal("production without a role mapping must fail: no identity could obtain a role")
+ }
+ base["PULSE_OIDC_ROLE_MAPPING"] = "pulse-admin=administrator"
+ if _, err := LoadFrom(mapLookup(base)); err != nil {
+ t.Fatalf("production with a role mapping must succeed: %v", err)
+ }
+}
+
+func TestLoadWorkerProductionDoesNotRequireAPICredentials(t *testing.T) {
+ configuration, err := LoadWorkerFrom(mapLookup(map[string]string{
+ "PULSE_ENV": "production",
+ "PULSE_DATABASE_URL": "postgres://pulse:secret@pulse-postgres:5432/pulse?sslmode=disable",
+ "PULSE_PROMETHEUS_URL": "http://192.0.2.10:9090",
+ }))
+ if err != nil {
+ t.Fatalf("LoadWorkerFrom returned API-only validation error: %v", err)
+ }
+ if configuration.Environment != Production || configuration.DatabaseURL == "" {
+ t.Fatalf("unexpected worker config: %#v", configuration)
+ }
+}
+
+func TestLoadWorkerStillRequiresDatabaseAndValidatesSources(t *testing.T) {
+ _, err := LoadWorkerFrom(mapLookup(map[string]string{
+ "PULSE_ENV": "production",
+ "PULSE_PROMETHEUS_URL": "://invalid",
+ }))
+ if err == nil {
+ t.Fatal("LoadWorkerFrom accepted missing database and malformed Prometheus URL")
+ }
+ message := err.Error()
+ if !strings.Contains(message, "PULSE_DATABASE_URL") || !strings.Contains(message, "PULSE_PROMETHEUS_URL") {
+ t.Fatalf("worker validation error = %q", message)
+ }
+}
diff --git a/internal/container/types.go b/internal/container/types.go
new file mode 100644
index 0000000..dbe3bad
--- /dev/null
+++ b/internal/container/types.go
@@ -0,0 +1,320 @@
+package container
+
+import (
+ "context"
+ "errors"
+ "math"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ ContractVersion = "v1"
+ DefaultMaxContainers = 250
+)
+
+type Limits struct {
+ MaxContainers, MaxPorts, MaxVolumes, MaxNetworks, MaxLabels, MaxPageSize int
+ FreshnessMaxAge time.Duration
+}
+
+func (l Limits) withDefaults() Limits {
+ if l.MaxContainers == 0 {
+ // The v1 performance target remains 150 containers. Bounded growth margin
+ // keeps a modestly larger host from invalidating the complete snapshot.
+ l.MaxContainers = DefaultMaxContainers
+ }
+ if l.MaxPorts == 0 {
+ l.MaxPorts = 32
+ }
+ if l.MaxVolumes == 0 {
+ l.MaxVolumes = 32
+ }
+ if l.MaxNetworks == 0 {
+ l.MaxNetworks = 32
+ }
+ if l.MaxLabels == 0 {
+ l.MaxLabels = 64
+ }
+ if l.MaxPageSize == 0 {
+ l.MaxPageSize = 100
+ }
+ if l.FreshnessMaxAge == 0 {
+ l.FreshnessMaxAge = 60 * time.Second
+ }
+ return l
+}
+func (l Limits) Validate() error {
+ if l.MaxContainers < 1 || l.MaxContainers > 1000 || l.MaxPorts < 1 || l.MaxPorts > 128 || l.MaxVolumes < 1 || l.MaxVolumes > 128 || l.MaxNetworks < 1 || l.MaxNetworks > 128 || l.MaxLabels < 1 || l.MaxLabels > 256 || l.MaxPageSize < 1 || l.MaxPageSize > 500 || l.FreshnessMaxAge <= 0 || l.FreshnessMaxAge > 24*time.Hour {
+ return errors.New("container limits are outside safe bounds")
+ }
+ return nil
+}
+
+type Source struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Freshness string `json:"freshness"`
+ State string `json:"state"`
+ Reason string `json:"reason,omitempty"`
+}
+type Port struct {
+ ContainerPort int `json:"containerPort"`
+ HostPort int `json:"hostPort,omitempty"`
+ Protocol string `json:"protocol"`
+}
+type RawContainer struct {
+ ID, Name, Image, ImageDigest, State, Health string
+ IntentionalStop bool
+ MetricsAvailable, LifecycleAvailable bool
+ UptimeSeconds float64
+ RestartCount int
+ ExitCode int
+ CPUPercent float64
+ MemoryBytes, MemoryLimitBytes uint64
+ NetworkRxBytes, NetworkTxBytes uint64
+ BlockReadBytes, BlockWriteBytes uint64
+ Ports []Port
+ Volumes, Networks []string
+ Project string
+ Labels map[string]string
+}
+type Container struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Image string `json:"image,omitempty"`
+ ImageDigest string `json:"imageDigest,omitempty"`
+ State string `json:"state"`
+ Health string `json:"health"`
+ IntentionalStop bool `json:"intentionalStop"`
+ MetricsAvailable bool `json:"metricsAvailable"`
+ LifecycleAvailable bool `json:"lifecycleAvailable"`
+ UptimeSeconds float64 `json:"uptimeSeconds"`
+ RestartCount int `json:"restartCount"`
+ ExitCode int `json:"exitCode"`
+ CPUPercent float64 `json:"cpuPercent"`
+ MemoryBytes uint64 `json:"memoryBytes"`
+ MemoryLimitBytes uint64 `json:"memoryLimitBytes"`
+ NetworkRxBytes uint64 `json:"networkRxBytes"`
+ NetworkTxBytes uint64 `json:"networkTxBytes"`
+ BlockReadBytes uint64 `json:"blockReadBytes"`
+ BlockWriteBytes uint64 `json:"blockWriteBytes"`
+ Ports []Port `json:"ports"`
+ Volumes []string `json:"volumes"`
+ Networks []string `json:"networks"`
+ Project string `json:"project,omitempty"`
+ Labels map[string]string `json:"labels,omitempty"`
+}
+type RawSnapshot struct {
+ Source Source
+ Containers []RawContainer
+ ObservedAt, ReceivedAt time.Time
+}
+type Snapshot struct {
+ ContractVersion string `json:"contractVersion"`
+ Source Source `json:"source"`
+ Containers []Container `json:"containers"`
+ Total int `json:"total"`
+ NextCursor string `json:"nextCursor,omitempty"`
+}
+type Provider interface {
+ Snapshot(context.Context) (Snapshot, error)
+}
+type Adapter struct {
+ Source interface {
+ Snapshot(context.Context) (RawSnapshot, error)
+ }
+ Limits Limits
+ Now func() time.Time
+}
+
+func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return Snapshot{}, err
+ }
+ if a.Source == nil {
+ return UnknownSnapshot(time.Now().UTC(), "container", "agent", "source_unavailable"), nil
+ }
+ raw, err := a.Source.Snapshot(ctx)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ now := time.Now().UTC()
+ if a.Now != nil {
+ now = a.Now()
+ }
+ return Normalize(raw, now, a.Limits)
+}
+func UnknownSnapshot(now time.Time, id, typ, reason string) Snapshot {
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: id, Type: typ, ReceivedAt: now, Freshness: "unavailable", State: "unknown", Reason: reason}, Containers: []Container{}, Total: 0}
+}
+func Normalize(raw RawSnapshot, now time.Time, limits Limits) (Snapshot, error) {
+ limits = limits.withDefaults()
+ if err := limits.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ if raw.ReceivedAt.IsZero() {
+ raw.ReceivedAt = now
+ }
+ if raw.ObservedAt.IsZero() {
+ raw.ObservedAt = raw.ReceivedAt
+ }
+ if raw.ObservedAt.After(now.Add(time.Minute)) {
+ return Snapshot{}, errors.New("container observation is materially in the future")
+ }
+ if len(raw.Containers) > limits.MaxContainers {
+ return Snapshot{}, errors.New("container count exceeds bounds")
+ }
+ source := raw.Source
+ if source.ID == "" {
+ source.ID = "container"
+ }
+ if source.Type == "" {
+ source.Type = "agent"
+ }
+ source.ObservedAt = raw.ObservedAt.UTC()
+ source.ReceivedAt = raw.ReceivedAt.UTC()
+ source.Freshness = "fresh"
+ source.State = "healthy"
+ if now.Sub(raw.ObservedAt) > limits.FreshnessMaxAge {
+ source.Freshness = "stale"
+ source.State = "unknown"
+ source.Reason = "stale_source"
+ }
+ items := make([]Container, 0, len(raw.Containers))
+ for _, r := range raw.Containers {
+ if strings.TrimSpace(r.ID) == "" || strings.TrimSpace(r.Name) == "" || len(r.Name) > 255 || r.RestartCount < 0 || r.UptimeSeconds < 0 || r.CPUPercent < 0 || r.CPUPercent > 10000 || math.IsNaN(r.CPUPercent) || math.IsInf(r.CPUPercent, 0) {
+ return Snapshot{}, errors.New("invalid container identity or metrics")
+ }
+ if len(r.Ports) > limits.MaxPorts || len(r.Volumes) > limits.MaxVolumes || len(r.Networks) > limits.MaxNetworks || len(r.Labels) > limits.MaxLabels {
+ return Snapshot{}, errors.New("container detail exceeds bounds")
+ }
+ ports := append([]Port(nil), r.Ports...)
+ sort.Slice(ports, func(i, j int) bool {
+ if ports[i].ContainerPort != ports[j].ContainerPort {
+ return ports[i].ContainerPort < ports[j].ContainerPort
+ }
+ return ports[i].Protocol < ports[j].Protocol
+ })
+ volumes := sortedStrings(r.Volumes)
+ networks := sortedStrings(r.Networks)
+ labels := make(map[string]string, len(r.Labels))
+ for k, v := range r.Labels {
+ if len(k) <= 128 && len(v) <= 512 {
+ labels[k] = v
+ }
+ }
+ items = append(items, Container{ID: r.ID, Name: r.Name, Image: r.Image, ImageDigest: r.ImageDigest, State: normalizeRuntimeState(r.State), Health: normalizeHealth(r.Health), IntentionalStop: r.IntentionalStop, MetricsAvailable: r.MetricsAvailable, LifecycleAvailable: r.LifecycleAvailable, UptimeSeconds: r.UptimeSeconds, RestartCount: r.RestartCount, ExitCode: r.ExitCode, CPUPercent: r.CPUPercent, MemoryBytes: r.MemoryBytes, MemoryLimitBytes: r.MemoryLimitBytes, NetworkRxBytes: r.NetworkRxBytes, NetworkTxBytes: r.NetworkTxBytes, BlockReadBytes: r.BlockReadBytes, BlockWriteBytes: r.BlockWriteBytes, Ports: ports, Volumes: volumes, Networks: networks, Project: r.Project, Labels: labels})
+ }
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].Name != items[j].Name {
+ return items[i].Name < items[j].Name
+ }
+ return items[i].ID < items[j].ID
+ })
+ return Snapshot{ContractVersion: ContractVersion, Source: source, Containers: items, Total: len(items)}, nil
+}
+func Page(snapshot Snapshot, limit int, after string, limits Limits) (Snapshot, error) {
+ return FilteredPage(snapshot, limit, after, limits, "", "", "", "name")
+}
+
+func FilteredPage(snapshot Snapshot, limit int, after string, limits Limits, query, state, health, order string) (Snapshot, error) {
+ limits = limits.withDefaults()
+ if err := limits.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ if limit < 1 || limit > limits.MaxPageSize {
+ return Snapshot{}, errors.New("container page limit is outside bounds")
+ }
+ query, state, health, order = strings.ToLower(strings.TrimSpace(query)), strings.ToLower(strings.TrimSpace(state)), strings.ToLower(strings.TrimSpace(health)), strings.ToLower(strings.TrimSpace(order))
+ if len(query) > 100 || (state != "" && normalizeRuntimeState(state) != state) || (health != "" && normalizeHealth(health) != health) || (order != "name" && order != "cpu" && order != "memory" && order != "state") {
+ return Snapshot{}, errors.New("container filters are invalid")
+ }
+ items := make([]Container, 0, len(snapshot.Containers))
+ for _, item := range snapshot.Containers {
+ if query != "" && !strings.Contains(strings.ToLower(item.Name+" "+item.Project+" "+item.Image), query) {
+ continue
+ }
+ if state != "" && item.State != state {
+ continue
+ }
+ if health != "" && item.Health != health {
+ continue
+ }
+ items = append(items, item)
+ }
+ sort.SliceStable(items, func(i, j int) bool {
+ switch order {
+ case "cpu":
+ if items[i].CPUPercent != items[j].CPUPercent {
+ return items[i].CPUPercent > items[j].CPUPercent
+ }
+ case "memory":
+ if items[i].MemoryBytes != items[j].MemoryBytes {
+ return items[i].MemoryBytes > items[j].MemoryBytes
+ }
+ case "state":
+ if items[i].State != items[j].State {
+ return items[i].State < items[j].State
+ }
+ }
+ if items[i].Name != items[j].Name {
+ return items[i].Name < items[j].Name
+ }
+ return items[i].ID < items[j].ID
+ })
+ start := 0
+ if after != "" {
+ n, err := strconv.Atoi(after)
+ if err != nil || n < 0 || n > len(items) {
+ return Snapshot{}, errors.New("invalid container cursor")
+ }
+ start = n
+ }
+ end := start + limit
+ if end > len(items) {
+ end = len(items)
+ }
+ result := snapshot
+ result.Containers = items[start:end]
+ result.Total = len(items)
+ result.NextCursor = ""
+ if end < len(items) {
+ result.NextCursor = strconv.Itoa(end)
+ }
+ return result, nil
+}
+func sortedStrings(values []string) []string {
+ r := append([]string(nil), values...)
+ sort.Strings(r)
+ return r
+}
+func normalizeRuntimeState(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ switch value {
+ case "running", "restarting", "paused", "exited", "dead", "stopped", "created", "removing":
+ return value
+ default:
+ return "unknown"
+ }
+}
+
+func normalizeHealth(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ switch value {
+ case "healthy", "unhealthy", "starting":
+ return value
+ default:
+ return "unknown"
+ }
+}
diff --git a/internal/container/types_test.go b/internal/container/types_test.go
new file mode 100644
index 0000000..da860a1
--- /dev/null
+++ b/internal/container/types_test.go
@@ -0,0 +1,117 @@
+package container
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestNormalizeAcceptsBoundedOperationalContainerHeadroom(t *testing.T) {
+ now := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC)
+ raw := RawSnapshot{Source: Source{ID: "agent"}, ObservedAt: now, ReceivedAt: now}
+ for index := 0; index < DefaultMaxContainers; index++ {
+ raw.Containers = append(raw.Containers, RawContainer{ID: fmt.Sprintf("id-%03d", index), Name: fmt.Sprintf("container-%03d", index), State: "running"})
+ }
+ if snapshot, err := Normalize(raw, now, Limits{}); err != nil || snapshot.Total != DefaultMaxContainers {
+ t.Fatalf("bounded headroom snapshot total=%d err=%v", snapshot.Total, err)
+ }
+ raw.Containers = append(raw.Containers, RawContainer{ID: "overflow", Name: "overflow", State: "running"})
+ if _, err := Normalize(raw, now, Limits{}); err == nil {
+ t.Fatal("container inventory beyond bounded headroom was accepted")
+ }
+}
+
+func TestNormalize150ContainerFixtureAndSeparateStateHealth(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ raw := RawSnapshot{Source: Source{ID: "agent-1"}, ObservedAt: now, ReceivedAt: now}
+ for i := 0; i < 150; i++ {
+ raw.Containers = append(raw.Containers, RawContainer{ID: string(rune('a'+i/26)) + string(rune('a'+i%26)), Name: "container-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26)), State: "running", Health: "unhealthy", CPUPercent: 1})
+ }
+ raw.Containers[0].IntentionalStop = true
+ raw.Containers[0].State = "exited"
+ raw.Containers[0].Health = "healthy"
+ snapshot, err := Normalize(raw, now, Limits{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Total != 150 || len(snapshot.Containers) != 150 || snapshot.Containers[0].State == snapshot.Containers[0].Health {
+ t.Fatalf("container state and health were conflated: %+v", snapshot.Containers[0])
+ }
+ foundStopped := false
+ for _, item := range snapshot.Containers {
+ if item.IntentionalStop {
+ foundStopped = item.State == "exited" && item.Health == "healthy"
+ }
+ }
+ if !foundStopped {
+ t.Fatal("intentional stop was not preserved")
+ }
+}
+
+func TestPageIsBoundedAndDeterministic(t *testing.T) {
+ now := time.Now().UTC()
+ raw := RawSnapshot{Source: Source{ID: "agent"}, ObservedAt: now, ReceivedAt: now, Containers: []RawContainer{{ID: "b", Name: "zeta", State: "running"}, {ID: "a", Name: "alpha", State: "running"}}}
+ snapshot, err := Normalize(raw, now, Limits{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ page, err := Page(snapshot, 1, "", Limits{})
+ if err != nil || len(page.Containers) != 1 || page.Containers[0].Name != "alpha" || page.NextCursor != "1" {
+ t.Fatalf("page=%+v err=%v", page, err)
+ }
+ if _, err := Page(snapshot, 101, "", Limits{}); err == nil {
+ t.Fatal("oversized page accepted")
+ }
+}
+
+func TestFilteredPageFiltersBeforeCursorAndSortsDeterministically(t *testing.T) {
+ snapshot := Snapshot{Containers: []Container{{ID: "b", Name: "Beta", State: "running", Health: "healthy", CPUPercent: 5}, {ID: "a", Name: "Alpha", State: "running", Health: "healthy", CPUPercent: 10}, {ID: "c", Name: "Other", State: "exited", Health: "unknown"}}}
+ page, err := FilteredPage(snapshot, 1, "", Limits{}, "a", "running", "healthy", "cpu")
+ if err != nil || page.Total != 2 || len(page.Containers) != 1 || page.Containers[0].ID != "a" || page.NextCursor != "1" {
+ t.Fatalf("filtered page=%+v err=%v", page, err)
+ }
+ next, err := FilteredPage(snapshot, 1, page.NextCursor, Limits{}, "a", "running", "healthy", "cpu")
+ if err != nil || len(next.Containers) != 1 || next.Containers[0].ID != "b" {
+ t.Fatalf("next filtered page=%+v err=%v", next, err)
+ }
+}
+
+func TestStaleSourceAndContextCancellation(t *testing.T) {
+ now := time.Now().UTC()
+ raw := RawSnapshot{Source: Source{ID: "agent"}, ObservedAt: now.Add(-2 * time.Minute), ReceivedAt: now, Containers: []RawContainer{{ID: "a", Name: "alpha", State: "running"}}}
+ snapshot, err := Normalize(raw, now, Limits{FreshnessMaxAge: time.Minute})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Source.State != "unknown" || snapshot.Source.Freshness != "stale" {
+ t.Fatalf("source=%+v", snapshot.Source)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err = (Adapter{}).Snapshot(ctx)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("err=%v", err)
+ }
+}
+
+func TestNormalizeCanonicalizesRuntimeAndPreservesAvailability(t *testing.T) {
+ now := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
+ snapshot, err := Normalize(RawSnapshot{
+ Source: Source{ID: "agent"}, ObservedAt: now, ReceivedAt: now,
+ Containers: []RawContainer{
+ {ID: "a", Name: "alpha", State: " RUNNING ", Health: " HEALTHY ", MetricsAvailable: true, LifecycleAvailable: true},
+ {ID: "b", Name: "beta", State: "RUNNING", Health: "Up 34 hours"},
+ },
+ }, now, Limits{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Containers[0].State != "running" || snapshot.Containers[0].Health != "healthy" || !snapshot.Containers[0].MetricsAvailable || !snapshot.Containers[0].LifecycleAvailable {
+ t.Fatalf("canonical container = %+v", snapshot.Containers[0])
+ }
+ if snapshot.Containers[1].State != "running" || snapshot.Containers[1].Health != "unknown" || snapshot.Containers[1].MetricsAvailable || snapshot.Containers[1].LifecycleAvailable {
+ t.Fatalf("missing telemetry was fabricated: %+v", snapshot.Containers[1])
+ }
+}
diff --git a/internal/containerapi/handler.go b/internal/containerapi/handler.go
new file mode 100644
index 0000000..0c07a14
--- /dev/null
+++ b/internal/containerapi/handler.go
@@ -0,0 +1,85 @@
+package containerapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/container"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Handler struct {
+ Provider interface {
+ Snapshot(context.Context) (container.Snapshot, error)
+ }
+ Limits container.Limits
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || (r.URL.Path != "/api/v1/containers" && !strings.HasPrefix(r.URL.Path, "/api/v1/containers/")) {
+ http.NotFound(w, r)
+ return
+ }
+ if _, ok := auth.PrincipalFromContext(r.Context()); !ok {
+ problem.Write(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required", "Authentication is required to read containers.", nil)
+ return
+ }
+ snapshot, err := h.snapshot(r)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return
+ }
+ problem.Write(w, r, http.StatusServiceUnavailable, "CONTAINERS_UNAVAILABLE", "Containers not available", "Containergegevens konden niet worden gelezen.", nil)
+ return
+ }
+ if r.URL.Path != "/api/v1/containers" {
+ id := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/")
+ for _, item := range snapshot.Containers {
+ if item.ID == id {
+ writeJSON(w, struct {
+ Source container.Source `json:"source"`
+ Container container.Container `json:"container"`
+ }{Source: snapshot.Source, Container: item})
+ return
+ }
+ }
+ http.NotFound(w, r)
+ return
+ }
+ limit := 50
+ if value := r.URL.Query().Get("limit"); value != "" {
+ parsed, parseErr := strconv.Atoi(value)
+ if parseErr != nil {
+ problem.Write(w, r, http.StatusBadRequest, "CONTAINER_QUERY_INVALID", "Invalid container query", "De containerlimiet is ongeldig.", nil)
+ return
+ }
+ limit = parsed
+ }
+ order := r.URL.Query().Get("sort")
+ if order == "" {
+ order = "name"
+ }
+ page, err := container.FilteredPage(snapshot, limit, r.URL.Query().Get("after"), h.Limits, r.URL.Query().Get("q"), r.URL.Query().Get("state"), r.URL.Query().Get("health"), order)
+ if err != nil {
+ problem.Write(w, r, http.StatusBadRequest, "CONTAINER_QUERY_INVALID", "Invalid container query", "De containerlimiet of cursor is ongeldig.", nil)
+ return
+ }
+ writeJSON(w, page)
+}
+func (h Handler) snapshot(r *http.Request) (container.Snapshot, error) {
+ if h.Provider == nil {
+ return container.UnknownSnapshot(time.Now().UTC(), "container", "agent", "source_unavailable"), nil
+ }
+ return h.Provider.Snapshot(r.Context())
+}
+func writeJSON(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, max-age=5")
+ _ = json.NewEncoder(w).Encode(value)
+}
diff --git a/internal/containerapi/handler_test.go b/internal/containerapi/handler_test.go
new file mode 100644
index 0000000..2d08a59
--- /dev/null
+++ b/internal/containerapi/handler_test.go
@@ -0,0 +1,57 @@
+package containerapi
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/container"
+)
+
+type provider struct{ value container.Snapshot }
+
+func (p provider) Snapshot(context.Context) (container.Snapshot, error) { return p.value, nil }
+func authRequest(method, path string) *http.Request {
+ r := httptest.NewRequest(method, path, nil)
+ return r.WithContext(auth.WithPrincipal(r.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
+}
+
+func TestHandlerListDetailAndNoMutation(t *testing.T) {
+ snapshot := container.Snapshot{ContractVersion: container.ContractVersion, Source: container.Source{ID: "agent", State: "healthy"}, Containers: []container.Container{{ID: "abc", Name: "media", State: "running", Health: "unhealthy"}}, Total: 1}
+ h := Handler{Provider: provider{value: snapshot}}
+ list := httptest.NewRecorder()
+ h.ServeHTTP(list, authRequest(http.MethodGet, "/api/v1/containers?limit=1"))
+ if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"name":"media"`) {
+ t.Fatalf("status=%d body=%s", list.Code, list.Body.String())
+ }
+ detail := httptest.NewRecorder()
+ h.ServeHTTP(detail, authRequest(http.MethodGet, "/api/v1/containers/abc"))
+ if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"container":{"id":"abc"`) {
+ t.Fatalf("status=%d body=%s", detail.Code, detail.Body.String())
+ }
+ mutate := httptest.NewRecorder()
+ h.ServeHTTP(mutate, authRequest(http.MethodPost, "/api/v1/containers/abc/restart"))
+ if mutate.Code != http.StatusNotFound {
+ t.Fatalf("mutation=%d", mutate.Code)
+ }
+}
+
+func TestHandlerRequiresAuthentication(t *testing.T) {
+ response := httptest.NewRecorder()
+ Handler{}.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/containers", nil))
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("status=%d", response.Code)
+ }
+}
+
+func TestHandlerAppliesFiltersBeforePagination(t *testing.T) {
+ snapshot := container.Snapshot{Source: container.Source{ID: "agent", State: "healthy"}, Containers: []container.Container{{ID: "a", Name: "api", State: "running", Health: "healthy"}, {ID: "b", Name: "database", State: "running", Health: "healthy"}}, Total: 2}
+ response := httptest.NewRecorder()
+ Handler{Provider: provider{value: snapshot}}.ServeHTTP(response, authRequest(http.MethodGet, "/api/v1/containers?limit=1&q=database&state=running&health=healthy&sort=name"))
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"name":"database"`) || !strings.Contains(response.Body.String(), `"total":1`) {
+ t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
+ }
+}
diff --git a/internal/correlation/correlation.go b/internal/correlation/correlation.go
new file mode 100644
index 0000000..dd6191a
--- /dev/null
+++ b/internal/correlation/correlation.go
@@ -0,0 +1,43 @@
+package correlation
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "net/http"
+ "regexp"
+)
+
+const Header = "X-Correlation-ID"
+
+type contextKey struct{}
+
+var validID = regexp.MustCompile(`^[A-Za-z0-9._:-]{8,64}$`)
+
+func New() string {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "correlation-unavailable"
+ }
+ return hex.EncodeToString(bytes)
+}
+
+func FromContext(ctx context.Context) string {
+ value, _ := ctx.Value(contextKey{}).(string)
+ return value
+}
+
+func WithContext(ctx context.Context, id string) context.Context {
+ return context.WithValue(ctx, contextKey{}, id)
+}
+
+func Middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ id := request.Header.Get(Header)
+ if !validID.MatchString(id) {
+ id = New()
+ }
+ response.Header().Set(Header, id)
+ next.ServeHTTP(response, request.WithContext(WithContext(request.Context(), id)))
+ })
+}
diff --git a/internal/correlation/correlation_test.go b/internal/correlation/correlation_test.go
new file mode 100644
index 0000000..d0cd155
--- /dev/null
+++ b/internal/correlation/correlation_test.go
@@ -0,0 +1,39 @@
+package correlation
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestMiddlewarePreservesValidCorrelationID(t *testing.T) {
+ handler := Middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ if got := FromContext(request.Context()); got != "request-123" {
+ t.Errorf("context correlation ID = %q", got)
+ }
+ response.WriteHeader(http.StatusNoContent)
+ }))
+ request := httptest.NewRequest(http.MethodGet, "/", nil)
+ request.Header.Set(Header, "request-123")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Header().Get(Header) != "request-123" {
+ t.Fatalf("response correlation ID = %q", response.Header().Get(Header))
+ }
+}
+
+func TestMiddlewareReplacesInvalidCorrelationID(t *testing.T) {
+ handler := Middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ if len(FromContext(request.Context())) < 8 {
+ t.Error("generated correlation ID is too short")
+ }
+ response.WriteHeader(http.StatusNoContent)
+ }))
+ request := httptest.NewRequest(http.MethodGet, "/", nil)
+ request.Header.Set(Header, "secret\nforged")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Header().Get(Header) == "secret\nforged" || response.Header().Get(Header) == "" {
+ t.Fatalf("invalid correlation ID was not replaced: %q", response.Header().Get(Header))
+ }
+}
diff --git a/internal/dashboard/document.go b/internal/dashboard/document.go
new file mode 100644
index 0000000..205af95
--- /dev/null
+++ b/internal/dashboard/document.go
@@ -0,0 +1,86 @@
+package dashboard
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "regexp"
+)
+
+const CurrentSchemaVersion = 2
+
+var slugPattern = regexp.MustCompile("^[a-z0-9]+(?:-[a-z0-9]+)*$")
+
+type Document map[string]any
+
+func Validate(document Document) error {
+ if document == nil {
+ return errors.New("dashboard document is required")
+ }
+ if version, ok := number(document["schemaVersion"]); !ok || version < 1 || version > CurrentSchemaVersion {
+ return errors.New("unsupported dashboard schema version")
+ }
+ for _, field := range []string{"id", "slug", "name", "scope", "variables", "widgets", "settings"} {
+ if _, ok := document[field]; !ok {
+ return fmt.Errorf("dashboard field %q is required", field)
+ }
+ }
+ slug, ok := document["slug"].(string)
+ if !ok || !slugPattern.MatchString(slug) || len(slug) > 80 {
+ return errors.New("invalid dashboard slug")
+ }
+ name, ok := document["name"].(string)
+ if !ok || name == "" || len(name) > 120 {
+ return errors.New("invalid dashboard name")
+ }
+ scope, ok := document["scope"].(string)
+ if !ok || scope != "personal" && scope != "shared" && scope != "system" {
+ return errors.New("invalid dashboard scope")
+ }
+ if _, ok := document["widgets"].([]any); !ok {
+ return errors.New("dashboard widgets must be an array")
+ }
+ if _, ok := document["variables"].([]any); !ok {
+ return errors.New("dashboard variables must be an array")
+ }
+ if _, ok := document["settings"].(map[string]any); !ok {
+ return errors.New("dashboard settings must be an object")
+ }
+ return nil
+}
+
+func Migrate(document Document) (Document, error) {
+ if err := Validate(document); err != nil {
+ return nil, err
+ }
+ version, _ := number(document["schemaVersion"])
+ if version == CurrentSchemaVersion {
+ return clone(document), nil
+ }
+ migrated := clone(document)
+ migrated["schemaVersion"] = CurrentSchemaVersion
+ settings := migrated["settings"].(map[string]any)
+ if _, ok := settings["live"]; !ok {
+ settings["live"] = false
+ }
+ if _, ok := settings["refreshSeconds"]; !ok {
+ settings["refreshSeconds"] = 30
+ }
+ return migrated, nil
+}
+func clone(document Document) Document {
+ encoded, _ := json.Marshal(document)
+ var result Document
+ _ = json.Unmarshal(encoded, &result)
+ return result
+}
+func number(value any) (int, bool) {
+ switch v := value.(type) {
+ case int:
+ return v, true
+ case float64:
+ return int(v), v == float64(int(v))
+ default:
+ return 0, false
+ }
+}
diff --git a/internal/dashboard/document_test.go b/internal/dashboard/document_test.go
new file mode 100644
index 0000000..2e29091
--- /dev/null
+++ b/internal/dashboard/document_test.go
@@ -0,0 +1,32 @@
+package dashboard
+
+import "testing"
+
+func valid() Document {
+ return Document{"schemaVersion": 1, "id": "00000000-0000-0000-0000-000000000001", "slug": "overview", "name": "Overview", "scope": "system", "variables": []any{}, "widgets": []any{}, "settings": map[string]any{}}
+}
+func TestInvalidDocumentRejected(t *testing.T) {
+ doc := valid()
+ delete(doc, "widgets")
+ if err := Validate(doc); err == nil {
+ t.Fatal("expected invalid document rejection")
+ }
+}
+func TestMigrationIsDeterministicAndPreservesCustomSettings(t *testing.T) {
+ doc := valid()
+ doc["settings"].(map[string]any)["refreshSeconds"] = 90
+ first, err := Migrate(doc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := Migrate(doc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first["schemaVersion"] != 2 || first["settings"].(map[string]any)["refreshSeconds"] != float64(90) {
+ t.Fatalf("migration overwrote custom value: %+v", first)
+ }
+ if len(first["widgets"].([]any)) != len(second["widgets"].([]any)) {
+ t.Fatal("migration not deterministic")
+ }
+}
diff --git a/internal/dashboard/errors.go b/internal/dashboard/errors.go
new file mode 100644
index 0000000..e252e14
--- /dev/null
+++ b/internal/dashboard/errors.go
@@ -0,0 +1,14 @@
+package dashboard
+
+import (
+ "errors"
+ "github.com/jackc/pgx/v5/pgconn"
+)
+
+func mapDatabaseError(err error) error {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ return ErrConflict
+ }
+ return err
+}
diff --git a/internal/dashboard/immutability_integration_test.go b/internal/dashboard/immutability_integration_test.go
new file mode 100644
index 0000000..7200e3c
--- /dev/null
+++ b/internal/dashboard/immutability_integration_test.go
@@ -0,0 +1,42 @@
+package dashboard
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func TestDashboardVersionIsImmutableInPostgreSQL(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ const dashboardID = "00000000-0000-0000-0000-0000000000d1"
+ const versionID = "00000000-0000-0000-0000-0000000000f1"
+ _, _ = pool.Exec(ctx, `DELETE FROM dashboards WHERE id=$1`, dashboardID)
+ if _, err := pool.Exec(ctx, `INSERT INTO dashboards (id,slug,name,scope) VALUES ($1,'m3-test','M3 test','system')`, dashboardID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO dashboard_versions (id,dashboard_id,version_number,schema_version,document) VALUES ($1,$2,1,1,'{}')`, versionID, dashboardID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE dashboard_versions SET change_summary='mutated' WHERE id=$1`, versionID); err == nil {
+ t.Fatal("expected immutable update failure")
+ }
+ if _, err := pool.Exec(ctx, `DELETE FROM dashboards WHERE id=$1`, dashboardID); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/dashboard/repository.go b/internal/dashboard/repository.go
new file mode 100644
index 0000000..e740205
--- /dev/null
+++ b/internal/dashboard/repository.go
@@ -0,0 +1,284 @@
+package dashboard
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+var ErrConflict = errors.New("dashboard revision conflict")
+var ErrNotFound = errors.New("dashboard not found")
+var ErrForbidden = errors.New("dashboard access denied")
+
+type Summary struct {
+ ID, Slug, Name, Description, OwnerID, Scope string
+ ArchivedAt *time.Time
+ Revision int64
+ CurrentVersion int
+}
+type Version struct {
+ ID, DashboardID string
+ Number, SchemaVersion int
+ Document Document
+ ChangeSummary, CreatedBy string
+ CreatedAt time.Time
+}
+type Repository struct{ Pool *pgxpool.Pool }
+
+func (r Repository) CanAccess(ctx context.Context, id, actor string) (bool, error) {
+ if r.Pool == nil {
+ return false, errors.New("dashboard repository is not configured")
+ }
+ var allowed bool
+ err := r.Pool.QueryRow(ctx, `SELECT EXISTS (
+ SELECT 1 FROM dashboards
+ WHERE id=$1 AND archived_at IS NULL
+ AND (scope IN ('shared','system') OR owner_user_id=(SELECT id FROM users WHERE external_subject=$2))
+ )`, id, actor).Scan(&allowed)
+ if err != nil {
+ return false, fmt.Errorf("check dashboard access: %w", err)
+ }
+ return allowed, nil
+}
+
+func (r Repository) Create(ctx context.Context, actor string, document Document, changeSummary string) (Summary, Version, error) {
+ if err := Validate(document); err != nil {
+ return Summary{}, Version{}, err
+ }
+ id, ok := document["id"].(string)
+ if !ok || id == "" {
+ return Summary{}, Version{}, errors.New("dashboard id is required")
+ }
+ slug, _ := document["slug"].(string)
+ name, _ := document["name"].(string)
+ scope, _ := document["scope"].(string)
+ description, _ := document["description"].(string)
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Summary{}, Version{}, fmt.Errorf("begin dashboard create: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ docJSON, _ := json.Marshal(document)
+ versionID := idForVersion(id, 1)
+ if _, err = tx.Exec(ctx, `INSERT INTO dashboards (id,slug,name,description,owner_user_id,scope) VALUES ($1,$2,$3,$4,(SELECT id FROM users WHERE external_subject=$5),$6)`, id, slug, name, description, actor, scope); err != nil {
+ err = mapDatabaseError(err)
+ return Summary{}, Version{}, fmt.Errorf("create dashboard: %w", err)
+ }
+ if _, err = tx.Exec(ctx, `INSERT INTO dashboard_versions (id,dashboard_id,version_number,schema_version,document,change_summary,created_by) VALUES ($1,$2,1,$3,$4,$5,(SELECT id FROM users WHERE external_subject=$6))`, versionID, id, CurrentSchemaVersion, docJSON, changeSummary, actor); err != nil {
+ return Summary{}, Version{}, fmt.Errorf("create dashboard version: %w", err)
+ }
+ if _, err = tx.Exec(ctx, `UPDATE dashboards SET current_version_id=$1 WHERE id=$2`, versionID, id); err != nil {
+ return Summary{}, Version{}, fmt.Errorf("set current dashboard version: %w", err)
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return Summary{}, Version{}, fmt.Errorf("commit dashboard create: %w", err)
+ }
+ createdSummary, createdVersion, err := r.Get(ctx, id, actor)
+ if err != nil {
+ return Summary{}, Version{}, err
+ }
+ return createdSummary, createdVersion, nil
+}
+
+func (r Repository) UpdateDocument(ctx context.Context, id, actor string, expected int64, document Document, summary string) (Summary, error) {
+ if err := Validate(document); err != nil {
+ return Summary{}, err
+ }
+ tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return Summary{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var currentVersionID string
+ var currentVersion int
+ var currentJSON []byte
+ var current Summary
+ var canEdit bool
+ err = tx.QueryRow(ctx, `SELECT id,slug,name,description,COALESCE(owner_user_id::text,''),scope,archived_at,revision,current_version_id,(owner_user_id=(SELECT id FROM users WHERE external_subject=$2) OR scope IN ('shared','system')) FROM dashboards WHERE id=$1 FOR UPDATE`, id, actor).Scan(¤t.ID, ¤t.Slug, ¤t.Name, ¤t.Description, ¤t.OwnerID, ¤t.Scope, ¤t.ArchivedAt, ¤t.Revision, ¤tVersionID, &canEdit)
+ if err != nil {
+ return Summary{}, err
+ }
+ if !canEdit {
+ return Summary{}, ErrForbidden
+ }
+ if current.Revision != expected {
+ return Summary{}, ErrConflict
+ }
+ if err = tx.QueryRow(ctx, `SELECT version_number,document FROM dashboard_versions WHERE id=$1`, currentVersionID).Scan(¤tVersion, ¤tJSON); err != nil {
+ return Summary{}, err
+ }
+ newJSON, _ := json.Marshal(document)
+ var stored, normalized Document
+ _ = json.Unmarshal(currentJSON, &stored)
+ _ = json.Unmarshal(newJSON, &normalized)
+ if reflect.DeepEqual(stored, normalized) {
+ current.CurrentVersion = currentVersion
+ return current, nil
+ }
+ next := currentVersion + 1
+ versionID := idForVersion(id, next)
+ if _, err = tx.Exec(ctx, `INSERT INTO dashboard_versions (id,dashboard_id,version_number,schema_version,document,change_summary,created_by) VALUES ($1,$2,$3,$4,$5,$6,(SELECT id FROM users WHERE external_subject=$7))`, versionID, id, next, CurrentSchemaVersion, newJSON, summary, actor); err != nil {
+ return Summary{}, err
+ }
+ tag, err := tx.Exec(ctx, `UPDATE dashboards SET current_version_id=$1,revision=revision+1,updated_at=now() WHERE id=$2 AND revision=$3`, versionID, id, expected)
+ if err != nil {
+ return Summary{}, err
+ }
+ if tag.RowsAffected() != 1 {
+ return Summary{}, ErrConflict
+ }
+ current.Revision++
+ current.CurrentVersion = next
+ if err = tx.Commit(ctx); err != nil {
+ return Summary{}, err
+ }
+ return current, nil
+}
+
+func (r Repository) Restore(ctx context.Context, id, actor string, expected int64, versionNumber int) (Summary, error) {
+ version, err := r.GetVersion(ctx, id, actor, versionNumber)
+ if err != nil {
+ return Summary{}, err
+ }
+ return r.UpdateDocument(ctx, id, actor, expected, version.Document, fmt.Sprintf("restore version %d", versionNumber))
+}
+
+func (r Repository) Get(ctx context.Context, id, actor string) (Summary, Version, error) {
+ var s Summary
+ var versionID string
+ err := r.Pool.QueryRow(ctx, `SELECT id,slug,name,description,COALESCE(owner_user_id::text,''),scope,archived_at,revision,current_version_id FROM dashboards WHERE id=$1 AND (scope IN ('shared','system') OR owner_user_id=(SELECT id FROM users WHERE external_subject=$2))`, id, actor).Scan(&s.ID, &s.Slug, &s.Name, &s.Description, &s.OwnerID, &s.Scope, &s.ArchivedAt, &s.Revision, &versionID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Summary{}, Version{}, ErrNotFound
+ }
+ if err != nil {
+ return Summary{}, Version{}, err
+ }
+ var raw []byte
+ var v Version
+ err = r.Pool.QueryRow(ctx, `SELECT id,dashboard_id,version_number,schema_version,document,change_summary,COALESCE(created_by::text,''),created_at FROM dashboard_versions WHERE id=$1`, versionID).Scan(&v.ID, &v.DashboardID, &v.Number, &v.SchemaVersion, &raw, &v.ChangeSummary, &v.CreatedBy, &v.CreatedAt)
+ if err != nil {
+ return Summary{}, Version{}, err
+ }
+ if err = json.Unmarshal(raw, &v.Document); err != nil {
+ return Summary{}, Version{}, errors.New("invalid stored dashboard document")
+ }
+ s.CurrentVersion = v.Number
+ return s, v, nil
+}
+
+func (r Repository) List(ctx context.Context, actor string, limit int) ([]Summary, error) {
+ if limit < 1 || limit > 100 {
+ return nil, errors.New("dashboard page limit is invalid")
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT d.id,d.slug,d.name,d.description,COALESCE(d.owner_user_id::text,''),d.scope,d.archived_at,d.revision,v.version_number FROM dashboards d JOIN dashboard_versions v ON v.id=d.current_version_id WHERE d.scope <> 'personal' OR d.owner_user_id=(SELECT id FROM users WHERE external_subject=$1) ORDER BY d.name ASC,d.id ASC LIMIT $2`, actor, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list dashboards: %w", err)
+ }
+ defer rows.Close()
+ result := make([]Summary, 0, limit)
+ for rows.Next() {
+ var s Summary
+ if err := rows.Scan(&s.ID, &s.Slug, &s.Name, &s.Description, &s.OwnerID, &s.Scope, &s.ArchivedAt, &s.Revision, &s.CurrentVersion); err != nil {
+ return nil, fmt.Errorf("scan dashboard: %w", err)
+ }
+ result = append(result, s)
+ }
+ return result, rows.Err()
+}
+
+func (r Repository) UpdateMetadata(ctx context.Context, id, actor string, expected int64, name, description string) (Summary, error) {
+ tag, err := r.Pool.Exec(ctx, `UPDATE dashboards SET name=$1,description=$2,revision=revision+1,updated_at=now() WHERE id=$3 AND revision=$4 AND (owner_user_id=(SELECT id FROM users WHERE external_subject=$5) OR scope IN ('shared','system'))`, name, description, id, expected, actor)
+ if err != nil {
+ return Summary{}, fmt.Errorf("update dashboard metadata: %w", err)
+ }
+ if tag.RowsAffected() != 1 {
+ return Summary{}, ErrConflict
+ }
+ s, _, err := r.Get(ctx, id, actor)
+ return s, err
+}
+
+func (r Repository) Archive(ctx context.Context, id, actor string, expected int64) (Summary, error) {
+ tag, err := r.Pool.Exec(ctx, `UPDATE dashboards SET archived_at=now(),revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$2 AND (owner_user_id=(SELECT id FROM users WHERE external_subject=$3) OR scope IN ('shared','system'))`, id, expected, actor)
+ if err != nil {
+ return Summary{}, fmt.Errorf("archive dashboard: %w", err)
+ }
+ if tag.RowsAffected() != 1 {
+ return Summary{}, ErrConflict
+ }
+ s, _, err := r.Get(ctx, id, actor)
+ return s, err
+}
+
+func (r Repository) Versions(ctx context.Context, id, actor string, limit int) ([]Version, error) {
+ if limit < 1 || limit > 100 {
+ return nil, errors.New("version page limit is invalid")
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT v.id,v.dashboard_id,v.version_number,v.schema_version,v.document,v.change_summary,COALESCE(v.created_by::text,''),v.created_at FROM dashboard_versions v JOIN dashboards d ON d.id=v.dashboard_id WHERE v.dashboard_id=$1 AND (d.scope IN ('shared','system') OR d.owner_user_id=(SELECT id FROM users WHERE external_subject=$2)) ORDER BY v.version_number DESC LIMIT $3`, id, actor, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list dashboard versions: %w", err)
+ }
+ defer rows.Close()
+ result := make([]Version, 0, limit)
+ for rows.Next() {
+ var v Version
+ var raw []byte
+ if err := rows.Scan(&v.ID, &v.DashboardID, &v.Number, &v.SchemaVersion, &raw, &v.ChangeSummary, &v.CreatedBy, &v.CreatedAt); err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(raw, &v.Document); err != nil {
+ return nil, errors.New("invalid stored dashboard document")
+ }
+ result = append(result, v)
+ }
+ return result, rows.Err()
+}
+func (r Repository) Clone(ctx context.Context, actor, sourceID, slug, name string) (Summary, Version, error) {
+ summary, version, err := r.Get(ctx, sourceID, actor)
+ if err != nil {
+ return Summary{}, Version{}, err
+ }
+ var allowed bool
+ if err := r.Pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM dashboards WHERE id=$1 AND (scope <> 'personal' OR owner_user_id=(SELECT id FROM users WHERE external_subject=$2)))`, sourceID, actor).Scan(&allowed); err != nil {
+ return Summary{}, Version{}, err
+ }
+ if !allowed {
+ return Summary{}, Version{}, ErrForbidden
+ }
+ if slug == "" {
+ slug = summary.Slug + "-copy"
+ }
+ if name == "" {
+ name = summary.Name + " (kopie)"
+ }
+ copyDoc := clone(version.Document)
+ copyDoc["id"] = newUUID()
+ copyDoc["slug"] = slug
+ copyDoc["name"] = name
+ copyDoc["scope"] = "personal"
+ return r.Create(ctx, actor, copyDoc, "cloned dashboard")
+}
+
+func newUUID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "00000000-0000-4000-8000-000000000000"
+ }
+ b[6] = (b[6] & 0x0f) | 0x40
+ b[8] = (b[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
+}
+func idForVersion(id string, number int) string {
+ digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", id, number)))
+ digest[6] = (digest[6] & 0x0f) | 0x50
+ digest[8] = (digest[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", digest[0:4], digest[4:6], digest[6:8], digest[8:10], digest[10:16])
+}
diff --git a/internal/dashboard/repository_integration_test.go b/internal/dashboard/repository_integration_test.go
new file mode 100644
index 0000000..3f99bc8
--- /dev/null
+++ b/internal/dashboard/repository_integration_test.go
@@ -0,0 +1,113 @@
+package dashboard
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func TestDashboardRepositoryPostgreSQL(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 10, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+
+ actor := "00000000-0000-0000-0000-0000000003a1"
+ otherActor := "00000000-0000-0000-0000-0000000003a2"
+ if _, err := pool.Exec(ctx, "INSERT INTO users (id, external_subject, display_name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", actor, actor, "M3 actor"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, "INSERT INTO users (id, external_subject, display_name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", otherActor, otherActor, "M3 other actor"); err != nil {
+ t.Fatal(err)
+ }
+ repo := Repository{Pool: pool}
+ id := "00000000-0000-0000-0000-0000000003d1"
+ _, _ = pool.Exec(ctx, "DELETE FROM dashboards WHERE id=$1 OR slug IN ('m3-repo','m3-repo-copy')", id)
+ doc := Document{"schemaVersion": 2, "id": id, "slug": "m3-repo", "name": "M3 Repo", "scope": "personal", "variables": []any{}, "widgets": []any{}, "settings": map[string]any{"refreshSeconds": 30}}
+
+ summary, version, err := repo.Create(ctx, actor, doc, "initial")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if version.Number != 1 || summary.Revision != 1 {
+ t.Fatalf("create=%+v/%+v", summary, version)
+ }
+ same, err := repo.UpdateDocument(ctx, id, actor, 1, doc, "noop")
+ if err != nil || same.Revision != 1 {
+ t.Fatalf("noop=%+v err=%v", same, err)
+ }
+ doc["name"] = "Changed"
+ updated, err := repo.UpdateDocument(ctx, id, actor, 1, doc, "edit")
+ if err != nil || updated.Revision != 2 || updated.CurrentVersion != 2 {
+ t.Fatalf("update=%+v err=%v", updated, err)
+ }
+ if _, err := repo.UpdateDocument(ctx, id, actor, 1, doc, "stale"); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected conflict, got %v", err)
+ }
+ restored, err := repo.Restore(ctx, id, actor, 2, 1)
+ if err != nil || restored.Revision != 3 || restored.CurrentVersion != 3 {
+ t.Fatalf("restore=%+v err=%v", restored, err)
+ }
+
+ versions, err := repo.Versions(ctx, id, actor, 10)
+ if err != nil || len(versions) != 3 {
+ t.Fatalf("versions=%d err=%v", len(versions), err)
+ }
+ historical, err := repo.GetVersion(ctx, id, actor, 1)
+ if err != nil || historical.Number != 1 {
+ t.Fatalf("historical=%+v err=%v", historical, err)
+ }
+ if _, _, err := repo.Get(ctx, id, otherActor); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("personal dashboard leaked through direct read: %v", err)
+ }
+ if otherVersions, err := repo.Versions(ctx, id, otherActor, 10); err != nil || len(otherVersions) != 0 {
+ t.Fatalf("personal dashboard versions leaked: count=%d err=%v", len(otherVersions), err)
+ }
+ if _, err := repo.GetVersion(ctx, id, otherActor, 1); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("personal dashboard version leaked through direct read: %v", err)
+ }
+ if _, _, err := repo.Create(ctx, actor, doc, "duplicate"); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected duplicate conflict, got %v", err)
+ }
+
+ cloned, clonedVersion, err := repo.Clone(ctx, actor, id, "m3-repo-copy", "M3 Repo Copy")
+ if err != nil || cloned.ID == id || clonedVersion.Number != 1 || cloned.Scope != "personal" {
+ t.Fatalf("clone=%+v/%+v err=%v", cloned, clonedVersion, err)
+ }
+ listed, err := repo.List(ctx, actor, 10)
+ if err != nil || len(listed) < 2 {
+ t.Fatalf("list=%d err=%v", len(listed), err)
+ }
+ edited, err := repo.UpdateMetadata(ctx, id, actor, 3, "M3 Repo Renamed", "updated")
+ if err != nil || edited.Revision != 4 {
+ t.Fatalf("metadata=%+v err=%v", edited, err)
+ }
+ archived, err := repo.Archive(ctx, id, actor, 4)
+ if err != nil || archived.ArchivedAt == nil || archived.Revision != 5 {
+ t.Fatalf("archive=%+v err=%v", archived, err)
+ }
+
+ if _, err := pool.Exec(ctx, "UPDATE dashboard_versions SET change_summary='bad' WHERE dashboard_id=$1", id); err == nil {
+ t.Fatal("expected immutable version failure")
+ }
+ if _, err := pool.Exec(ctx, "DELETE FROM dashboards WHERE id=$1", id); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, "DELETE FROM dashboards WHERE id=$1", cloned.ID); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/dashboard/repository_scale_integration_test.go b/internal/dashboard/repository_scale_integration_test.go
new file mode 100644
index 0000000..71f0143
--- /dev/null
+++ b/internal/dashboard/repository_scale_integration_test.go
@@ -0,0 +1,60 @@
+package dashboard
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func TestDashboardListTargetScalePostgreSQL(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 10, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ actor := "00000000-0000-0000-0000-0000000003a1"
+ if _, err := pool.Exec(ctx, "INSERT INTO users (id, external_subject, display_name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", actor, actor, "M3 scale actor"); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _, _ = pool.Exec(context.Background(), "DELETE FROM dashboards WHERE slug LIKE 'm3-scale-%'") }()
+ repo := Repository{Pool: pool}
+ for i := 0; i < 150; i++ {
+ id := fmt.Sprintf("00000000-0000-0000-0000-%012x", 0x5000+i)
+ doc := Document{"schemaVersion": 2, "id": id, "slug": fmt.Sprintf("m3-scale-%03d", i), "name": fmt.Sprintf("M3 Scale %03d", i), "scope": "personal", "variables": []any{}, "widgets": []any{}, "settings": map[string]any{"refreshSeconds": 30}}
+ if _, _, err := repo.Create(ctx, actor, doc, "scale fixture"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ samples := make([]time.Duration, 20)
+ for i := range samples {
+ start := time.Now()
+ items, err := repo.List(ctx, actor, 100)
+ samples[i] = time.Since(start)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) < 100 {
+ t.Fatalf("list returned %d items", len(items))
+ }
+ }
+ sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
+ p95 := samples[len(samples)*95/100-1]
+ t.Logf("target-scale dashboards=150 list_limit=100 p95=%s max=%s", p95, samples[len(samples)-1])
+ if p95 > 250*time.Millisecond {
+ t.Fatalf("dashboard list p95=%s exceeds 250ms budget", p95)
+ }
+}
diff --git a/internal/dashboard/version.go b/internal/dashboard/version.go
new file mode 100644
index 0000000..b678fca
--- /dev/null
+++ b/internal/dashboard/version.go
@@ -0,0 +1,24 @@
+package dashboard
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "github.com/jackc/pgx/v5"
+)
+
+func (r Repository) GetVersion(ctx context.Context, id, actor string, number int) (Version, error) {
+ var v Version
+ var raw []byte
+ err := r.Pool.QueryRow(ctx, `SELECT v.id,v.dashboard_id,v.version_number,v.schema_version,v.document,v.change_summary,COALESCE(v.created_by::text,''),v.created_at FROM dashboard_versions v JOIN dashboards d ON d.id=v.dashboard_id WHERE v.dashboard_id=$1 AND v.version_number=$2 AND (d.scope IN ('shared','system') OR d.owner_user_id=(SELECT id FROM users WHERE external_subject=$3))`, id, number, actor).Scan(&v.ID, &v.DashboardID, &v.Number, &v.SchemaVersion, &raw, &v.ChangeSummary, &v.CreatedBy, &v.CreatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Version{}, ErrNotFound
+ }
+ if err != nil {
+ return Version{}, err
+ }
+ if err := json.Unmarshal(raw, &v.Document); err != nil {
+ return Version{}, errors.New("invalid stored dashboard document")
+ }
+ return v, nil
+}
diff --git a/internal/dashboardapi/handler.go b/internal/dashboardapi/handler.go
new file mode 100644
index 0000000..d7044eb
--- /dev/null
+++ b/internal/dashboardapi/handler.go
@@ -0,0 +1,407 @@
+package dashboardapi
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/correlation"
+ "github.com/itworx/pulse/internal/dashboard"
+ "github.com/itworx/pulse/internal/problem"
+ "github.com/itworx/pulse/internal/widgetpreview"
+)
+
+type Handler struct {
+ Repository dashboard.Repository
+ Audit audit.Store
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ principal, ok := auth.PrincipalFromContext(r.Context())
+ if !ok {
+ fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
+ return
+ }
+ path := strings.TrimPrefix(r.URL.Path, "/api/v1/dashboards")
+ if path == "" || path == "/" {
+ switch r.Method {
+ case http.MethodGet:
+ h.list(w, r, principal.Subject)
+ case http.MethodPost:
+ if !auth.Allows(principal.Role, auth.PermissionEdit) {
+ fail(w, r, http.StatusForbidden, "FORBIDDEN", "Dashboard editing is not allowed for this role.")
+ return
+ }
+ h.create(w, r, principal.Subject)
+ default:
+ fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
+ }
+ return
+ }
+ parts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(parts) == 0 || parts[0] == "" {
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", "Dashboard not found.")
+ return
+ }
+ id := parts[0]
+ if len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodGet {
+ h.versions(w, r, id, principal.Subject)
+ return
+ }
+ if len(parts) == 3 && parts[1] == "versions" && r.Method == http.MethodGet {
+ h.version(w, r, id, parts[2], principal.Subject)
+ return
+ }
+ if len(parts) == 2 && parts[1] == "document" && r.Method == http.MethodPut {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.update(w, r, id, principal.Subject)
+ return
+ }
+ if len(parts) == 2 && parts[1] == "preview" && r.Method == http.MethodPost {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.preview(w, r, id, principal.Subject)
+ return
+ }
+ if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.clone(w, r, id, principal.Subject)
+ return
+ }
+ if len(parts) == 2 && parts[1] == "restore" && r.Method == http.MethodPost {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.restore(w, r, id, principal.Subject, "")
+ return
+ }
+ if len(parts) == 3 && parts[1] == "restore" && r.Method == http.MethodPost {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.restore(w, r, id, principal.Subject, parts[2])
+ return
+ }
+ if len(parts) == 1 && r.Method == http.MethodGet {
+ h.get(w, r, id, principal.Subject)
+ return
+ }
+ if len(parts) == 1 && r.Method == http.MethodPatch {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.metadata(w, r, id, principal.Subject)
+ return
+ }
+ if len(parts) == 1 && r.Method == http.MethodDelete {
+ if !requireEdit(w, r, principal.Role) {
+ return
+ }
+ h.archive(w, r, id, principal.Subject)
+ return
+ }
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", "Dashboard route not found.")
+}
+
+func requireEdit(w http.ResponseWriter, r *http.Request, role auth.Role) bool {
+ if auth.Allows(role, auth.PermissionEdit) {
+ return true
+ }
+ fail(w, r, http.StatusForbidden, "FORBIDDEN", "Dashboard editing is not allowed for this role.")
+ return false
+}
+
+func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string) {
+ var doc dashboard.Document
+ if err := decode(r, &doc); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_DOCUMENT", "The dashboard document is invalid.")
+ return
+ }
+ s, v, err := h.Repository.Create(r.Context(), actor, doc, "initial version")
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard could not be created.")
+ return
+ }
+ if err := h.record(r, actor, "dashboard.create", s.ID, nil, map[string]any{"revision": s.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusCreated, map[string]any{"dashboard": s, "version": v})
+}
+
+func (h Handler) list(w http.ResponseWriter, r *http.Request, actor string) {
+ limit := 100
+ if value := r.URL.Query().Get("limit"); value != "" {
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed < 1 || parsed > 100 {
+ fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The dashboard limit must be between 1 and 100.")
+ return
+ }
+ limit = parsed
+ }
+ items, err := h.Repository.List(r.Context(), actor, limit)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard list unavailable.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"items": items})
+}
+
+func (h Handler) get(w http.ResponseWriter, r *http.Request, id, actor string) {
+ s, v, err := h.Repository.Get(r.Context(), id, actor)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard not found.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"dashboard": s, "version": v})
+}
+
+func (h Handler) preview(w http.ResponseWriter, r *http.Request, id, actor string) {
+ allowed, err := h.Repository.CanAccess(r.Context(), id, actor)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard preview unavailable.")
+ return
+ }
+ if !allowed {
+ fail(w, r, http.StatusForbidden, "FORBIDDEN", "You cannot preview this dashboard.")
+ return
+ }
+ var body struct {
+ Widget map[string]any
+ State string
+ }
+ if err := decode(r, &body); err != nil || body.Widget == nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "A widget configuration is required.")
+ return
+ }
+ result, err := widgetpreview.Preview(body.Widget, body.State)
+ if err != nil {
+ var invalid widgetpreview.InvalidConfig
+ if errors.As(err, &invalid) {
+ fail(w, r, http.StatusBadRequest, "INVALID_WIDGET_CONFIG", "The widget configuration is invalid.", invalid.Fields)
+ return
+ }
+ fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The widget preview is invalid.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"preview": result})
+}
+
+func (h Handler) update(w http.ResponseWriter, r *http.Request, id, actor string) {
+ expected, err := revision(r)
+ if err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
+ return
+ }
+ var doc dashboard.Document
+ if err := decode(r, &doc); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_DOCUMENT", "The dashboard document is invalid.")
+ return
+ }
+ s, err := h.Repository.UpdateDocument(r.Context(), id, actor, expected, doc, "document update")
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard update failed.")
+ return
+ }
+ if err := h.record(r, actor, "dashboard.update_document", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"dashboard": s})
+}
+
+func (h Handler) metadata(w http.ResponseWriter, r *http.Request, id, actor string) {
+ expected, err := revision(r)
+ if err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
+ return
+ }
+ var body struct {
+ Name string
+ Description string
+ }
+ if err := decode(r, &body); err != nil || strings.TrimSpace(body.Name) == "" {
+ fail(w, r, http.StatusBadRequest, "INVALID_METADATA", "A dashboard name is required.")
+ return
+ }
+ s, err := h.Repository.UpdateMetadata(r.Context(), id, actor, expected, body.Name, body.Description)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Metadata update failed.")
+ return
+ }
+ if err := h.record(r, actor, "dashboard.update_metadata", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"dashboard": s})
+}
+
+func (h Handler) archive(w http.ResponseWriter, r *http.Request, id, actor string) {
+ expected, err := revision(r)
+ if err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
+ return
+ }
+ s, err := h.Repository.Archive(r.Context(), id, actor, expected)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard archive failed.")
+ return
+ }
+ if err := h.record(r, actor, "dashboard.archive", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"dashboard": s})
+}
+
+func (h Handler) versions(w http.ResponseWriter, r *http.Request, id, actor string) {
+ items, err := h.Repository.Versions(r.Context(), id, actor, 100)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Version history unavailable.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"items": items})
+}
+
+func (h Handler) version(w http.ResponseWriter, r *http.Request, id, value, actor string) {
+ number, err := strconv.Atoi(value)
+ if err != nil || number < 1 {
+ fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "The version number is invalid.")
+ return
+ }
+ item, err := h.Repository.GetVersion(r.Context(), id, actor, number)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard version not found.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"version": item})
+}
+
+func (h Handler) clone(w http.ResponseWriter, r *http.Request, id, actor string) {
+ var body struct {
+ Slug string
+ Name string
+ }
+ if err := decode(r, &body); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_CLONE", "The clone options are invalid.")
+ return
+ }
+ s, v, err := h.Repository.Clone(r.Context(), actor, id, body.Slug, body.Name)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard could not be cloned.")
+ return
+ }
+ if err := h.record(r, actor, "dashboard.clone", s.ID, nil, map[string]any{"revision": s.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusCreated, map[string]any{"dashboard": s, "version": v})
+}
+
+func (h Handler) restore(w http.ResponseWriter, r *http.Request, id, actor, value string) {
+ number := value
+ if number == "" {
+ var body struct{ Version int }
+ if err := decode(r, &body); err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "A version number is required.")
+ return
+ }
+ number = strconv.Itoa(body.Version)
+ }
+ versionNumber, err := strconv.Atoi(number)
+ if err != nil || versionNumber < 1 {
+ fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "The version number is invalid.")
+ return
+ }
+ expected, err := revision(r)
+ if err != nil {
+ fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
+ return
+ }
+ s, err := h.Repository.Restore(r.Context(), id, actor, expected, versionNumber)
+ if err != nil {
+ h.repositoryFailure(w, r, err, "Dashboard could not be restored.")
+ return
+ }
+ if err := h.record(r, actor, "dashboard.restore", s.ID, map[string]any{"version": versionNumber}, map[string]any{"revision": s.Revision}); err != nil {
+ fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
+ return
+ }
+ write(w, http.StatusOK, map[string]any{"dashboard": s})
+}
+
+func (h Handler) record(r *http.Request, actor, action, resourceID string, before, after map[string]any) error {
+ if h.Audit == nil {
+ return nil
+ }
+ return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "dashboard", ResourceID: resourceID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after})
+}
+
+func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error, fallback string) {
+ switch {
+ case errors.Is(err, dashboard.ErrConflict):
+ fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The dashboard was changed by another request.")
+ case errors.Is(err, dashboard.ErrForbidden):
+ fail(w, r, http.StatusForbidden, "FORBIDDEN", "You cannot change this dashboard.")
+ case errors.Is(err, dashboard.ErrNotFound):
+ fail(w, r, http.StatusNotFound, "NOT_FOUND", fallback)
+ default:
+ fail(w, r, http.StatusBadRequest, "DASHBOARD_REQUEST_FAILED", fallback)
+ }
+}
+
+func decode(r *http.Request, target any) error {
+ contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
+ if contentType != "" && contentType != "application/json" {
+ return errors.New("unsupported content type")
+ }
+ body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1))
+ if err != nil {
+ return err
+ }
+ defer r.Body.Close()
+ if len(body) > 2<<20 {
+ return errors.New("request too large")
+ }
+ decoder := json.NewDecoder(strings.NewReader(string(body)))
+ if err := decoder.Decode(target); err != nil {
+ return err
+ }
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ return errors.New("multiple JSON values")
+ }
+ return nil
+}
+
+func revision(r *http.Request) (int64, error) {
+ value := r.Header.Get("If-Match")
+ if value == "" {
+ value = r.URL.Query().Get("revision")
+ }
+ return strconv.ParseInt(strings.Trim(value, "\""), 10, 64)
+}
+
+func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string, fields ...map[string]string) {
+ var extra map[string]string
+ if len(fields) > 0 {
+ extra = fields[0]
+ }
+ problem.Write(w, r, status, code, http.StatusText(status), detail, extra)
+}
+
+func write(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
diff --git a/internal/dashboardapi/handler_test.go b/internal/dashboardapi/handler_test.go
new file mode 100644
index 0000000..349a3fc
--- /dev/null
+++ b/internal/dashboardapi/handler_test.go
@@ -0,0 +1,112 @@
+package dashboardapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/itworx/pulse/internal/audit"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/correlation"
+ "github.com/itworx/pulse/internal/dashboard"
+)
+
+func requestWithPrincipal(method, path, body string, principal *auth.Principal) *httptest.ResponseRecorder {
+ request := httptest.NewRequest(method, path, strings.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request = request.WithContext(correlation.WithContext(request.Context(), "m3-03-test-correlation"))
+ if principal != nil {
+ request = request.WithContext(auth.WithPrincipal(request.Context(), *principal))
+ }
+ response := httptest.NewRecorder()
+ (Handler{Repository: dashboard.Repository{}}).ServeHTTP(response, request)
+ return response
+}
+
+func problemCode(t *testing.T, response *httptest.ResponseRecorder) string {
+ t.Helper()
+ var body struct{ Code string }
+ if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
+ t.Fatalf("problem response is not JSON: %v", err)
+ }
+ return body.Code
+}
+
+func TestHandlerRequiresAuthenticationWithProblemResponse(t *testing.T) {
+ response := requestWithPrincipal(http.MethodGet, "/api/v1/dashboards", "", nil)
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("status=%d", response.Code)
+ }
+ if got := response.Header().Get("Content-Type"); got != "application/problem+json" {
+ t.Fatalf("content type=%q", got)
+ }
+ if got := problemCode(t, response); got != "UNAUTHORIZED" {
+ t.Fatalf("code=%q", got)
+ }
+ if got := response.Header().Get(correlation.Header); got != "m3-03-test-correlation" {
+ t.Fatalf("correlation=%q", got)
+ }
+}
+
+func TestHandlerEnforcesEditorForMutation(t *testing.T) {
+ viewer := auth.Principal{Subject: "viewer", Role: auth.RoleViewer}
+ response := requestWithPrincipal(http.MethodPost, "/api/v1/dashboards", "{}", &viewer)
+ if response.Code != http.StatusForbidden {
+ t.Fatalf("status=%d", response.Code)
+ }
+ if got := problemCode(t, response); got != "FORBIDDEN" {
+ t.Fatalf("code=%q", got)
+ }
+}
+
+func TestHandlerEnforcesEditorForPreview(t *testing.T) {
+ viewer := auth.Principal{Subject: "viewer", Role: auth.RoleViewer}
+ response := requestWithPrincipal(http.MethodPost, "/api/v1/dashboards/00000000-0000-0000-0000-000000000001/preview", `{"widget":{}}`, &viewer)
+ if response.Code != http.StatusForbidden || problemCode(t, response) != "FORBIDDEN" {
+ t.Fatalf("response=%d %s", response.Code, response.Body.String())
+ }
+}
+
+func TestHandlerRejectsInvalidContentTypeAndVersion(t *testing.T) {
+ editor := auth.Principal{Subject: "editor", Role: auth.RoleEditor}
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/dashboards", strings.NewReader("{}"))
+ request.Header.Set("Content-Type", "text/plain")
+ request = request.WithContext(auth.WithPrincipal(context.Background(), editor))
+ response := httptest.NewRecorder()
+ (Handler{Repository: dashboard.Repository{}}).ServeHTTP(response, request)
+ if response.Code != http.StatusBadRequest || problemCode(t, response) != "INVALID_DOCUMENT" {
+ t.Fatalf("response=%d %s", response.Code, response.Body.String())
+ }
+
+ response = requestWithPrincipal(http.MethodPost, "/api/v1/dashboards/00000000-0000-0000-0000-000000000001/restore/not-a-number", "{}", &editor)
+ if response.Code != http.StatusBadRequest || problemCode(t, response) != "INVALID_VERSION" {
+ t.Fatalf("response=%d %s", response.Code, response.Body.String())
+ }
+}
+
+func TestHandlerRejectsUnsupportedMethod(t *testing.T) {
+ viewer := auth.Principal{Subject: "viewer", Role: auth.RoleViewer}
+ response := requestWithPrincipal(http.MethodPut, "/api/v1/dashboards", "{}", &viewer)
+ if response.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("status=%d", response.Code)
+ }
+}
+
+func TestHandlerRecordsAuditedSafeDiff(t *testing.T) {
+ store := &audit.MemoryStore{}
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/dashboards", strings.NewReader("{}"))
+ request = request.WithContext(correlation.WithContext(request.Context(), "audit-correlation"))
+ handler := Handler{Audit: store}
+ if err := handler.record(request, "subject-1", "dashboard.update_document", "00000000-0000-0000-0000-000000000001", map[string]any{"revision": 1}, map[string]any{"revision": 2}); err != nil {
+ t.Fatal(err)
+ }
+ if len(store.Events) != 1 || store.Events[0].Action != "dashboard.update_document" || store.Events[0].CorrelationID != "audit-correlation" {
+ t.Fatalf("events=%+v", store.Events)
+ }
+ if _, ok := store.Events[0].After["document"]; ok {
+ t.Fatal("audit event unexpectedly contains full document")
+ }
+}
diff --git a/internal/database/database.go b/internal/database/database.go
new file mode 100644
index 0000000..7b1b8dd
--- /dev/null
+++ b/internal/database/database.go
@@ -0,0 +1,129 @@
+package database
+
+import (
+ "context"
+ "embed"
+ "errors"
+ "fmt"
+ "io/fs"
+ "path"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+//go:embed migrations/*.sql
+var migrationFiles embed.FS
+
+const (
+ defaultMaxConns = 10
+ defaultMinConns = 1
+)
+
+type Config struct {
+ URL string
+ MaxConns int32
+ MinConns int32
+ MaxConnIdle time.Duration
+}
+
+func NewPool(ctx context.Context, config Config) (*pgxpool.Pool, error) {
+ if strings.TrimSpace(config.URL) == "" {
+ return nil, errors.New("database URL is required")
+ }
+ poolConfig, err := pgxpool.ParseConfig(config.URL)
+ if err != nil {
+ return nil, fmt.Errorf("parse database URL: %w", err)
+ }
+ if config.MaxConns == 0 {
+ config.MaxConns = defaultMaxConns
+ }
+ if config.MinConns == 0 {
+ config.MinConns = defaultMinConns
+ }
+ if config.MaxConns < config.MinConns || config.MinConns < 0 {
+ return nil, errors.New("database pool limits are invalid")
+ }
+ poolConfig.MaxConns = config.MaxConns
+ poolConfig.MinConns = config.MinConns
+ if config.MaxConnIdle > 0 {
+ poolConfig.MaxConnIdleTime = config.MaxConnIdle
+ }
+ pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
+ if err != nil {
+ return nil, fmt.Errorf("create database pool: %w", err)
+ }
+ return pool, nil
+}
+
+func Ping(ctx context.Context, pool *pgxpool.Pool) error {
+ if pool == nil {
+ return errors.New("database pool is nil")
+ }
+ if err := pool.Ping(ctx); err != nil {
+ return fmt.Errorf("database ping: %w", err)
+ }
+ return nil
+}
+
+func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
+ if pool == nil {
+ return errors.New("database pool is nil")
+ }
+ entries, err := fs.Glob(migrationFiles, "migrations/*.sql")
+ if err != nil {
+ return fmt.Errorf("list migrations: %w", err)
+ }
+ sort.Strings(entries)
+ for _, entry := range entries {
+ if err := applyMigration(ctx, pool, entry); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func applyMigration(ctx context.Context, pool *pgxpool.Pool, entry string) error {
+ migrationID := strings.TrimSuffix(path.Base(entry), path.Ext(entry))
+ sqlBytes, err := migrationFiles.ReadFile(entry)
+ if err != nil {
+ return fmt.Errorf("read migration %s: %w", migrationID, err)
+ }
+ tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return fmt.Errorf("begin migration %s: %w", migrationID, err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('itworx-pulse:schema-migrations'))`); err != nil {
+ return fmt.Errorf("lock migrations: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
+ id text PRIMARY KEY,
+ applied_at timestamptz NOT NULL DEFAULT now()
+ )`); err != nil {
+ return fmt.Errorf("create migration table: %w", err)
+ }
+ var exists bool
+ if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE id = $1)`, migrationID).Scan(&exists); err != nil {
+ return fmt.Errorf("check migration %s: %w", migrationID, err)
+ }
+ if exists {
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit migration check %s: %w", migrationID, err)
+ }
+ return nil
+ }
+ if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil {
+ return fmt.Errorf("apply migration %s: %w", migrationID, err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (id) VALUES ($1)`, migrationID); err != nil {
+ return fmt.Errorf("record migration %s: %w", migrationID, err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit migration %s: %w", migrationID, err)
+ }
+ return nil
+}
diff --git a/internal/database/database_test.go b/internal/database/database_test.go
new file mode 100644
index 0000000..8819cb5
--- /dev/null
+++ b/internal/database/database_test.go
@@ -0,0 +1,302 @@
+package database
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestMigrationsAreEmbeddedAndOrdered(t *testing.T) {
+ entries, err := migrationFiles.ReadDir("migrations")
+ if err != nil {
+ t.Fatalf("read embedded migrations: %v", err)
+ }
+ expected := []string{
+ "0001_foundation.sql", "0002_inventory.sql", "0003_dashboard_immutability.sql", "0004_dashboard_revision.sql",
+ "0005_services_probes.sql", "0006_alert_rules.sql", "0007_alert_evaluator_leases.sql", "0008_alert_state.sql",
+ "0009_alert_hysteresis.sql", "0010_alert_controls.sql", "0011_alert_unacknowledge.sql", "0012_notifications.sql",
+ "0013_incidents.sql", "0014_incident_notes.sql", "0015_entity_listing_index.sql", "0016_agent_snapshots.sql",
+ "0017_worker_runtime.sql", "0018_inventory_read_indexes.sql", "0019_capacity_samples.sql",
+ "0020_service_certificate_history_index.sql",
+ }
+ if len(entries) != len(expected) {
+ t.Fatalf("migration count = %d, want %d: %#v", len(entries), len(expected), entries)
+ }
+ for index, name := range expected {
+ if entries[index].Name() != name {
+ t.Fatalf("migration %d = %q, want %q", index, entries[index].Name(), name)
+ }
+ }
+}
+
+func TestServiceCertificateHistoryMigrationMatchesStatusQuery(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0020_service_certificate_history_index.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"service_certificates_service_history_idx", "service_id", "observed_at DESC", "id ASC"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("service certificate history migration is missing %q", fragment)
+ }
+ }
+ if strings.Contains(sql, "CONCURRENTLY") {
+ t.Fatal("migrations run inside a transaction and cannot create indexes concurrently")
+ }
+}
+
+func TestWorkerRuntimeMigrationContainsAliasAndJobLookupSafety(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0017_worker_runtime.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"CREATE TABLE container_aliases", "PRIMARY KEY (source_id, runtime_id)", "tombstoned_at", "ON DELETE CASCADE", "container_aliases_active_idx", "CREATE INDEX job_runs_recent_idx"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("worker runtime migration is missing %q", fragment)
+ }
+ }
+ if strings.Contains(sql, "CONCURRENTLY") {
+ t.Fatal("migrations run inside a transaction and cannot create indexes concurrently")
+ }
+}
+
+func TestAlertControlsMigrationContainsExpiryAndIndexes(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0010_alert_controls.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"CREATE TABLE alert_silences", "CREATE TABLE maintenance_windows", "expires_at", "ends_at", "status", "alert_silences_active_expiry_idx", "maintenance_windows_active_expiry_idx"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("control migration is missing %q", fragment)
+ }
+ }
+}
+func TestNotificationsMigrationContainsOutboxAndAuditConstraints(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0012_notifications.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"CREATE TABLE notification_channels", "CREATE TABLE notification_outbox", "CREATE TABLE notification_deliveries", "UNIQUE (outbox_id, attempt)", "ON DELETE RESTRICT", "notification_outbox_due_idx", "notification_deliveries_history_idx"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("notification migration is missing %q", fragment)
+ }
+ }
+}
+func TestIncidentsMigrationContainsCorrelationAndAssociationSafety(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0013_incidents.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"CREATE TABLE incidents", "incidents_active_correlation_key_uq", "CREATE TABLE incident_alerts", "CREATE TABLE incident_entities", "ON DELETE RESTRICT", "confidence", "rationale"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("incident migration is missing %q", fragment)
+ }
+ }
+}
+func TestIncidentNotesMigrationContainsBoundedNotes(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0014_incident_notes.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"CREATE TABLE incident_notes", "incident_notes_history_idx", "ON DELETE CASCADE", "char_length(body) BETWEEN 1 AND 2000"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("incident notes migration is missing %q", fragment)
+ }
+ }
+}
+func TestEntityListingMigrationIndexesKeysetPagination(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0015_entity_listing_index.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"CREATE INDEX IF NOT EXISTS entities_canonical_name_idx", "ON entities (canonical_name ASC, id ASC)"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("entity listing migration is missing %q", fragment)
+ }
+ }
+ if strings.Contains(sql, "CONCURRENTLY") {
+ t.Fatal("migrations run inside a transaction and cannot create indexes concurrently")
+ }
+}
+func TestPostgreSQLMigrationsAreRestartSafe(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ pool, err := NewPool(ctx, Config{URL: dsn})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := Ping(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ if err := Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ if err := Migrate(ctx, pool); err != nil {
+ t.Fatalf("repeated migration: %v", err)
+ }
+ var count int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations WHERE id = '0001_foundation'`).Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatalf("migration count = %d, want 1", count)
+ }
+ var inventoryCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations WHERE id = '0002_inventory'`).Scan(&inventoryCount); err != nil {
+ t.Fatal(err)
+ }
+ if inventoryCount != 1 {
+ t.Fatalf("inventory migration count = %d, want 1", inventoryCount)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO system_settings (key, value) VALUES ('test.persistence', '{"ok":true}') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`); err != nil {
+ t.Fatal(err)
+ }
+ var value bool
+ if err := pool.QueryRow(ctx, `SELECT value->>'ok' = 'true' FROM system_settings WHERE key = 'test.persistence'`).Scan(&value); err != nil {
+ t.Fatal(err)
+ }
+ if !value {
+ t.Fatal("persisted setting was not retained")
+ }
+ pool.Close()
+ restartedPool, err := NewPool(ctx, Config{URL: dsn})
+ if err != nil {
+ t.Fatalf("reopen database pool: %v", err)
+ }
+ defer restartedPool.Close()
+ var afterRestart bool
+ if err := restartedPool.QueryRow(ctx, `SELECT value->>'ok' = 'true' FROM system_settings WHERE key = 'test.persistence'`).Scan(&afterRestart); err != nil {
+ t.Fatal(err)
+ }
+ if !afterRestart {
+ t.Fatal("persisted setting was not retained after pool restart")
+ }
+}
+
+func TestAlertRuleMigrationContainsVersionSafety(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0006_alert_rules.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, table := range []string{"alert_rules", "alert_rule_versions"} {
+ if !strings.Contains(sql, "CREATE TABLE "+table) {
+ t.Fatalf("migration is missing table %s", table)
+ }
+ }
+ for _, constraint := range []string{"UNIQUE (rule_id, version_number)", "ON DELETE RESTRICT", "DEFERRABLE INITIALLY DEFERRED", "WHERE enabled = true"} {
+ if !strings.Contains(sql, constraint) {
+ t.Fatalf("migration is missing safety constraint %q", constraint)
+ }
+ }
+}
+
+func TestAlertEvaluatorLeaseMigrationContainsExpiryIndex(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0007_alert_evaluator_leases.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"ADD COLUMN lease_owner text", "ADD COLUMN lease_until timestamptz", "CREATE INDEX job_runs_lease_idx", "status IN ('queued', 'running')"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("lease migration is missing %q", fragment)
+ }
+ }
+}
+
+func TestAlertStateMigrationContainsLifecycleAndHistorySafety(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0008_alert_state.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, table := range []string{"alert_instances", "alert_occurrences"} {
+ if !strings.Contains(sql, "CREATE TABLE "+table) {
+ t.Fatalf("migration is missing table %s", table)
+ }
+ }
+ for _, fragment := range []string{"UNIQUE (rule_id, fingerprint)", "UNIQUE (instance_id, evaluation_key)", "ON DELETE RESTRICT", "current_state text NOT NULL", "CREATE INDEX alert_occurrences_history_idx"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("state migration is missing %q", fragment)
+ }
+ }
+}
+func TestAlertHysteresisMigrationContainsCooldownSafety(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0009_alert_hysteresis.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{"ADD COLUMN cooldown_seconds", "ADD COLUMN cooldown_until", "cooldown_seconds BETWEEN 0 AND 2592000", "CREATE INDEX alert_instances_cooldown_idx"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("hysteresis migration is missing %q", fragment)
+ }
+ }
+}
+func TestAlertUnacknowledgeMigrationContainsConstraintSafety(t *testing.T) {
+ sqlBytes, err := migrationFiles.ReadFile("migrations/0011_alert_unacknowledge.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(sqlBytes)
+ for _, fragment := range []string{"DROP CONSTRAINT alert_occurrences_event_type_check", "unacknowledge", "alert_instances_acknowledged_idx"} {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("unacknowledge migration is missing %q", fragment)
+ }
+ }
+}
+func TestServiceProbeMigrationContainsHistoryAndAccessSafety(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0005_services_probes.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, table := range []string{"services", "service_endpoints", "probes", "probe_results", "service_certificates", "service_dependencies", "service_permissions"} {
+ if !strings.Contains(sql, "CREATE TABLE "+table) {
+ t.Fatalf("migration is missing table %s", table)
+ }
+ }
+ for _, constraint := range []string{"revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0)", "ON DELETE RESTRICT", "WHERE archived_at IS NULL", "UNIQUE (probe_id, observed_at)", "permission IN ('view', 'operate', 'edit', 'admin')"} {
+ if !strings.Contains(sql, constraint) {
+ t.Fatalf("migration is missing safety constraint %q", constraint)
+ }
+ }
+}
+
+func TestAgentSnapshotMigrationBoundsPayloadAndCapability(t *testing.T) {
+ content, err := migrationFiles.ReadFile("migrations/0016_agent_snapshots.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sql := string(content)
+ for _, fragment := range []string{
+ "CREATE TABLE agent_snapshots",
+ "observed_at timestamptz NOT NULL",
+ "received_at timestamptz NOT NULL",
+ "jsonb_typeof(payload) = 'object'",
+ "pg_column_size(payload) <= 2097152",
+ "capability IN ('host', 'processes', 'containers', 'array', 'disks', 'pools', 'shares')",
+ "PRIMARY KEY (agent_id, capability)",
+ "agent_snapshots_capability_freshness_idx",
+ } {
+ if !strings.Contains(sql, fragment) {
+ t.Fatalf("agent snapshot migration is missing %q", fragment)
+ }
+ }
+ if strings.Contains(sql, "CONCURRENTLY") {
+ t.Fatal("migrations run inside a transaction and cannot create indexes concurrently")
+ }
+}
diff --git a/internal/database/migrations/0001_foundation.sql b/internal/database/migrations/0001_foundation.sql
new file mode 100644
index 0000000..bd1ba2e
--- /dev/null
+++ b/internal/database/migrations/0001_foundation.sql
@@ -0,0 +1,154 @@
+CREATE TABLE users (
+ id uuid PRIMARY KEY,
+ external_subject text NOT NULL UNIQUE,
+ display_name text NOT NULL,
+ email text,
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ last_login_at timestamptz
+);
+
+CREATE TABLE roles (
+ id uuid PRIMARY KEY,
+ name text NOT NULL UNIQUE CHECK (name IN ('viewer', 'operator', 'editor', 'administrator')),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE user_roles (
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (user_id, role_id)
+);
+
+CREATE TABLE data_sources (
+ id uuid PRIMARY KEY,
+ type text NOT NULL,
+ name text NOT NULL,
+ enabled boolean NOT NULL DEFAULT true,
+ configuration_ref text NOT NULL,
+ capability_document jsonb NOT NULL DEFAULT '{}'::jsonb,
+ health_state text NOT NULL DEFAULT 'unknown' CHECK (health_state IN ('healthy', 'degraded', 'unhealthy', 'unknown')),
+ last_success_at timestamptz,
+ last_error_code text,
+ last_error_message text,
+ freshness_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE collectors (
+ id uuid PRIMARY KEY,
+ datasource_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
+ kind text NOT NULL,
+ version text NOT NULL,
+ heartbeat timestamptz,
+ capabilities jsonb NOT NULL DEFAULT '[]'::jsonb,
+ status text NOT NULL DEFAULT 'unknown',
+ UNIQUE (datasource_id, kind)
+);
+
+CREATE TABLE entities (
+ id uuid PRIMARY KEY,
+ entity_type text NOT NULL,
+ canonical_name text NOT NULL,
+ display_name text NOT NULL,
+ status text NOT NULL DEFAULT 'unknown',
+ status_reasons jsonb NOT NULL DEFAULT '[]'::jsonb,
+ first_seen_at timestamptz NOT NULL,
+ last_seen_at timestamptz,
+ tombstoned_at timestamptz,
+ attributes jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+
+CREATE TABLE entity_aliases (
+ entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+ source_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
+ external_type text NOT NULL,
+ external_id text NOT NULL,
+ PRIMARY KEY (source_id, external_type, external_id)
+);
+
+CREATE TABLE dashboards (
+ id uuid PRIMARY KEY,
+ slug text NOT NULL UNIQUE,
+ name text NOT NULL,
+ description text NOT NULL DEFAULT '',
+ owner_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
+ scope text NOT NULL CHECK (scope IN ('personal', 'shared', 'system')),
+ archived_at timestamptz,
+ current_version_id uuid,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE dashboard_versions (
+ id uuid PRIMARY KEY,
+ dashboard_id uuid NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
+ version_number integer NOT NULL CHECK (version_number > 0),
+ schema_version integer NOT NULL CHECK (schema_version > 0),
+ document jsonb NOT NULL,
+ change_summary text NOT NULL DEFAULT '',
+ created_by uuid REFERENCES users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (dashboard_id, version_number)
+);
+
+ALTER TABLE dashboards
+ ADD CONSTRAINT dashboards_current_version_fk
+ FOREIGN KEY (current_version_id) REFERENCES dashboard_versions(id) ON DELETE SET NULL;
+
+CREATE TABLE events (
+ id uuid PRIMARY KEY,
+ event_type text NOT NULL,
+ severity text NOT NULL,
+ entity_id uuid REFERENCES entities(id) ON DELETE SET NULL,
+ source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
+ occurred_at timestamptz NOT NULL,
+ received_at timestamptz NOT NULL DEFAULT now(),
+ dedup_key text NOT NULL,
+ summary text NOT NULL,
+ attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
+ correlation_id text,
+ UNIQUE (source_id, dedup_key, occurred_at)
+);
+
+CREATE TABLE audit_events (
+ id uuid PRIMARY KEY,
+ actor text NOT NULL,
+ action text NOT NULL,
+ resource_type text NOT NULL,
+ resource_id uuid,
+ result text NOT NULL,
+ occurred_at timestamptz NOT NULL DEFAULT now(),
+ correlation_id text,
+ before_diff jsonb,
+ after_diff jsonb
+);
+
+CREATE TABLE job_runs (
+ id uuid PRIMARY KEY,
+ job_type text NOT NULL,
+ job_key text NOT NULL,
+ scheduled_at timestamptz NOT NULL,
+ started_at timestamptz,
+ completed_at timestamptz,
+ status text NOT NULL,
+ counts jsonb NOT NULL DEFAULT '{}'::jsonb,
+ error_code text,
+ correlation_id text,
+ UNIQUE (job_type, job_key, scheduled_at)
+);
+
+CREATE TABLE system_settings (
+ key text PRIMARY KEY,
+ value jsonb NOT NULL,
+ version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX entities_type_status_idx ON entities (entity_type, status);
+CREATE INDEX events_occurred_at_idx ON events (occurred_at DESC);
+CREATE INDEX audit_events_occurred_at_idx ON audit_events (occurred_at DESC);
+CREATE INDEX job_runs_status_idx ON job_runs (status, scheduled_at);
diff --git a/internal/database/migrations/0002_inventory.sql b/internal/database/migrations/0002_inventory.sql
new file mode 100644
index 0000000..c05d098
--- /dev/null
+++ b/internal/database/migrations/0002_inventory.sql
@@ -0,0 +1,36 @@
+CREATE TABLE entity_facts (
+ entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+ field_name text NOT NULL,
+ source_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
+ value jsonb NOT NULL,
+ observed_at timestamptz NOT NULL,
+ confidence numeric(5,4) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
+ valid_until timestamptz,
+ PRIMARY KEY (entity_id, field_name, source_id)
+);
+
+CREATE TABLE entity_overrides (
+ entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+ field_name text NOT NULL,
+ value jsonb NOT NULL,
+ user_id uuid REFERENCES users(id) ON DELETE SET NULL,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (entity_id, field_name)
+);
+
+CREATE TABLE entity_relations (
+ id uuid PRIMARY KEY,
+ source_entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+ relation_type text NOT NULL,
+ target_entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+ source_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
+ confidence numeric(5,4) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
+ confirmed boolean NOT NULL DEFAULT false,
+ first_seen_at timestamptz NOT NULL,
+ last_seen_at timestamptz,
+ tombstoned_at timestamptz,
+ UNIQUE (source_entity_id, relation_type, target_entity_id, source_id)
+);
+
+CREATE INDEX entity_facts_source_observed_idx ON entity_facts (source_id, observed_at DESC);
+CREATE INDEX entity_relations_source_idx ON entity_relations (source_id, last_seen_at DESC);
diff --git a/internal/database/migrations/0003_dashboard_immutability.sql b/internal/database/migrations/0003_dashboard_immutability.sql
new file mode 100644
index 0000000..40f87cf
--- /dev/null
+++ b/internal/database/migrations/0003_dashboard_immutability.sql
@@ -0,0 +1,11 @@
+CREATE OR REPLACE FUNCTION prevent_dashboard_version_mutation() RETURNS trigger AS $$
+BEGIN
+ RAISE EXCEPTION 'dashboard versions are immutable';
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE TRIGGER dashboard_versions_immutable_update
+ BEFORE UPDATE ON dashboard_versions
+ FOR EACH ROW EXECUTE FUNCTION prevent_dashboard_version_mutation();
+
+CREATE INDEX IF NOT EXISTS dashboard_versions_created_at_idx ON dashboard_versions (dashboard_id, created_at DESC, version_number DESC);
diff --git a/internal/database/migrations/0004_dashboard_revision.sql b/internal/database/migrations/0004_dashboard_revision.sql
new file mode 100644
index 0000000..235957d
--- /dev/null
+++ b/internal/database/migrations/0004_dashboard_revision.sql
@@ -0,0 +1,6 @@
+ALTER TABLE dashboards ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0);
+CREATE INDEX IF NOT EXISTS dashboards_owner_revision_idx ON dashboards (owner_user_id, revision DESC, id ASC);
+
+CREATE INDEX IF NOT EXISTS dashboards_name_id_idx ON dashboards (name ASC, id ASC);
+CREATE INDEX IF NOT EXISTS dashboards_scope_name_id_idx ON dashboards (scope, name ASC, id ASC);
+CREATE INDEX IF NOT EXISTS dashboards_owner_name_id_idx ON dashboards (owner_user_id, name ASC, id ASC);
diff --git a/internal/database/migrations/0005_services_probes.sql b/internal/database/migrations/0005_services_probes.sql
new file mode 100644
index 0000000..06d8b81
--- /dev/null
+++ b/internal/database/migrations/0005_services_probes.sql
@@ -0,0 +1,124 @@
+CREATE TABLE services (
+ id uuid PRIMARY KEY,
+ entity_id uuid REFERENCES entities(id) ON DELETE SET NULL,
+ source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
+ name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
+ description text NOT NULL DEFAULT '',
+ state text NOT NULL DEFAULT 'unknown' CHECK (state IN ('up', 'degraded', 'down', 'unknown')),
+ labels jsonb NOT NULL DEFAULT '{}'::jsonb,
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ archived_at timestamptz,
+ created_by uuid REFERENCES users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX services_entity_idx ON services (entity_id) WHERE archived_at IS NULL;
+CREATE INDEX services_source_state_idx ON services (source_id, state, updated_at DESC);
+
+CREATE TABLE service_endpoints (
+ id uuid PRIMARY KEY,
+ service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
+ source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
+ name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
+ endpoint_type text NOT NULL CHECK (endpoint_type IN ('http', 'tcp', 'dns', 'icmp', 'tls')),
+ target jsonb NOT NULL,
+ enabled boolean NOT NULL DEFAULT true,
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ archived_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX service_endpoints_active_idx ON service_endpoints (service_id, enabled) WHERE archived_at IS NULL;
+CREATE UNIQUE INDEX service_endpoints_active_name_idx ON service_endpoints (service_id, name) WHERE archived_at IS NULL;
+
+CREATE TABLE probes (
+ id uuid PRIMARY KEY,
+ service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
+ endpoint_id uuid REFERENCES service_endpoints(id) ON DELETE SET NULL,
+ source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
+ name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
+ probe_type text NOT NULL CHECK (probe_type IN ('http', 'tcp', 'dns', 'icmp', 'tls')),
+ target jsonb NOT NULL,
+ interval_seconds integer NOT NULL CHECK (interval_seconds BETWEEN 5 AND 86400),
+ timeout_seconds integer NOT NULL CHECK (timeout_seconds BETWEEN 1 AND 120),
+ enabled boolean NOT NULL DEFAULT true,
+ expected_status_codes jsonb NOT NULL DEFAULT '[]'::jsonb,
+ follow_redirects boolean NOT NULL DEFAULT false,
+ verify_tls boolean NOT NULL DEFAULT true,
+ content_assertion jsonb,
+ secret_reference text,
+ network_policy_id uuid,
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ archived_at timestamptz,
+ created_by uuid REFERENCES users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX probes_schedule_idx ON probes (enabled, interval_seconds, updated_at) WHERE archived_at IS NULL;
+CREATE INDEX probes_service_idx ON probes (service_id, updated_at DESC);
+CREATE UNIQUE INDEX probes_active_name_idx ON probes (service_id, name) WHERE archived_at IS NULL;
+
+CREATE TABLE probe_results (
+ id uuid PRIMARY KEY,
+ probe_id uuid NOT NULL REFERENCES probes(id) ON DELETE RESTRICT,
+ source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
+ observed_at timestamptz NOT NULL,
+ completed_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('up', 'degraded', 'down', 'unknown')),
+ response_time_ms integer CHECK (response_time_ms IS NULL OR response_time_ms >= 0),
+ status_code integer CHECK (status_code IS NULL OR status_code BETWEEN 100 AND 599),
+ error_class text,
+ error_message text,
+ attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
+ UNIQUE (probe_id, observed_at)
+);
+
+CREATE INDEX probe_results_history_idx ON probe_results (probe_id, observed_at DESC);
+CREATE INDEX probe_results_state_idx ON probe_results (state, observed_at DESC);
+
+CREATE TABLE service_certificates (
+ id uuid PRIMARY KEY,
+ service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
+ endpoint_id uuid REFERENCES service_endpoints(id) ON DELETE SET NULL,
+ source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
+ observed_at timestamptz NOT NULL,
+ expires_at timestamptz,
+ issuer text,
+ subject text,
+ hostname_valid boolean,
+ verification_state text NOT NULL CHECK (verification_state IN ('valid', 'attention', 'invalid', 'unknown')),
+ attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
+ UNIQUE (service_id, endpoint_id, observed_at)
+);
+
+CREATE INDEX service_certificates_expiry_idx ON service_certificates (expires_at, observed_at DESC);
+CREATE UNIQUE INDEX service_certificates_without_endpoint_unique_idx ON service_certificates (service_id, observed_at) WHERE endpoint_id IS NULL;
+
+CREATE TABLE service_dependencies (
+ id uuid PRIMARY KEY,
+ service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
+ depends_on_service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
+ source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
+ relation_type text NOT NULL CHECK (relation_type IN ('depends_on', 'backs', 'exposes')),
+ confidence numeric(5,4) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
+ confirmed boolean NOT NULL DEFAULT false,
+ first_seen_at timestamptz NOT NULL,
+ last_seen_at timestamptz,
+ archived_at timestamptz,
+ UNIQUE (service_id, depends_on_service_id, relation_type, source_id),
+ CHECK (service_id <> depends_on_service_id)
+);
+
+CREATE INDEX service_dependencies_source_idx ON service_dependencies (source_id, last_seen_at DESC);
+CREATE UNIQUE INDEX service_dependencies_manual_unique_idx ON service_dependencies (service_id, depends_on_service_id, relation_type) WHERE source_id IS NULL;
+
+CREATE TABLE service_permissions (
+ service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+ role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
+ permission text NOT NULL CHECK (permission IN ('view', 'operate', 'edit', 'admin')),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (service_id, role_id, permission)
+);
diff --git a/internal/database/migrations/0006_alert_rules.sql b/internal/database/migrations/0006_alert_rules.sql
new file mode 100644
index 0000000..b02c3ac
--- /dev/null
+++ b/internal/database/migrations/0006_alert_rules.sql
@@ -0,0 +1,41 @@
+CREATE TABLE alert_rules (
+ id uuid PRIMARY KEY,
+ schema_version integer NOT NULL CHECK (schema_version = 1),
+ name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
+ enabled boolean NOT NULL DEFAULT false,
+ severity text NOT NULL CHECK (severity IN ('attention', 'degraded', 'critical')),
+ scope jsonb NOT NULL DEFAULT '{}'::jsonb,
+ condition jsonb NOT NULL,
+ evaluation_interval_seconds integer NOT NULL CHECK (evaluation_interval_seconds BETWEEN 5 AND 3600),
+ pending_seconds integer NOT NULL CHECK (pending_seconds BETWEEN 0 AND 2592000),
+ resolve_seconds integer NOT NULL CHECK (resolve_seconds BETWEEN 0 AND 2592000),
+ unknown_behavior text NOT NULL CHECK (unknown_behavior IN ('retain-firing-as-unknown', 'become-unknown', 'ignore-short-gap')),
+ group_by jsonb NOT NULL DEFAULT '[]'::jsonb,
+ suppress_when jsonb NOT NULL DEFAULT '[]'::jsonb,
+ message jsonb NOT NULL,
+ current_version_id uuid,
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ created_by uuid REFERENCES users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE alert_rule_versions (
+ id uuid PRIMARY KEY,
+ rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE RESTRICT,
+ version_number integer NOT NULL CHECK (version_number > 0),
+ document jsonb NOT NULL,
+ change_summary text NOT NULL DEFAULT '' CHECK (length(change_summary) <= 500),
+ created_by uuid REFERENCES users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (rule_id, version_number)
+);
+
+ALTER TABLE alert_rules
+ ADD CONSTRAINT alert_rules_current_version_fk
+ FOREIGN KEY (current_version_id) REFERENCES alert_rule_versions(id)
+ ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED;
+
+CREATE INDEX alert_rules_enabled_idx ON alert_rules (enabled, evaluation_interval_seconds, updated_at DESC) WHERE enabled = true;
+CREATE INDEX alert_rules_severity_idx ON alert_rules (severity, updated_at DESC);
+CREATE INDEX alert_rule_versions_history_idx ON alert_rule_versions (rule_id, version_number DESC);
diff --git a/internal/database/migrations/0007_alert_evaluator_leases.sql b/internal/database/migrations/0007_alert_evaluator_leases.sql
new file mode 100644
index 0000000..1e20c4e
--- /dev/null
+++ b/internal/database/migrations/0007_alert_evaluator_leases.sql
@@ -0,0 +1,6 @@
+ALTER TABLE job_runs
+ ADD COLUMN lease_owner text,
+ ADD COLUMN lease_until timestamptz;
+
+CREATE INDEX job_runs_lease_idx ON job_runs (job_type, job_key, scheduled_at, lease_until)
+ WHERE status IN ('queued', 'running');
diff --git a/internal/database/migrations/0008_alert_state.sql b/internal/database/migrations/0008_alert_state.sql
new file mode 100644
index 0000000..e04f92f
--- /dev/null
+++ b/internal/database/migrations/0008_alert_state.sql
@@ -0,0 +1,41 @@
+CREATE TABLE alert_instances (
+ id uuid PRIMARY KEY,
+ rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE RESTRICT,
+ rule_version_id uuid NOT NULL REFERENCES alert_rule_versions(id) ON DELETE RESTRICT,
+ fingerprint text NOT NULL CHECK (length(fingerprint) BETWEEN 1 AND 160),
+ entity_id uuid REFERENCES entities(id) ON DELETE RESTRICT,
+ current_state text NOT NULL DEFAULT 'inactive' CHECK (current_state IN ('inactive', 'pending', 'firing', 'acknowledged', 'resolved', 'unknown')),
+ retained_state text NOT NULL DEFAULT 'inactive' CHECK (retained_state IN ('inactive', 'pending', 'firing', 'acknowledged', 'resolved', 'unknown')),
+ active_since timestamptz,
+ recovery_since timestamptz,
+ last_evaluated_at timestamptz NOT NULL,
+ last_known_at timestamptz,
+ last_value jsonb NOT NULL DEFAULT 'null'::jsonb,
+ reason text NOT NULL DEFAULT '' CHECK (length(reason) <= 500),
+ source_health jsonb NOT NULL DEFAULT '{}'::jsonb,
+ acknowledged_by text,
+ acknowledged_at timestamptz,
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (rule_id, fingerprint)
+);
+
+CREATE TABLE alert_occurrences (
+ id uuid PRIMARY KEY,
+ instance_id uuid NOT NULL REFERENCES alert_instances(id) ON DELETE RESTRICT,
+ evaluation_key text NOT NULL CHECK (length(evaluation_key) BETWEEN 1 AND 160),
+ event_type text NOT NULL CHECK (event_type IN ('evaluation', 'transition', 'acknowledge')),
+ from_state text NOT NULL CHECK (from_state IN ('inactive', 'pending', 'firing', 'acknowledged', 'resolved', 'unknown')),
+ to_state text NOT NULL CHECK (to_state IN ('inactive', 'pending', 'firing', 'acknowledged', 'resolved', 'unknown')),
+ observed_at timestamptz NOT NULL,
+ value jsonb NOT NULL DEFAULT 'null'::jsonb,
+ reason text NOT NULL DEFAULT '' CHECK (length(reason) <= 500),
+ source_health jsonb NOT NULL DEFAULT '{}'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (instance_id, evaluation_key)
+);
+
+CREATE INDEX alert_instances_state_idx ON alert_instances (current_state, last_evaluated_at DESC, id ASC);
+CREATE INDEX alert_instances_rule_idx ON alert_instances (rule_id, current_state, updated_at DESC, id ASC);
+CREATE INDEX alert_occurrences_history_idx ON alert_occurrences (instance_id, observed_at DESC, id ASC);
diff --git a/internal/database/migrations/0009_alert_hysteresis.sql b/internal/database/migrations/0009_alert_hysteresis.sql
new file mode 100644
index 0000000..f73de50
--- /dev/null
+++ b/internal/database/migrations/0009_alert_hysteresis.sql
@@ -0,0 +1,8 @@
+ALTER TABLE alert_rules
+ ADD COLUMN cooldown_seconds integer NOT NULL DEFAULT 0 CHECK (cooldown_seconds BETWEEN 0 AND 2592000);
+
+ALTER TABLE alert_instances
+ ADD COLUMN cooldown_until timestamptz;
+
+CREATE INDEX alert_instances_cooldown_idx ON alert_instances (cooldown_until, current_state, id)
+ WHERE cooldown_until IS NOT NULL;
diff --git a/internal/database/migrations/0010_alert_controls.sql b/internal/database/migrations/0010_alert_controls.sql
new file mode 100644
index 0000000..56ad170
--- /dev/null
+++ b/internal/database/migrations/0010_alert_controls.sql
@@ -0,0 +1,42 @@
+CREATE TABLE alert_silences (
+ id uuid PRIMARY KEY,
+ name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),
+ reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 500),
+ owner text NOT NULL CHECK (char_length(owner) BETWEEN 1 AND 255),
+ matchers jsonb NOT NULL CHECK (jsonb_typeof(matchers) = 'object'),
+ starts_at timestamptz NOT NULL,
+ expires_at timestamptz NOT NULL CHECK (expires_at > starts_at),
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'revoked')),
+ created_by text NOT NULL CHECK (char_length(created_by) BETWEEN 1 AND 255),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ revoked_by text,
+ revoked_at timestamptz,
+ expired_at timestamptz,
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ CHECK ((status = 'revoked' AND revoked_at IS NOT NULL) OR (status <> 'revoked' AND revoked_at IS NULL)),
+ CHECK ((status = 'expired' AND expired_at IS NOT NULL) OR (status <> 'expired' AND expired_at IS NULL))
+);
+
+CREATE INDEX alert_silences_active_expiry_idx ON alert_silences (expires_at, id) WHERE status = 'active';
+CREATE INDEX alert_silences_listing_idx ON alert_silences (starts_at DESC, id DESC);
+
+CREATE TABLE maintenance_windows (
+ id uuid PRIMARY KEY,
+ name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),
+ reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 500),
+ selector jsonb NOT NULL CHECK (jsonb_typeof(selector) = 'object'),
+ starts_at timestamptz NOT NULL,
+ ends_at timestamptz NOT NULL CHECK (ends_at > starts_at),
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'revoked')),
+ created_by text NOT NULL CHECK (char_length(created_by) BETWEEN 1 AND 255),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ revoked_by text,
+ revoked_at timestamptz,
+ expired_at timestamptz,
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ CHECK ((status = 'revoked' AND revoked_at IS NOT NULL) OR (status <> 'revoked' AND revoked_at IS NULL)),
+ CHECK ((status = 'expired' AND expired_at IS NOT NULL) OR (status <> 'expired' AND expired_at IS NULL))
+);
+
+CREATE INDEX maintenance_windows_active_expiry_idx ON maintenance_windows (ends_at, id) WHERE status = 'active';
+CREATE INDEX maintenance_windows_listing_idx ON maintenance_windows (starts_at DESC, id DESC);
diff --git a/internal/database/migrations/0011_alert_unacknowledge.sql b/internal/database/migrations/0011_alert_unacknowledge.sql
new file mode 100644
index 0000000..1a55292
--- /dev/null
+++ b/internal/database/migrations/0011_alert_unacknowledge.sql
@@ -0,0 +1,8 @@
+ALTER TABLE alert_occurrences
+ DROP CONSTRAINT alert_occurrences_event_type_check;
+
+ALTER TABLE alert_occurrences
+ ADD CONSTRAINT alert_occurrences_event_type_check
+ CHECK (event_type IN ('evaluation', 'transition', 'acknowledge', 'unacknowledge'));
+
+CREATE INDEX alert_instances_acknowledged_idx ON alert_instances (acknowledged_at DESC, id ASC) WHERE current_state = 'acknowledged';
diff --git a/internal/database/migrations/0012_notifications.sql b/internal/database/migrations/0012_notifications.sql
new file mode 100644
index 0000000..93d1e5e
--- /dev/null
+++ b/internal/database/migrations/0012_notifications.sql
@@ -0,0 +1,42 @@
+CREATE TABLE notification_channels (
+ id uuid PRIMARY KEY,
+ name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),
+ channel_type text NOT NULL CHECK (channel_type IN ('memory', 'webhook', 'email')),
+ enabled boolean NOT NULL DEFAULT true,
+ secret_ref text NOT NULL CHECK (char_length(secret_ref) BETWEEN 1 AND 255),
+ configuration jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(configuration) = 'object'),
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE notification_outbox (
+ id uuid PRIMARY KEY,
+ idempotency_key text NOT NULL UNIQUE CHECK (char_length(idempotency_key) BETWEEN 1 AND 255),
+ channel_id uuid NOT NULL REFERENCES notification_channels(id) ON DELETE RESTRICT,
+ event_type text NOT NULL CHECK (event_type IN ('firing', 'recovery', 'unknown')),
+ subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 240),
+ body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 8000),
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivering', 'retry', 'delivered', 'failed')),
+ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0 AND attempts <= 10),
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
+ locked_until timestamptz,
+ last_error text CHECK (last_error IS NULL OR char_length(last_error) <= 500),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ delivered_at timestamptz
+);
+
+CREATE TABLE notification_deliveries (
+ id uuid PRIMARY KEY,
+ outbox_id uuid NOT NULL REFERENCES notification_outbox(id) ON DELETE CASCADE,
+ attempt integer NOT NULL CHECK (attempt > 0),
+ status text NOT NULL CHECK (status IN ('delivering', 'delivered', 'failed')),
+ error text CHECK (error IS NULL OR char_length(error) <= 500),
+ occurred_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (outbox_id, attempt)
+);
+
+CREATE INDEX notification_outbox_due_idx ON notification_outbox (next_attempt_at, id) WHERE status IN ('pending', 'retry');
+CREATE INDEX notification_outbox_channel_idx ON notification_outbox (channel_id, status, updated_at DESC, id ASC);
+CREATE INDEX notification_deliveries_history_idx ON notification_deliveries (outbox_id, occurred_at DESC, id ASC);
diff --git a/internal/database/migrations/0013_incidents.sql b/internal/database/migrations/0013_incidents.sql
new file mode 100644
index 0000000..b6838bc
--- /dev/null
+++ b/internal/database/migrations/0013_incidents.sql
@@ -0,0 +1,48 @@
+CREATE TABLE incidents (
+ id uuid PRIMARY KEY,
+ correlation_key text NOT NULL CHECK (char_length(correlation_key) BETWEEN 1 AND 255),
+ title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 240),
+ summary text NOT NULL DEFAULT '' CHECK (char_length(summary) <= 2000),
+ severity text NOT NULL CHECK (severity IN ('attention', 'degraded', 'critical')),
+ status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
+ started_at timestamptz NOT NULL,
+ resolved_at timestamptz,
+ owner_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
+ correlation_method text NOT NULL CHECK (char_length(correlation_method) BETWEEN 1 AND 80),
+ confidence numeric(4,3) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CHECK (status <> 'resolved' OR resolved_at IS NOT NULL),
+ CHECK (status = 'resolved' OR resolved_at IS NULL)
+);
+
+CREATE UNIQUE INDEX incidents_active_correlation_key_uq ON incidents (correlation_key) WHERE status <> 'resolved';
+CREATE INDEX incidents_list_idx ON incidents (status, severity, updated_at DESC, id ASC);
+CREATE INDEX incidents_correlation_idx ON incidents (correlation_key, updated_at DESC, id ASC);
+
+CREATE TABLE incident_alerts (
+ incident_id uuid NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
+ alert_id uuid NOT NULL REFERENCES alert_instances(id) ON DELETE RESTRICT,
+ rationale text NOT NULL CHECK (char_length(rationale) BETWEEN 1 AND 500),
+ confidence numeric(4,3) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
+ correlation_method text NOT NULL CHECK (char_length(correlation_method) BETWEEN 1 AND 80),
+ is_manual boolean NOT NULL DEFAULT false,
+ added_by text NOT NULL DEFAULT '' CHECK (char_length(added_by) <= 160),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (incident_id, alert_id)
+);
+
+CREATE INDEX incident_alerts_alert_idx ON incident_alerts (alert_id, incident_id);
+CREATE INDEX incident_alerts_incident_idx ON incident_alerts (incident_id, created_at ASC, alert_id ASC);
+
+CREATE TABLE incident_entities (
+ incident_id uuid NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
+ entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE RESTRICT,
+ rationale text NOT NULL CHECK (char_length(rationale) BETWEEN 1 AND 500),
+ confidence numeric(4,3) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (incident_id, entity_id)
+);
+
+CREATE INDEX incident_entities_entity_idx ON incident_entities (entity_id, incident_id);
diff --git a/internal/database/migrations/0014_incident_notes.sql b/internal/database/migrations/0014_incident_notes.sql
new file mode 100644
index 0000000..ce3ac26
--- /dev/null
+++ b/internal/database/migrations/0014_incident_notes.sql
@@ -0,0 +1,9 @@
+CREATE TABLE incident_notes (
+ id uuid PRIMARY KEY,
+ incident_id uuid NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
+ author text NOT NULL CHECK (char_length(author) BETWEEN 1 AND 160),
+ body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX incident_notes_history_idx ON incident_notes (incident_id, created_at ASC, id ASC);
diff --git a/internal/database/migrations/0015_entity_listing_index.sql b/internal/database/migrations/0015_entity_listing_index.sql
new file mode 100644
index 0000000..3eb7c13
--- /dev/null
+++ b/internal/database/migrations/0015_entity_listing_index.sql
@@ -0,0 +1 @@
+CREATE INDEX IF NOT EXISTS entities_canonical_name_idx ON entities (canonical_name ASC, id ASC);
diff --git a/internal/database/migrations/0016_agent_snapshots.sql b/internal/database/migrations/0016_agent_snapshots.sql
new file mode 100644
index 0000000..baed423
--- /dev/null
+++ b/internal/database/migrations/0016_agent_snapshots.sql
@@ -0,0 +1,17 @@
+-- pulse-agent writes the newest bounded telemetry snapshot per capability here and
+-- pulse-api reads it. There is exactly one row per (agent, capability): history lives in
+-- Prometheus, not in this table, so the transport cannot grow without bound.
+CREATE TABLE agent_snapshots (
+ agent_id text NOT NULL CHECK (char_length(agent_id) BETWEEN 1 AND 128),
+ capability text NOT NULL CHECK (capability IN ('host', 'processes', 'containers', 'array', 'disks', 'pools', 'shares')),
+ observed_at timestamptz NOT NULL,
+ received_at timestamptz NOT NULL DEFAULT now(),
+ -- The authoritative size bound is enforced by the writer against the encoded payload
+ -- (agentstore.MaxPayloadBytes). This check is a storage backstop against a writer that
+ -- bypasses the store; pg_column_size reports the stored, possibly compressed size.
+ payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object' AND pg_column_size(payload) <= 2097152),
+ PRIMARY KEY (agent_id, capability)
+);
+
+-- The API reads the newest snapshot for one capability across agents on every request.
+CREATE INDEX agent_snapshots_capability_freshness_idx ON agent_snapshots (capability, observed_at DESC);
diff --git a/internal/database/migrations/0017_worker_runtime.sql b/internal/database/migrations/0017_worker_runtime.sql
new file mode 100644
index 0000000..7ff04b7
--- /dev/null
+++ b/internal/database/migrations/0017_worker_runtime.sql
@@ -0,0 +1,38 @@
+-- Worker runtime support.
+--
+-- container_aliases is the durable memory the background discovery job needs to
+-- be idempotent across restarts: reconciliation.ReconcileContainers must be able
+-- to compare the current runtime snapshot against the previous one to keep a
+-- stable entity identity across container recreation, and lifecycle event
+-- derivation must compare the previous observed state/health/restart count to
+-- decide whether anything actually changed. Both inputs are per runtime alias,
+-- not per entity, so they cannot be expressed with entity_aliases (which has no
+-- runtime identity or observation columns).
+--
+-- A runtime alias that stops being observed is tombstoned, never deleted, which
+-- is what keeps a temporarily unhealthy source from erasing inventory.
+CREATE TABLE container_aliases (
+ source_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
+ runtime_id text NOT NULL CHECK (length(runtime_id) BETWEEN 1 AND 255),
+ entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+ name text NOT NULL CHECK (length(name) BETWEEN 1 AND 255),
+ project text NOT NULL DEFAULT '' CHECK (length(project) <= 255),
+ service text NOT NULL DEFAULT '' CHECK (length(service) <= 255),
+ image_digest text NOT NULL DEFAULT '' CHECK (length(image_digest) <= 255),
+ observed_state text NOT NULL DEFAULT '' CHECK (length(observed_state) <= 64),
+ observed_health text NOT NULL DEFAULT '' CHECK (length(observed_health) <= 64),
+ restart_count integer NOT NULL DEFAULT 0 CHECK (restart_count >= 0),
+ intentional_stop boolean NOT NULL DEFAULT false,
+ first_seen_at timestamptz NOT NULL,
+ last_seen_at timestamptz NOT NULL,
+ tombstoned_at timestamptz,
+ PRIMARY KEY (source_id, runtime_id)
+);
+
+CREATE INDEX container_aliases_entity_idx ON container_aliases (entity_id, last_seen_at DESC);
+CREATE INDEX container_aliases_active_idx ON container_aliases (source_id, last_seen_at DESC) WHERE tombstoned_at IS NULL;
+
+-- The system status endpoint reports each background job's last outcome by
+-- reading the newest job_runs row per job_type. job_runs_status_idx leads with
+-- status, so it cannot serve that lookup; this index can.
+CREATE INDEX job_runs_recent_idx ON job_runs (job_type, scheduled_at DESC, id ASC);
diff --git a/internal/database/migrations/0018_inventory_read_indexes.sql b/internal/database/migrations/0018_inventory_read_indexes.sql
new file mode 100644
index 0000000..95781ce
--- /dev/null
+++ b/internal/database/migrations/0018_inventory_read_indexes.sql
@@ -0,0 +1,16 @@
+CREATE INDEX IF NOT EXISTS entities_type_canonical_idx
+ ON entities (entity_type, canonical_name, id)
+ WHERE tombstoned_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS entities_status_canonical_idx
+ ON entities (status, canonical_name, id)
+ WHERE tombstoned_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS entity_relations_source_entity_idx
+ ON entity_relations (source_entity_id, relation_type, target_entity_id);
+
+CREATE INDEX IF NOT EXISTS entity_relations_target_entity_idx
+ ON entity_relations (target_entity_id, relation_type, source_entity_id);
+
+CREATE INDEX IF NOT EXISTS entity_facts_freshness_idx
+ ON entity_facts (entity_id, valid_until, field_name, observed_at DESC);
diff --git a/internal/database/migrations/0019_capacity_samples.sql b/internal/database/migrations/0019_capacity_samples.sql
new file mode 100644
index 0000000..398c15d
--- /dev/null
+++ b/internal/database/migrations/0019_capacity_samples.sql
@@ -0,0 +1,14 @@
+CREATE TABLE capacity_samples (
+ entity_kind text NOT NULL CHECK (entity_kind IN ('share', 'pool', 'disk')),
+ entity_id text NOT NULL CHECK (length(entity_id) BETWEEN 1 AND 128),
+ entity_name text NOT NULL CHECK (length(entity_name) BETWEEN 1 AND 255),
+ source_id text NOT NULL CHECK (length(source_id) BETWEEN 1 AND 128),
+ sampled_at timestamptz NOT NULL,
+ observed_at timestamptz NOT NULL,
+ used_bytes bigint NOT NULL CHECK (used_bytes >= 0),
+ capacity_bytes bigint NOT NULL CHECK (capacity_bytes >= 0),
+ PRIMARY KEY (entity_kind, entity_id, source_id, sampled_at)
+);
+
+CREATE INDEX capacity_samples_history_idx
+ ON capacity_samples (entity_kind, entity_id, sampled_at DESC);
diff --git a/internal/database/migrations/0020_service_certificate_history_index.sql b/internal/database/migrations/0020_service_certificate_history_index.sql
new file mode 100644
index 0000000..ad92be0
--- /dev/null
+++ b/internal/database/migrations/0020_service_certificate_history_index.sql
@@ -0,0 +1,2 @@
+CREATE INDEX service_certificates_service_history_idx
+ ON service_certificates (service_id, observed_at DESC, id ASC);
diff --git a/internal/datasource/contracts.go b/internal/datasource/contracts.go
new file mode 100644
index 0000000..6a98e7a
--- /dev/null
+++ b/internal/datasource/contracts.go
@@ -0,0 +1,234 @@
+package datasource
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+const ContractVersion = "v1"
+
+type SourceType string
+
+const (
+ SourcePrometheus SourceType = "prometheus"
+ SourceUnraid SourceType = "unraid"
+ SourceAgent SourceType = "agent"
+ SourceExporter SourceType = "exporter"
+)
+
+func (s SourceType) Valid() bool {
+ switch s {
+ case SourcePrometheus, SourceUnraid, SourceAgent, SourceExporter:
+ return true
+ default:
+ return false
+ }
+}
+
+type HealthState string
+
+const (
+ HealthHealthy HealthState = "healthy"
+ HealthDegraded HealthState = "degraded"
+ HealthUnknown HealthState = "unknown"
+ HealthDisabled HealthState = "disabled"
+)
+
+type CapabilityState string
+
+const (
+ CapabilityEnabled CapabilityState = "enabled"
+ CapabilityUnsupported CapabilityState = "unsupported"
+ CapabilityUnavailable CapabilityState = "unavailable"
+ CapabilityDisabled CapabilityState = "disabled"
+)
+
+type FreshnessPolicy struct {
+ MaxAge time.Duration
+}
+
+func (p FreshnessPolicy) Validate() error {
+ if p.MaxAge <= 0 || p.MaxAge > 24*time.Hour {
+ return errors.New("freshness max age must be between 1 second and 24 hours")
+ }
+ return nil
+}
+
+type SourceHealth struct {
+ State HealthState
+ ObservedAt time.Time
+ ReceivedAt time.Time
+ LastSuccess time.Time
+ Policy FreshnessPolicy
+ ReasonCode string
+}
+
+func (h SourceHealth) Validate(now time.Time) error {
+ if h.State != HealthHealthy && h.State != HealthDegraded && h.State != HealthUnknown && h.State != HealthDisabled {
+ return fmt.Errorf("invalid health state %q", h.State)
+ }
+ if err := h.Policy.Validate(); err != nil {
+ return err
+ }
+ if h.ReceivedAt.IsZero() {
+ return errors.New("received timestamp is required")
+ }
+ if h.ObservedAt.IsZero() && h.State == HealthHealthy {
+ return errors.New("healthy source requires observed timestamp")
+ }
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ if !h.ObservedAt.IsZero() && h.ObservedAt.After(now.Add(time.Minute)) {
+ return errors.New("observed timestamp cannot be materially in the future")
+ }
+ return nil
+}
+
+func (h SourceHealth) Fresh(now time.Time) bool {
+ if h.State == HealthDisabled || h.State == HealthUnknown || h.ObservedAt.IsZero() || h.Policy.MaxAge <= 0 {
+ return false
+ }
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ return !h.ObservedAt.Before(now.Add(-h.Policy.MaxAge))
+}
+
+func (h SourceHealth) EffectiveState(now time.Time) HealthState {
+ if h.State == HealthHealthy && !h.Fresh(now) {
+ return HealthUnknown
+ }
+ return h.State
+}
+
+type Capability struct {
+ ID string
+ Version string
+ State CapabilityState
+ Description string
+ ReasonCode string
+ ObservedAt time.Time
+}
+
+func (c Capability) Validate() error {
+ if strings.TrimSpace(c.ID) == "" || len(c.ID) > 120 {
+ return errors.New("capability id must be 1-120 characters")
+ }
+ if strings.TrimSpace(c.Version) == "" || len(c.Version) > 32 {
+ return errors.New("capability version must be 1-32 characters")
+ }
+ switch c.State {
+ case CapabilityEnabled, CapabilityUnsupported, CapabilityUnavailable, CapabilityDisabled:
+ default:
+ return fmt.Errorf("invalid capability state %q", c.State)
+ }
+ if len(c.Description) > 500 || len(c.ReasonCode) > 80 {
+ return errors.New("capability text exceeds bounds")
+ }
+ return nil
+}
+
+type CapabilitySet []Capability
+
+func (s CapabilitySet) Validate() error {
+ if len(s) > 100 {
+ return errors.New("capability set exceeds 100 entries")
+ }
+ seen := make(map[string]struct{}, len(s))
+ for _, capability := range s {
+ if err := capability.Validate(); err != nil {
+ return err
+ }
+ key := capability.ID + "@" + capability.Version
+ if _, exists := seen[key]; exists {
+ return fmt.Errorf("duplicate capability %q", key)
+ }
+ seen[key] = struct{}{}
+ }
+ return nil
+}
+
+func (s CapabilitySet) Find(id string) (Capability, bool) {
+ for _, capability := range s {
+ if capability.ID == id {
+ return capability, true
+ }
+ }
+ return Capability{}, false
+}
+
+type Source struct {
+ ID string
+ Name string
+ Type SourceType
+ Enabled bool
+ Contract string
+ Health SourceHealth
+ Capabilities CapabilitySet
+}
+
+func (s Source) Validate(now time.Time) error {
+ if strings.TrimSpace(s.ID) == "" || len(s.ID) > 120 || strings.TrimSpace(s.Name) == "" || len(s.Name) > 255 {
+ return errors.New("source id and name are required and bounded")
+ }
+ if !s.Type.Valid() {
+ return fmt.Errorf("invalid source type %q", s.Type)
+ }
+ if s.Contract != ContractVersion {
+ return fmt.Errorf("unsupported datasource contract %q", s.Contract)
+ }
+ if err := s.Health.Validate(now); err != nil {
+ return err
+ }
+ return s.Capabilities.Validate()
+}
+
+type DiscoveryResult struct {
+ Source Source
+}
+
+type Entity struct {
+ ExternalType string
+ ExternalID string
+ EntityType string
+ CanonicalName string
+ DisplayName string
+ Status string
+ ObservedAt time.Time
+}
+
+type InventoryResult struct {
+ Entities []Entity
+}
+
+type MetricBinding struct {
+ SemanticName string
+ Version string
+ CapabilityID string
+}
+
+type Event struct {
+ Type string
+ Summary string
+ OccurredAt time.Time
+}
+
+type Snapshot struct {
+ Source Source
+ Inventory InventoryResult
+ MetricBindings []MetricBinding
+ Events []Event
+}
+
+type Adapter interface {
+ Discover(context.Context) (DiscoveryResult, error)
+ Health(context.Context) (SourceHealth, error)
+ Inventory(context.Context) (InventoryResult, error)
+ MetricsBindings(context.Context) ([]MetricBinding, error)
+ Events(context.Context) ([]Event, error)
+ Capabilities(context.Context) (CapabilitySet, error)
+}
diff --git a/internal/datasource/contracts_test.go b/internal/datasource/contracts_test.go
new file mode 100644
index 0000000..d14dae2
--- /dev/null
+++ b/internal/datasource/contracts_test.go
@@ -0,0 +1,46 @@
+package datasource
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestSourceHealthBecomesUnknownWhenStale(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ health := SourceHealth{State: HealthHealthy, ObservedAt: now.Add(-2 * time.Minute), ReceivedAt: now, Policy: FreshnessPolicy{MaxAge: time.Minute}}
+ if got := health.EffectiveState(now); got != HealthUnknown {
+ t.Fatalf("effective state = %q, want unknown", got)
+ }
+}
+
+func TestCapabilitySetRejectsDuplicateVersions(t *testing.T) {
+ set := CapabilitySet{{ID: "inventory", Version: "v1", State: CapabilityEnabled}, {ID: "inventory", Version: "v1", State: CapabilityUnavailable}}
+ if err := set.Validate(); err == nil {
+ t.Fatal("expected duplicate capability validation error")
+ }
+}
+
+func TestSourceRejectsUnknownContractAndUnboundedHealth(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ source := Source{ID: "source-1", Name: "Prometheus", Type: SourcePrometheus, Enabled: true, Contract: "v2", Health: SourceHealth{State: HealthUnknown, ReceivedAt: now, Policy: FreshnessPolicy{MaxAge: 25 * time.Hour}}}
+ if err := source.Validate(now); err == nil {
+ t.Fatal("expected source validation error")
+ }
+}
+
+func TestAdapterContractIsTransportFree(t *testing.T) {
+ var adapter Adapter = fakeAdapter{}
+ if _, err := adapter.Discover(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+type fakeAdapter struct{}
+
+func (fakeAdapter) Discover(context.Context) (DiscoveryResult, error) { return DiscoveryResult{}, nil }
+func (fakeAdapter) Health(context.Context) (SourceHealth, error) { return SourceHealth{}, nil }
+func (fakeAdapter) Inventory(context.Context) (InventoryResult, error) { return InventoryResult{}, nil }
+func (fakeAdapter) MetricsBindings(context.Context) ([]MetricBinding, error) { return nil, nil }
+func (fakeAdapter) Events(context.Context) ([]Event, error) { return nil, nil }
+func (fakeAdapter) Capabilities(context.Context) (CapabilitySet, error) { return nil, nil }
diff --git a/internal/discovery/jobs.go b/internal/discovery/jobs.go
new file mode 100644
index 0000000..ffad062
--- /dev/null
+++ b/internal/discovery/jobs.go
@@ -0,0 +1,166 @@
+package discovery
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+)
+
+type SnapshotFunc func(context.Context) ([]Event, error)
+
+// Event is one discovered change. SourceID and DedupKey together with OccurredAt
+// form the deduplication identity that persistent stores rely on, so a repeated
+// discovery pass re-emits the same event without creating a second row.
+// EntityID and Severity are optional: they carry the inventory entity the change
+// belongs to and how loud it is, and default to "no entity" and "info" so older
+// producers keep working unchanged.
+type Event struct {
+ SourceID, DedupKey, Type, Summary string
+ EntityID, Severity string
+ OccurredAt time.Time
+}
+type JobRun struct {
+ Key, Status, ErrorCode string
+ Attempts int
+ StartedAt, CompletedAt time.Time
+}
+
+type Store interface {
+ Claim(context.Context, string, time.Time) (bool, error)
+ Finish(context.Context, JobRun) error
+ Emit(context.Context, Event) (bool, error)
+}
+type AuditFunc func(context.Context, string, string) error
+type AuthorizeFunc func(context.Context, string) bool
+
+type Runner struct {
+ Store Store
+ MaxAttempts int
+ BaseRetry time.Duration
+ Sleep func(context.Context, time.Duration) error
+}
+
+func (r Runner) Run(ctx context.Context, jobKey string, discover SnapshotFunc) error {
+ if r.Store == nil || discover == nil || jobKey == "" {
+ return errors.New("discovery runner requires store, key, and discover function")
+ }
+ max := r.MaxAttempts
+ if max == 0 {
+ max = 3
+ }
+ if max < 1 || max > 5 {
+ return errors.New("discovery attempts must be between 1 and 5")
+ }
+ base := r.BaseRetry
+ if base == 0 {
+ base = 100 * time.Millisecond
+ }
+ sleeper := r.Sleep
+ if sleeper == nil {
+ sleeper = func(ctx context.Context, d time.Duration) error {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+ }
+ }
+ claimed, err := r.Store.Claim(ctx, jobKey, time.Now().UTC())
+ if err != nil {
+ return fmt.Errorf("claim discovery job: %w", err)
+ }
+ if !claimed {
+ return nil
+ }
+ run := JobRun{Key: jobKey, Status: "running", StartedAt: time.Now().UTC()}
+ var last error
+ for attempt := 1; attempt <= max; attempt++ {
+ run.Attempts = attempt
+ events, runErr := discover(ctx)
+ if runErr == nil {
+ for _, event := range events {
+ if _, err := r.Store.Emit(ctx, event); err != nil {
+ runErr = fmt.Errorf("emit discovery event: %w", err)
+ break
+ }
+ }
+ }
+ if runErr == nil {
+ run.Status = "succeeded"
+ run.CompletedAt = time.Now().UTC()
+ if err := r.Store.Finish(ctx, run); err != nil {
+ return err
+ }
+ return nil
+ }
+ last = runErr
+ if ctx.Err() != nil {
+ break
+ }
+ if attempt < max {
+ if err := sleeper(ctx, base*time.Duration(1<<(attempt-1))); err != nil {
+ last = err
+ break
+ }
+ }
+ }
+ run.Status = "failed"
+ run.ErrorCode = "DISCOVERY_FAILED"
+ run.CompletedAt = time.Now().UTC()
+ if err := r.Store.Finish(ctx, run); err != nil {
+ return err
+ }
+ return last
+}
+
+func (r Runner) RunManual(ctx context.Context, actor string, authorize AuthorizeFunc, audit AuditFunc, jobKey string, discover SnapshotFunc) error {
+ if authorize == nil || !authorize(ctx, actor) {
+ return errors.New("manual discovery is unauthorized")
+ }
+ if audit != nil {
+ if err := audit(ctx, actor, "discovery.manual.run"); err != nil {
+ return fmt.Errorf("audit manual discovery: %w", err)
+ }
+ }
+ return r.Run(ctx, jobKey, discover)
+}
+
+type MemoryStore struct {
+ mu sync.Mutex
+ claimed map[string]bool
+ Runs []JobRun
+ Events map[string]Event
+}
+
+func NewMemoryStore() *MemoryStore {
+ return &MemoryStore{claimed: make(map[string]bool), Events: make(map[string]Event)}
+}
+func (s *MemoryStore) Claim(_ context.Context, key string, _ time.Time) (bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.claimed[key] {
+ return false, nil
+ }
+ s.claimed[key] = true
+ return true, nil
+}
+func (s *MemoryStore) Finish(_ context.Context, run JobRun) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.Runs = append(s.Runs, run)
+ return nil
+}
+func (s *MemoryStore) Emit(_ context.Context, event Event) (bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if _, ok := s.Events[event.SourceID+"\x00"+event.DedupKey]; ok {
+ return false, nil
+ }
+ s.Events[event.SourceID+"\x00"+event.DedupKey] = event
+ return true, nil
+}
diff --git a/internal/discovery/jobs_test.go b/internal/discovery/jobs_test.go
new file mode 100644
index 0000000..c9156e1
--- /dev/null
+++ b/internal/discovery/jobs_test.go
@@ -0,0 +1,51 @@
+package discovery
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestDuplicateJobsAndEventsAreIdempotent(t *testing.T) {
+ store := NewMemoryStore()
+ r := Runner{Store: store, MaxAttempts: 1}
+ discover := func(context.Context) ([]Event, error) {
+ return []Event{{SourceID: "s", DedupKey: "entity:1", Type: "changed", Summary: "changed", OccurredAt: time.Now().UTC()}}, nil
+ }
+ if err := r.Run(context.Background(), "source:s:window:1", discover); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Run(context.Background(), "source:s:window:1", discover); err != nil {
+ t.Fatal(err)
+ }
+ if len(store.Runs) != 1 || len(store.Events) != 1 {
+ t.Fatalf("duplicate result: runs=%d events=%d", len(store.Runs), len(store.Events))
+ }
+}
+func TestRetryAndCancellationSafe(t *testing.T) {
+ store := NewMemoryStore()
+ attempts := 0
+ r := Runner{Store: store, MaxAttempts: 3, BaseRetry: time.Millisecond}
+ err := r.Run(context.Background(), "retry", func(context.Context) ([]Event, error) {
+ attempts++
+ if attempts < 3 {
+ return nil, errors.New("temporary")
+ }
+ return nil, nil
+ })
+ if err != nil || attempts != 3 || store.Runs[0].Status != "succeeded" {
+ t.Fatalf("retry result err=%v attempts=%d runs=%+v", err, attempts, store.Runs)
+ }
+}
+func TestManualRunRequiresAuthorizationAndAudits(t *testing.T) {
+ store := NewMemoryStore()
+ r := Runner{Store: store, MaxAttempts: 1}
+ if err := r.RunManual(context.Background(), "user", func(context.Context, string) bool { return false }, nil, "manual", func(context.Context) ([]Event, error) { return nil, nil }); err == nil {
+ t.Fatal("expected unauthorized error")
+ }
+ audited := false
+ if err := r.RunManual(context.Background(), "user", func(context.Context, string) bool { return true }, func(context.Context, string, string) error { audited = true; return nil }, "manual", func(context.Context) ([]Event, error) { return nil, nil }); err != nil || !audited {
+ t.Fatalf("manual run err=%v audited=%v", err, audited)
+ }
+}
diff --git a/internal/discovery/postgres_store.go b/internal/discovery/postgres_store.go
new file mode 100644
index 0000000..c36f07a
--- /dev/null
+++ b/internal/discovery/postgres_store.go
@@ -0,0 +1,332 @@
+package discovery
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// DefaultJobType is the job_runs.job_type discovery claims are recorded under.
+const DefaultJobType = "discovery"
+
+const (
+ defaultWindow = time.Minute
+ defaultLeaseTTL = 5 * time.Minute
+ maxEventType = 160
+ maxSummary = 500
+ maxDedupKey = 255
+)
+
+var (
+ // ErrLeaseLost reports that the job run this process claimed was taken over
+ // or completed elsewhere, so its result must not overwrite the newer one.
+ ErrLeaseLost = errors.New("discovery job lease was lost")
+ // ErrInvalidEvent reports an event that cannot be persisted safely.
+ ErrInvalidEvent = errors.New("invalid discovery event")
+ // ErrUnavailable reports a store without a usable database pool.
+ ErrUnavailable = errors.New("discovery store is unavailable")
+
+ uuidPattern = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$")
+ allowedSeverity = map[string]struct{}{"info": {}, "attention": {}, "warning": {}, "critical": {}}
+)
+
+// PostgresStore persists discovery job runs and discovered events.
+//
+// It deliberately mirrors alertworker.PostgresLeaseStore: a claim is a row in
+// job_runs guarded by (job_type, job_key, scheduled_at) with a bounded
+// lease_owner/lease_until pair, so two workers never run the same discovery
+// window twice and a worker that crashes mid-run has its lease reclaimed once
+// it expires instead of blocking discovery forever. Events are inserted with
+// ON CONFLICT DO NOTHING against the natural (source_id, dedup_key,
+// occurred_at) key, which is what makes a repeated pass idempotent.
+type PostgresStore struct {
+ Pool *pgxpool.Pool
+ // JobType overrides the job_runs.job_type value; DefaultJobType is used when empty.
+ JobType string
+ // Owner identifies this worker in job_runs.lease_owner.
+ Owner string
+ // Window is the schedule granularity a claim is truncated to. Two claims of
+ // the same key inside one window are the same unit of work.
+ Window time.Duration
+ // LeaseTTL bounds how long a claim blocks another worker after a crash.
+ LeaseTTL time.Duration
+ // Now is injectable for tests; time.Now is used when nil.
+ Now func() time.Time
+
+ mu sync.Mutex
+ claims map[string]time.Time
+}
+
+// NewPostgresStore validates the configuration and returns a ready store.
+func NewPostgresStore(pool *pgxpool.Pool, owner string) (*PostgresStore, error) {
+ if pool == nil {
+ return nil, ErrUnavailable
+ }
+ if strings.TrimSpace(owner) == "" || len(owner) > 120 {
+ return nil, errors.New("discovery store requires a bounded owner")
+ }
+ return &PostgresStore{Pool: pool, JobType: DefaultJobType, Owner: owner, Window: defaultWindow, LeaseTTL: defaultLeaseTTL}, nil
+}
+
+func (s *PostgresStore) jobType() string {
+ if strings.TrimSpace(s.JobType) == "" {
+ return DefaultJobType
+ }
+ return s.JobType
+}
+
+func (s *PostgresStore) window() time.Duration {
+ if s.Window <= 0 {
+ return defaultWindow
+ }
+ return s.Window
+}
+
+func (s *PostgresStore) leaseTTL() time.Duration {
+ if s.LeaseTTL <= 0 {
+ return defaultLeaseTTL
+ }
+ return s.LeaseTTL
+}
+
+func (s *PostgresStore) now() time.Time {
+ if s.Now != nil {
+ return s.Now().UTC()
+ }
+ return time.Now().UTC()
+}
+
+// Claim reserves the discovery window that contains at for this worker. It
+// returns false without an error when another worker holds a live lease or the
+// window already completed, which is the normal "nothing to do" outcome.
+func (s *PostgresStore) Claim(ctx context.Context, jobKey string, at time.Time) (bool, error) {
+ if s == nil || s.Pool == nil {
+ return false, ErrUnavailable
+ }
+ if strings.TrimSpace(jobKey) == "" || len(jobKey) > 255 {
+ return false, errors.New("discovery job key is invalid")
+ }
+ if at.IsZero() {
+ at = s.now()
+ }
+ now := s.now()
+ scheduledAt := at.UTC().Truncate(s.window())
+ leaseUntil := now.Add(s.leaseTTL())
+ tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return false, fmt.Errorf("begin discovery claim: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var insertedID string
+ err = tx.QueryRow(ctx, `INSERT INTO job_runs (id,job_type,job_key,scheduled_at,started_at,status,lease_owner,lease_until) VALUES ($1,$2,$3,$4,$5,'running',$6,$7) ON CONFLICT (job_type,job_key,scheduled_at) DO NOTHING RETURNING id`,
+ newID(), s.jobType(), jobKey, scheduledAt, now, s.Owner, leaseUntil).Scan(&insertedID)
+ if err == nil {
+ if err := tx.Commit(ctx); err != nil {
+ return false, fmt.Errorf("commit discovery claim: %w", err)
+ }
+ s.rememberClaim(jobKey, scheduledAt)
+ return true, nil
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return false, fmt.Errorf("insert discovery claim: %w", err)
+ }
+ var status string
+ var existingUntil *time.Time
+ if err := tx.QueryRow(ctx, `SELECT status,lease_until FROM job_runs WHERE job_type=$1 AND job_key=$2 AND scheduled_at=$3 FOR UPDATE`, s.jobType(), jobKey, scheduledAt).Scan(&status, &existingUntil); err != nil {
+ return false, fmt.Errorf("read discovery claim: %w", err)
+ }
+ if status != "running" || (existingUntil != nil && existingUntil.After(now)) {
+ if err := tx.Commit(ctx); err != nil {
+ return false, fmt.Errorf("commit discovery claim contention: %w", err)
+ }
+ return false, nil
+ }
+ tag, err := tx.Exec(ctx, `UPDATE job_runs SET started_at=$1,completed_at=NULL,error_code=NULL,lease_owner=$2,lease_until=$3 WHERE job_type=$4 AND job_key=$5 AND scheduled_at=$6 AND status='running' AND (lease_until IS NULL OR lease_until <= $7)`,
+ now, s.Owner, leaseUntil, s.jobType(), jobKey, scheduledAt, now)
+ if err != nil {
+ return false, fmt.Errorf("reclaim discovery lease: %w", err)
+ }
+ if tag.RowsAffected() != 1 {
+ if err := tx.Commit(ctx); err != nil {
+ return false, fmt.Errorf("commit discovery reclaim contention: %w", err)
+ }
+ return false, nil
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return false, fmt.Errorf("commit discovery reclaim: %w", err)
+ }
+ s.rememberClaim(jobKey, scheduledAt)
+ return true, nil
+}
+
+// Finish records the outcome of a claimed run. It only updates the row this
+// worker still owns, so a run whose lease expired cannot overwrite the result
+// of the worker that took over.
+func (s *PostgresStore) Finish(ctx context.Context, run JobRun) error {
+ if s == nil || s.Pool == nil {
+ return ErrUnavailable
+ }
+ if strings.TrimSpace(run.Key) == "" {
+ return errors.New("discovery job key is required")
+ }
+ scheduledAt, ok := s.takeClaim(run.Key)
+ if !ok {
+ reference := run.StartedAt
+ if reference.IsZero() {
+ reference = s.now()
+ }
+ scheduledAt = reference.UTC().Truncate(s.window())
+ }
+ status := "completed"
+ if run.Status != "succeeded" && run.Status != "completed" {
+ status = "failed"
+ }
+ errorCode := boundedText(run.ErrorCode, 160)
+ counts, err := json.Marshal(map[string]any{"status": status, "attempts": run.Attempts})
+ if err != nil {
+ return fmt.Errorf("encode discovery counts: %w", err)
+ }
+ completedAt := run.CompletedAt
+ if completedAt.IsZero() {
+ completedAt = s.now()
+ }
+ tag, err := s.Pool.Exec(ctx, `UPDATE job_runs SET status=$1,completed_at=$2,counts=$3::jsonb,error_code=NULLIF($4,''),lease_owner=NULL,lease_until=NULL WHERE job_type=$5 AND job_key=$6 AND scheduled_at=$7 AND lease_owner=$8 AND status='running'`,
+ status, completedAt.UTC(), counts, errorCode, s.jobType(), run.Key, scheduledAt, s.Owner)
+ if err != nil {
+ return fmt.Errorf("complete discovery job: %w", err)
+ }
+ if tag.RowsAffected() != 1 {
+ return ErrLeaseLost
+ }
+ return nil
+}
+
+// Emit persists one discovered event and reports whether it was new. A repeated
+// pass emitting the same (source, dedup key, occurrence time) is a no-op.
+func (s *PostgresStore) Emit(ctx context.Context, event Event) (bool, error) {
+ if s == nil || s.Pool == nil {
+ return false, ErrUnavailable
+ }
+ event, err := normalizeEvent(event)
+ if err != nil {
+ return false, err
+ }
+ attributes, err := json.Marshal(map[string]any{})
+ if err != nil {
+ return false, fmt.Errorf("encode discovery attributes: %w", err)
+ }
+ var inserted string
+ err = s.Pool.QueryRow(ctx, `INSERT INTO events (id,event_type,severity,entity_id,source_id,occurred_at,received_at,dedup_key,summary,attributes) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb) ON CONFLICT (source_id,dedup_key,occurred_at) DO NOTHING RETURNING id`,
+ DeterministicEventID(event), event.Type, event.Severity, nullableUUID(event.EntityID), event.SourceID, event.OccurredAt, s.now(), event.DedupKey, event.Summary, attributes).Scan(&inserted)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("emit discovery event: %w", err)
+ }
+ return true, nil
+}
+
+// DeterministicEventID derives a stable UUID from the event identity so a
+// retried emit reuses the same primary key instead of racing on a new one.
+func DeterministicEventID(event Event) string {
+ sum := sha256.Sum256([]byte("itworx-pulse/discovery-event/v1/" + event.SourceID + "\x00" + event.DedupKey + "\x00" + event.OccurredAt.UTC().Format(time.RFC3339Nano)))
+ return formatUUID(sum[:16], 0x50)
+}
+
+func normalizeEvent(event Event) (Event, error) {
+ event.SourceID = strings.TrimSpace(event.SourceID)
+ event.DedupKey = strings.TrimSpace(event.DedupKey)
+ event.Type = strings.TrimSpace(event.Type)
+ event.Summary = strings.TrimSpace(event.Summary)
+ event.EntityID = strings.TrimSpace(event.EntityID)
+ event.Severity = strings.ToLower(strings.TrimSpace(event.Severity))
+ if event.Severity == "" {
+ event.Severity = "info"
+ }
+ if !uuidPattern.MatchString(event.SourceID) {
+ return Event{}, fmt.Errorf("%w: source id must be a registered data source UUID", ErrInvalidEvent)
+ }
+ if event.EntityID != "" && !uuidPattern.MatchString(event.EntityID) {
+ return Event{}, fmt.Errorf("%w: entity id must be a UUID", ErrInvalidEvent)
+ }
+ if _, ok := allowedSeverity[event.Severity]; !ok {
+ return Event{}, fmt.Errorf("%w: severity is unsupported", ErrInvalidEvent)
+ }
+ if event.DedupKey == "" || len(event.DedupKey) > maxDedupKey {
+ return Event{}, fmt.Errorf("%w: dedup key must be 1-%d characters", ErrInvalidEvent, maxDedupKey)
+ }
+ if event.Type == "" || len(event.Type) > maxEventType {
+ return Event{}, fmt.Errorf("%w: type must be 1-%d characters", ErrInvalidEvent, maxEventType)
+ }
+ if event.Summary == "" || len(event.Summary) > maxSummary {
+ return Event{}, fmt.Errorf("%w: summary must be 1-%d characters", ErrInvalidEvent, maxSummary)
+ }
+ if event.OccurredAt.IsZero() {
+ return Event{}, fmt.Errorf("%w: occurrence time is required", ErrInvalidEvent)
+ }
+ event.OccurredAt = event.OccurredAt.UTC()
+ return event, nil
+}
+
+func (s *PostgresStore) rememberClaim(jobKey string, scheduledAt time.Time) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.claims == nil {
+ s.claims = make(map[string]time.Time)
+ }
+ s.claims[jobKey] = scheduledAt
+}
+
+func (s *PostgresStore) takeClaim(jobKey string) (time.Time, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ scheduledAt, ok := s.claims[jobKey]
+ if ok {
+ delete(s.claims, jobKey)
+ }
+ return scheduledAt, ok
+}
+
+func nullableUUID(value string) any {
+ if value == "" {
+ return nil
+ }
+ return value
+}
+
+func boundedText(value string, max int) string {
+ value = strings.TrimSpace(value)
+ if len(value) > max {
+ return value[:max]
+ }
+ return value
+}
+
+func newID() string {
+ raw := make([]byte, 16)
+ if _, err := rand.Read(raw); err != nil {
+ sum := sha256.Sum256([]byte(fmt.Sprintf("itworx-pulse/discovery-run/v1/%d", time.Now().UnixNano())))
+ copy(raw, sum[:16])
+ }
+ return formatUUID(raw, 0x40)
+}
+
+func formatUUID(raw []byte, version byte) string {
+ b := make([]byte, 16)
+ copy(b, raw)
+ b[6] = (b[6] & 0x0f) | version
+ b[8] = (b[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%s-%s-%s-%s-%s", hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]), hex.EncodeToString(b[6:8]), hex.EncodeToString(b[8:10]), hex.EncodeToString(b[10:16]))
+}
diff --git a/internal/discovery/postgres_store_integration_test.go b/internal/discovery/postgres_store_integration_test.go
new file mode 100644
index 0000000..1caa96e
--- /dev/null
+++ b/internal/discovery/postgres_store_integration_test.go
@@ -0,0 +1,99 @@
+package discovery
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+// TestPostgreSQLDiscoveryStoreCoordinatesAndDeduplicates exercises the real
+// job_runs lease and events deduplication semantics. It is skipped unless
+// PULSE_TEST_DATABASE_URL points at a disposable PostgreSQL instance.
+func TestPostgreSQLDiscoveryStoreCoordinatesAndDeduplicates(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 4, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ sourceID := newID()
+ if _, err := pool.Exec(ctx, `INSERT INTO data_sources (id,type,name,configuration_ref) VALUES ($1,'agent','discovery-test','test')`, sourceID); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cleanupCancel()
+ _, _ = pool.Exec(cleanupCtx, `DELETE FROM events WHERE source_id=$1`, sourceID)
+ _, _ = pool.Exec(cleanupCtx, `DELETE FROM data_sources WHERE id=$1`, sourceID)
+ })
+
+ now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+ jobKey := "container:" + sourceID
+ first, err := NewPostgresStore(pool, "worker-a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ first.Now = func() time.Time { return now }
+ second, err := NewPostgresStore(pool, "worker-b")
+ if err != nil {
+ t.Fatal(err)
+ }
+ second.Now = func() time.Time { return now }
+
+ claimed, err := first.Claim(ctx, jobKey, now)
+ if err != nil || !claimed {
+ t.Fatalf("first claim = %v err = %v", claimed, err)
+ }
+ if claimed, err := second.Claim(ctx, jobKey, now); err != nil || claimed {
+ t.Fatalf("second worker claimed a held window: %v err = %v", claimed, err)
+ }
+ if err := first.Finish(ctx, JobRun{Key: jobKey, Status: "succeeded", Attempts: 1, StartedAt: now, CompletedAt: now}); err != nil {
+ t.Fatal(err)
+ }
+ if claimed, err := second.Claim(ctx, jobKey, now); err != nil || claimed {
+ t.Fatalf("a completed window was reclaimed: %v err = %v", claimed, err)
+ }
+
+ // A worker that dies mid-run must not block the job forever.
+ expiredKey := jobKey + ":expired"
+ first.LeaseTTL = time.Second
+ if claimed, err := first.Claim(ctx, expiredKey, now); err != nil || !claimed {
+ t.Fatalf("expired-window claim = %v err = %v", claimed, err)
+ }
+ second.Now = func() time.Time { return now.Add(5 * time.Second) }
+ if claimed, err := second.Claim(ctx, expiredKey, now); err != nil || !claimed {
+ t.Fatalf("expired lease was not reclaimed: %v err = %v", claimed, err)
+ }
+
+ event := Event{SourceID: sourceID, DedupKey: "entity:1:state_changed", Type: "container.state_changed",
+ Summary: "Container state changed.", Severity: "attention", OccurredAt: now}
+ inserted, err := first.Emit(ctx, event)
+ if err != nil || !inserted {
+ t.Fatalf("first emit inserted = %v err = %v", inserted, err)
+ }
+ inserted, err = first.Emit(ctx, event)
+ if err != nil || inserted {
+ t.Fatalf("repeated emit inserted = %v err = %v", inserted, err)
+ }
+ var count int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM events WHERE source_id=$1`, sourceID).Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatalf("event rows = %d, want 1", count)
+ }
+ if _, err := first.Emit(ctx, Event{SourceID: "not-a-uuid", DedupKey: "k", Type: "t", Summary: "s", OccurredAt: now}); err == nil {
+ t.Fatal("an unregistered source must be rejected at the persistence boundary")
+ }
+}
diff --git a/internal/disk/performance.go b/internal/disk/performance.go
new file mode 100644
index 0000000..8abacd7
--- /dev/null
+++ b/internal/disk/performance.go
@@ -0,0 +1,187 @@
+package disk
+
+import (
+ "errors"
+ "math"
+ "sort"
+ "strings"
+ "time"
+)
+
+type RawPerformanceSample struct {
+ ObservedAt time.Time `json:"observedAt"`
+ ReadBytesPerSecond *float64 `json:"readBytesPerSecond,omitempty"`
+ WriteBytesPerSecond *float64 `json:"writeBytesPerSecond,omitempty"`
+ ReadIOPS *float64 `json:"readIops,omitempty"`
+ WriteIOPS *float64 `json:"writeIops,omitempty"`
+ ReadLatencyMs *float64 `json:"readLatencyMs,omitempty"`
+ WriteLatencyMs *float64 `json:"writeLatencyMs,omitempty"`
+}
+type PerformanceSample struct {
+ ObservedAt time.Time `json:"observedAt"`
+ ReadBytesPerSecond *float64 `json:"readBytesPerSecond,omitempty"`
+ WriteBytesPerSecond *float64 `json:"writeBytesPerSecond,omitempty"`
+ ReadIOPS *float64 `json:"readIops,omitempty"`
+ WriteIOPS *float64 `json:"writeIops,omitempty"`
+ ReadLatencyMs *float64 `json:"readLatencyMs,omitempty"`
+ WriteLatencyMs *float64 `json:"writeLatencyMs,omitempty"`
+}
+type RawPerformance struct {
+ Available bool `json:"available"`
+ Current RawPerformanceSample `json:"current"`
+ History []RawPerformanceSample `json:"history,omitempty"`
+}
+type Performance struct {
+ State string `json:"state"`
+ Current PerformanceSample `json:"current"`
+ History []PerformanceSample `json:"history,omitempty"`
+}
+type RawTemperature struct {
+ Available bool `json:"available"`
+ Celsius float64 `json:"celsius"`
+ ObservedAt time.Time `json:"observedAt"`
+}
+type Temperature struct {
+ State string `json:"state"`
+ Celsius *float64 `json:"celsius,omitempty"`
+ Status string `json:"status"`
+ ObservedAt *time.Time `json:"observedAt,omitempty"`
+}
+type RawSpin struct {
+ Supported bool `json:"supported"`
+ State string `json:"state"`
+}
+type Spin struct {
+ State string `json:"state"`
+}
+type PerformancePolicy struct {
+ MaxHistory int
+ TemperatureWarningCelsius float64
+ TemperatureCriticalCelsius float64
+ TemperatureRecoveryCelsius float64
+}
+
+func (p PerformancePolicy) withDefaults() PerformancePolicy {
+ if p.MaxHistory == 0 {
+ p.MaxHistory = 120
+ }
+ if p.TemperatureWarningCelsius == 0 {
+ p.TemperatureWarningCelsius = 50
+ }
+ if p.TemperatureCriticalCelsius == 0 {
+ p.TemperatureCriticalCelsius = 55
+ }
+ if p.TemperatureRecoveryCelsius == 0 {
+ p.TemperatureRecoveryCelsius = 45
+ }
+ return p
+}
+func (p PerformancePolicy) Validate() error {
+ if p.MaxHistory < 1 || p.MaxHistory > 1000 || p.TemperatureRecoveryCelsius >= p.TemperatureWarningCelsius || p.TemperatureWarningCelsius >= p.TemperatureCriticalCelsius {
+ return errors.New("disk performance policy is outside safe bounds")
+ }
+ return nil
+}
+func normalizePerformance(raw *RawPerformance, now time.Time, policy PerformancePolicy) (*Performance, error) {
+ if raw == nil {
+ return nil, nil
+ }
+ policy = policy.withDefaults()
+ if err := policy.Validate(); err != nil {
+ return nil, err
+ }
+ result := &Performance{State: "unknown", History: []PerformanceSample{}}
+ if !raw.Available {
+ result.State = "unsupported"
+ return result, nil
+ }
+ if len(raw.History) > policy.MaxHistory {
+ return nil, errors.New("disk performance history exceeds bounds")
+ }
+ current, err := normalizeSample(raw.Current, now)
+ if err != nil {
+ return nil, err
+ }
+ result.State = "available"
+ result.Current = current
+ for _, item := range raw.History {
+ sample, sampleErr := normalizeSample(item, now)
+ if sampleErr != nil {
+ return nil, sampleErr
+ }
+ result.History = append(result.History, sample)
+ }
+ sort.Slice(result.History, func(i, j int) bool { return result.History[i].ObservedAt.After(result.History[j].ObservedAt) })
+ return result, nil
+}
+func normalizeSample(raw RawPerformanceSample, now time.Time) (PerformanceSample, error) {
+ observed := raw.ObservedAt
+ if observed.IsZero() {
+ observed = now
+ }
+ if observed.After(now.Add(time.Minute)) {
+ return PerformanceSample{}, errors.New("disk performance observation is materially in the future")
+ }
+ for _, value := range []*float64{raw.ReadBytesPerSecond, raw.WriteBytesPerSecond, raw.ReadIOPS, raw.WriteIOPS, raw.ReadLatencyMs, raw.WriteLatencyMs} {
+ if value != nil && (*value < 0 || math.IsNaN(*value) || math.IsInf(*value, 0)) {
+ return PerformanceSample{}, errors.New("disk performance value is invalid")
+ }
+ }
+ return PerformanceSample{ObservedAt: observed.UTC(), ReadBytesPerSecond: raw.ReadBytesPerSecond, WriteBytesPerSecond: raw.WriteBytesPerSecond, ReadIOPS: raw.ReadIOPS, WriteIOPS: raw.WriteIOPS, ReadLatencyMs: raw.ReadLatencyMs, WriteLatencyMs: raw.WriteLatencyMs}, nil
+}
+func normalizeTemperature(raw *RawTemperature, now time.Time, policy PerformancePolicy) (*Temperature, error) {
+ if raw == nil {
+ return nil, nil
+ }
+ result := &Temperature{State: "unknown", Status: "unknown"}
+ if !raw.Available {
+ return result, nil
+ }
+ if raw.Celsius < -50 || raw.Celsius > 150 || math.IsNaN(raw.Celsius) || math.IsInf(raw.Celsius, 0) {
+ return nil, errors.New("disk temperature is invalid")
+ }
+ observed := raw.ObservedAt
+ if observed.IsZero() {
+ observed = now
+ }
+ if observed.After(now.Add(time.Minute)) {
+ return nil, errors.New("disk temperature observation is materially in the future")
+ }
+ result.State = "available"
+ result.Celsius = &raw.Celsius
+ value := observed.UTC()
+ result.ObservedAt = &value
+ result.Status = temperatureStatus("normal", raw.Celsius, policy)
+ return result, nil
+}
+func temperatureStatus(previous string, celsius float64, policy PerformancePolicy) string {
+ policy = policy.withDefaults()
+ if celsius >= policy.TemperatureCriticalCelsius {
+ return "critical"
+ }
+ if celsius >= policy.TemperatureWarningCelsius {
+ return "attention"
+ }
+ if (previous == "attention" || previous == "critical") && celsius >= policy.TemperatureRecoveryCelsius {
+ return previous
+ }
+ return "normal"
+}
+func TemperatureStatus(previous string, celsius float64, policy PerformancePolicy) string {
+ return temperatureStatus(strings.ToLower(previous), celsius, policy)
+}
+func normalizeSpin(raw *RawSpin) (*Spin, error) {
+ if raw == nil {
+ return nil, nil
+ }
+ if !raw.Supported {
+ return &Spin{State: "unsupported"}, nil
+ }
+ state := strings.ToLower(strings.TrimSpace(raw.State))
+ switch state {
+ case "spinning", "idle", "standby", "unknown":
+ return &Spin{State: state}, nil
+ default:
+ return &Spin{State: "unknown"}, nil
+ }
+}
diff --git a/internal/disk/performance_test.go b/internal/disk/performance_test.go
new file mode 100644
index 0000000..99f9347
--- /dev/null
+++ b/internal/disk/performance_test.go
@@ -0,0 +1,52 @@
+package disk
+
+import (
+ "testing"
+ "time"
+)
+
+func float(value float64) *float64 { return &value }
+func TestTemperatureHysteresisAndUnsupportedCapabilities(t *testing.T) {
+ policy := PerformancePolicy{}
+ if got := TemperatureStatus("normal", 52, policy); got != "attention" {
+ t.Fatalf("got=%s", got)
+ }
+ if got := TemperatureStatus("attention", 48, policy); got != "attention" {
+ t.Fatalf("got=%s", got)
+ }
+ if got := TemperatureStatus("attention", 44, policy); got != "normal" {
+ t.Fatalf("got=%s", got)
+ }
+ spin, err := normalizeSpin(&RawSpin{Supported: false})
+ if err != nil || spin.State != "unsupported" {
+ t.Fatalf("spin=%+v err=%v", spin, err)
+ }
+ performance, err := normalizePerformance(&RawPerformance{Available: false}, time.Now(), PerformancePolicy{})
+ if err != nil || performance.State != "unsupported" {
+ t.Fatalf("performance=%+v err=%v", performance, err)
+ }
+}
+func TestPerformanceHistoryIsBoundedAndSorted(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ old := now.Add(-time.Minute)
+ newer := now.Add(-time.Second)
+ raw := RawPerformance{Available: true, Current: RawPerformanceSample{ObservedAt: now, ReadBytesPerSecond: float(100)}, History: []RawPerformanceSample{{ObservedAt: old}, {ObservedAt: newer}}}
+ got, err := normalizePerformance(&raw, now, PerformancePolicy{MaxHistory: 2})
+ if err != nil || got.State != "available" || len(got.History) != 2 || !got.History[0].ObservedAt.Equal(newer) {
+ t.Fatalf("performance=%+v err=%v", got, err)
+ }
+ raw.History = append(raw.History, RawPerformanceSample{})
+ if _, err = normalizePerformance(&raw, now, PerformancePolicy{MaxHistory: 2}); err == nil {
+ t.Fatal("expected history bounds error")
+ }
+}
+func TestTemperatureObservationAndPerformanceValuesRejectInvalid(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ future := now.Add(2 * time.Hour)
+ if _, err := normalizeTemperature(&RawTemperature{Available: true, Celsius: 52, ObservedAt: future}, now, PerformancePolicy{}); err == nil {
+ t.Fatal("expected future temperature error")
+ }
+ if _, err := normalizePerformance(&RawPerformance{Available: true, Current: RawPerformanceSample{ObservedAt: now, ReadBytesPerSecond: float(-1)}}, now, PerformancePolicy{}); err == nil {
+ t.Fatal("expected invalid performance error")
+ }
+}
diff --git a/internal/disk/smart.go b/internal/disk/smart.go
new file mode 100644
index 0000000..8b095d9
--- /dev/null
+++ b/internal/disk/smart.go
@@ -0,0 +1,187 @@
+package disk
+
+import (
+ "errors"
+ "sort"
+ "strings"
+ "time"
+)
+
+type RawSMARTAttribute struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ RawValue int64 `json:"rawValue"`
+ NormalizedValue *float64 `json:"normalizedValue,omitempty"`
+ Unit string `json:"unit,omitempty"`
+}
+type SMARTAttribute struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ RawValue int64 `json:"rawValue"`
+ NormalizedValue *float64 `json:"normalizedValue,omitempty"`
+ Unit string `json:"unit,omitempty"`
+ Status string `json:"status"`
+ Critical bool `json:"critical"`
+ Reason string `json:"reason,omitempty"`
+}
+type RawSelfTest struct {
+ Supported bool `json:"supported"`
+ Result string `json:"result"`
+ CompletedAt *time.Time `json:"completedAt,omitempty"`
+}
+type SelfTest struct {
+ Supported bool `json:"supported"`
+ Result string `json:"result"`
+ CompletedAt *time.Time `json:"completedAt,omitempty"`
+ AgeSeconds *float64 `json:"ageSeconds,omitempty"`
+}
+type RawSMART struct {
+ Available bool `json:"available"`
+ Overall string `json:"overall"`
+ ObservedAt time.Time `json:"observedAt"`
+ Attributes []RawSMARTAttribute `json:"attributes,omitempty"`
+ SelfTest *RawSelfTest `json:"selfTest,omitempty"`
+}
+type SMART struct {
+ State string `json:"state"`
+ Overall string `json:"overall"`
+ ObservedAt *time.Time `json:"observedAt,omitempty"`
+ Attributes []SMARTAttribute `json:"attributes,omitempty"`
+ SelfTest *SelfTest `json:"selfTest,omitempty"`
+ Reasons []string `json:"reasons,omitempty"`
+}
+
+const (
+ SMARTAvailable = "available"
+ SMARTUnknown = "unknown"
+ SMARTAttention = "attention"
+ SMARTFailed = "failed"
+ SMARTPassed = "passed"
+)
+
+func normalizeSMART(raw *RawSMART, now time.Time, policy Policy) (*SMART, error) {
+ if raw == nil {
+ return nil, nil
+ }
+ result := &SMART{State: SMARTUnknown, Overall: SMARTUnknown, Attributes: []SMARTAttribute{}, Reasons: []string{}}
+ if !raw.Available {
+ result.Reasons = []string{"smart_unavailable"}
+ return result, nil
+ }
+ observed := raw.ObservedAt
+ if observed.IsZero() {
+ observed = now
+ }
+ observed = observed.UTC()
+ result.ObservedAt = &observed
+ if now.Sub(observed) > policy.SMARTFreshnessMaxAge {
+ result.Reasons = []string{"smart_stale"}
+ return result, nil
+ }
+ result.State = SMARTAvailable
+ result.Overall = normalizeOverall(raw.Overall)
+ attrs := make([]SMARTAttribute, 0, len(raw.Attributes))
+ reasons := map[string]bool{}
+ for _, item := range raw.Attributes {
+ if strings.TrimSpace(item.ID) == "" || len(item.ID) > 64 || len(item.Name) > 128 {
+ return nil, errors.New("SMART attribute identity is invalid")
+ }
+ canonical := canonicalAttribute(item.ID, item.Name)
+ critical, reason := criticalAttribute(canonical, item.RawValue)
+ status := "normal"
+ if critical {
+ status = SMARTAttention
+ reasons[reason] = true
+ }
+ attrs = append(attrs, SMARTAttribute{ID: item.ID, Name: item.Name, RawValue: item.RawValue, NormalizedValue: item.NormalizedValue, Unit: bounded(item.Unit, ""), Status: status, Critical: critical, Reason: reason})
+ }
+ sort.Slice(attrs, func(i, j int) bool {
+ if attrs[i].Critical != attrs[j].Critical {
+ return attrs[i].Critical
+ }
+ return attrs[i].ID < attrs[j].ID
+ })
+ if raw.SelfTest != nil {
+ self, err := normalizeSelfTest(raw.SelfTest, now)
+ if err != nil {
+ return nil, err
+ }
+ result.SelfTest = self
+ }
+ for _, reason := range []string{"reallocated_sectors", "pending_sectors", "offline_uncorrectable", "crc_errors", "wear"} {
+ if reasons[reason] {
+ result.Reasons = append(result.Reasons, reason)
+ }
+ }
+ if len(result.Reasons) > 0 && result.Overall == SMARTPassed {
+ result.Overall = SMARTAttention
+ }
+ if result.Overall == SMARTFailed {
+ result.Reasons = append(result.Reasons, "smart_overall_failed")
+ }
+ return result, nil
+}
+func normalizeOverall(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case SMARTPassed:
+ return SMARTPassed
+ case SMARTFailed:
+ return SMARTFailed
+ case SMARTAttention, "warning", "warn":
+ return SMARTAttention
+ default:
+ return SMARTUnknown
+ }
+}
+func canonicalAttribute(id, name string) string {
+ value := strings.ToLower(strings.TrimSpace(id) + " " + strings.TrimSpace(name))
+ switch {
+ case strings.Contains(value, "197") || strings.Contains(value, "pending"):
+ return "pending"
+ case strings.Contains(value, "198") || strings.Contains(value, "offline") || strings.Contains(value, "uncorrectable"):
+ return "offline"
+ case strings.Contains(value, "199") || strings.Contains(value, "crc") || strings.Contains(value, "interface"):
+ return "crc"
+ case strings.Contains(value, "realloc") || strings.Contains(value, " 5 "):
+ return "reallocated"
+ case strings.Contains(value, "wear") || strings.Contains(value, "life") || strings.Contains(value, "percent_used") || strings.Contains(value, "177"):
+ return "wear"
+ default:
+ return "other"
+ }
+}
+func criticalAttribute(kind string, value int64) (bool, string) {
+ if value <= 0 {
+ return false, ""
+ }
+ switch kind {
+ case "reallocated":
+ return true, "reallocated_sectors"
+ case "pending":
+ return true, "pending_sectors"
+ case "offline":
+ return true, "offline_uncorrectable"
+ case "crc":
+ return true, "crc_errors"
+ case "wear":
+ return true, "wear"
+ default:
+ return false, ""
+ }
+}
+func normalizeSelfTest(raw *RawSelfTest, now time.Time) (*SelfTest, error) {
+ result := &SelfTest{Supported: raw.Supported, Result: normalizeOverall(raw.Result)}
+ if raw.CompletedAt != nil {
+ value := raw.CompletedAt.UTC()
+ if value.After(now.Add(time.Minute)) {
+ return nil, errors.New("SMART self-test is materially in the future")
+ }
+ result.CompletedAt = &value
+ age := now.Sub(value).Seconds()
+ if age < 0 {
+ age = 0
+ }
+ result.AgeSeconds = &age
+ }
+ return result, nil
+}
diff --git a/internal/disk/smart_test.go b/internal/disk/smart_test.go
new file mode 100644
index 0000000..e1fd675
--- /dev/null
+++ b/internal/disk/smart_test.go
@@ -0,0 +1,55 @@
+package disk
+
+import (
+ "testing"
+ "time"
+)
+
+func TestSMARTCriticalAttributesOverrideGenericPassed(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{{ID: "disk-smart", Name: "SMART", SizeBytes: 100, SMART: &RawSMART{Available: true, Overall: SMARTPassed, ObservedAt: now, Attributes: []RawSMARTAttribute{{ID: "5", Name: "Reallocated Sector Count", RawValue: 1}, {ID: "197", Name: "Current Pending Sector", RawValue: 2}, {ID: "198", Name: "Offline Uncorrectable", RawValue: 1}, {ID: "199", Name: "UDMA CRC Error Count", RawValue: 3}, {ID: "177", Name: "Wear Leveling Count", RawValue: 95}}}}}}
+ got, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ smart := got.Disks[0].SMART
+ if smart == nil || smart.State != SMARTAvailable || smart.Overall != SMARTAttention {
+ t.Fatalf("smart=%+v", smart)
+ }
+ if len(smart.Reasons) != 5 {
+ t.Fatalf("reasons=%v", smart.Reasons)
+ }
+ for _, attribute := range smart.Attributes {
+ if !attribute.Critical || attribute.Status != SMARTAttention {
+ t.Fatalf("attribute=%+v", attribute)
+ }
+ }
+}
+func TestSMARTUnavailableAndStaleAreUnknown(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ unavailable := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{{ID: "disk", Name: "Disk", SizeBytes: 1, SMART: &RawSMART{Available: false, Overall: SMARTPassed}}}}
+ got, err := Normalize(unavailable, now, Limits{}, Policy{})
+ if err != nil || got.Disks[0].SMART.State != SMARTUnknown || got.Disks[0].SMART.Overall != SMARTUnknown {
+ t.Fatalf("unavailable=%+v err=%v", got, err)
+ }
+ stale := unavailable
+ stale.Disks[0].SMART = &RawSMART{Available: true, Overall: SMARTPassed, ObservedAt: now.Add(-2 * time.Hour)}
+ got, err = Normalize(stale, now, Limits{}, Policy{SMARTFreshnessMaxAge: time.Hour})
+ if err != nil || got.Disks[0].SMART.State != SMARTUnknown || got.Disks[0].SMART.Reasons[0] != "smart_stale" {
+ t.Fatalf("stale=%+v err=%v", got, err)
+ }
+}
+func TestSMARTSelfTestAgeAndNoFutureTimestamp(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ completed := now.Add(-2 * time.Hour)
+ raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{{ID: "disk", Name: "Disk", SizeBytes: 1, SMART: &RawSMART{Available: true, Overall: SMARTPassed, ObservedAt: now, SelfTest: &RawSelfTest{Supported: true, Result: SMARTPassed, CompletedAt: &completed}}}}}
+ got, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil || got.Disks[0].SMART.SelfTest == nil || *got.Disks[0].SMART.SelfTest.AgeSeconds != 7200 {
+ t.Fatalf("selftest=%+v err=%v", got.Disks[0].SMART.SelfTest, err)
+ }
+ future := now.Add(2 * time.Hour)
+ raw.Disks[0].SMART.SelfTest.CompletedAt = &future
+ if _, err = Normalize(raw, now, Limits{}, Policy{}); err == nil {
+ t.Fatal("expected future self-test error")
+ }
+}
diff --git a/internal/disk/types.go b/internal/disk/types.go
new file mode 100644
index 0000000..eec769a
--- /dev/null
+++ b/internal/disk/types.go
@@ -0,0 +1,398 @@
+package disk
+
+import (
+ "context"
+ "errors"
+ "math"
+ "reflect"
+ "sort"
+ "strings"
+ "time"
+)
+
+const ContractVersion = "v1"
+
+const (
+ StateOnline = "online"
+ StateMissing = "missing"
+ StateDisabled = "disabled"
+ StateEmulated = "emulated"
+ StateUnknown = "unknown"
+ Fresh = "fresh"
+ Stale = "stale"
+ Unavailable = "unavailable"
+)
+
+type Limits struct {
+ MaxDisks int
+ MaxHistory int
+}
+
+func (l Limits) withDefaults() Limits {
+ if l.MaxDisks == 0 {
+ l.MaxDisks = 150
+ }
+ if l.MaxHistory == 0 {
+ l.MaxHistory = 256
+ }
+ return l
+}
+func (l Limits) Validate() error {
+ if l.MaxDisks < 1 || l.MaxDisks > 512 || l.MaxHistory < 1 || l.MaxHistory > 512 {
+ return errors.New("disk limits are outside safe bounds")
+ }
+ return nil
+}
+
+type Policy struct {
+ FreshnessMaxAge time.Duration
+ SMARTFreshnessMaxAge time.Duration
+ WarningUtilizationPercent float64
+ CriticalUtilizationPercent float64
+ Performance PerformancePolicy
+}
+
+func (p Policy) withDefaults() Policy {
+ if p.FreshnessMaxAge == 0 {
+ p.FreshnessMaxAge = 60 * time.Second
+ }
+ if p.SMARTFreshnessMaxAge == 0 {
+ p.SMARTFreshnessMaxAge = 24 * time.Hour
+
+ }
+ if p.WarningUtilizationPercent == 0 {
+ p.WarningUtilizationPercent = 80
+ }
+ if p.CriticalUtilizationPercent == 0 {
+ p.CriticalUtilizationPercent = 95
+ }
+ p.Performance = p.Performance.withDefaults()
+ return p
+}
+func (p Policy) Validate() error {
+ if p.FreshnessMaxAge <= 0 || p.FreshnessMaxAge > 24*time.Hour || p.SMARTFreshnessMaxAge <= 0 || p.SMARTFreshnessMaxAge > 30*24*time.Hour || p.WarningUtilizationPercent < 0 || p.WarningUtilizationPercent > 100 || p.CriticalUtilizationPercent < p.WarningUtilizationPercent || p.CriticalUtilizationPercent > 100 || p.Performance.Validate() != nil {
+ return errors.New("disk policy is outside safe bounds")
+ }
+ return nil
+}
+
+type Source struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ CapabilityVersion string `json:"capabilityVersion"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Freshness string `json:"freshness"`
+ State string `json:"state"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type RawInodes struct {
+ Total uint64 `json:"total"`
+ Used uint64 `json:"used"`
+}
+type Inodes struct {
+ Total uint64 `json:"total"`
+ Used uint64 `json:"used"`
+ Free uint64 `json:"free"`
+ UtilizationPercent float64 `json:"utilizationPercent"`
+}
+type RawDisk struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ State string `json:"state"`
+ Model string `json:"model,omitempty"`
+ Serial string `json:"serial,omitempty"`
+ Filesystem string `json:"filesystem,omitempty"`
+ SizeBytes uint64 `json:"sizeBytes"`
+ UsedBytes uint64 `json:"usedBytes"`
+ Inodes *RawInodes `json:"inodes,omitempty"`
+ SMART *RawSMART `json:"smart,omitempty"`
+ Performance *RawPerformance `json:"performance,omitempty"`
+ Temperature *RawTemperature `json:"temperature,omitempty"`
+ Spin *RawSpin `json:"spin,omitempty"`
+}
+type Disk struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ State string `json:"state"`
+ Model string `json:"model,omitempty"`
+ SerialDisplay string `json:"serialDisplay,omitempty"`
+ Filesystem string `json:"filesystem,omitempty"`
+ SizeBytes uint64 `json:"sizeBytes"`
+ UsedBytes uint64 `json:"usedBytes"`
+ FreeBytes uint64 `json:"freeBytes"`
+ UtilizationPercent float64 `json:"utilizationPercent"`
+ CapacitySeverity string `json:"capacitySeverity"`
+ ThermalSeverity string `json:"thermalSeverity"`
+ Inodes *Inodes `json:"inodes,omitempty"`
+ SMART *SMART `json:"smart,omitempty"`
+ Performance *Performance `json:"performance,omitempty"`
+ Temperature *Temperature `json:"temperature,omitempty"`
+ Spin *Spin `json:"spin,omitempty"`
+}
+type RawMissingObservation struct {
+ DiskID string `json:"diskId"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ ObservedAt time.Time `json:"observedAt"`
+ Reason string `json:"reason,omitempty"`
+}
+type MissingObservation struct {
+ DiskID string `json:"diskId"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ ObservedAt time.Time `json:"observedAt"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type RawSnapshot struct {
+ Source Source `json:"source"`
+ Disks []RawDisk `json:"disks"`
+ MissingHistory []RawMissingObservation `json:"missingHistory,omitempty"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+}
+type Snapshot struct {
+ ContractVersion string `json:"contractVersion"`
+ Source Source `json:"source"`
+ Disks []Disk `json:"disks"`
+ Total int `json:"total"`
+ MissingHistory []MissingObservation `json:"missingHistory,omitempty"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+}
+type Provider interface {
+ Snapshot(context.Context) (Snapshot, error)
+}
+type RawProvider interface {
+ Snapshot(context.Context) (RawSnapshot, error)
+}
+type Adapter struct {
+ Source RawProvider
+ Limits Limits
+ Policy Policy
+ Now func() time.Time
+}
+
+func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return Snapshot{}, err
+ }
+ if a.Source == nil {
+ return UnknownSnapshot(time.Now().UTC(), "disks", "unraid", "source_unavailable"), nil
+ }
+ raw, err := a.Source.Snapshot(ctx)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ now := time.Now().UTC()
+ if a.Now != nil {
+ now = a.Now()
+ }
+ return Normalize(raw, now, a.Limits, a.Policy)
+}
+func UnknownSnapshot(now time.Time, id, typ, reason string) Snapshot {
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ now = now.UTC()
+ return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: id, Type: typ, CapabilityVersion: ContractVersion, ReceivedAt: now, Freshness: Unavailable, State: StateUnknown, Reason: reason}, Disks: []Disk{}, MissingHistory: []MissingObservation{}, ObservedAt: now, ReceivedAt: now}
+}
+
+func Normalize(raw RawSnapshot, now time.Time, limits Limits, policy Policy) (Snapshot, error) {
+ limits = limits.withDefaults()
+ policy = policy.withDefaults()
+ if err := limits.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ if err := policy.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ if raw.ReceivedAt.IsZero() {
+ raw.ReceivedAt = now
+ }
+ if raw.ObservedAt.IsZero() {
+ raw.ObservedAt = raw.ReceivedAt
+ }
+ if raw.ObservedAt.After(now.Add(time.Minute)) {
+ return Snapshot{}, errors.New("disk observation is materially in the future")
+ }
+ if len(raw.Disks) > limits.MaxDisks || len(raw.MissingHistory) > limits.MaxHistory {
+ return Snapshot{}, errors.New("disk payload exceeds bounds")
+ }
+ disks := make([]Disk, 0, len(raw.Disks))
+ seen := make(map[string]RawDisk, len(raw.Disks))
+ for _, item := range raw.Disks {
+ item.ID = canonicalIdentity(item.ID)
+ if previous, ok := seen[item.ID]; ok {
+ if reflect.DeepEqual(previous, item) {
+ continue
+ }
+ return Snapshot{}, errors.New("conflicting duplicate disk identity")
+ }
+ seen[item.ID] = item
+ normalized, err := normalizeDisk(item, now, policy)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ disks = append(disks, normalized)
+ }
+ sort.Slice(disks, func(i, j int) bool {
+ if disks[i].Role != disks[j].Role {
+ return disks[i].Role < disks[j].Role
+ }
+ if disks[i].Name != disks[j].Name {
+ return disks[i].Name < disks[j].Name
+ }
+ return disks[i].ID < disks[j].ID
+ })
+ missing := make([]MissingObservation, 0, len(raw.MissingHistory))
+ for _, item := range raw.MissingHistory {
+ if strings.TrimSpace(item.DiskID) == "" || len(item.DiskID) > 128 || len(item.Name) > 255 {
+ return Snapshot{}, errors.New("missing disk history identity is invalid")
+ }
+ observed := item.ObservedAt
+ if observed.IsZero() {
+ observed = raw.ObservedAt
+ }
+ missing = append(missing, MissingObservation{DiskID: item.DiskID, Name: item.Name, Role: bounded(item.Role, "data"), ObservedAt: observed.UTC(), Reason: bounded(item.Reason, "missing")})
+ }
+ sort.Slice(missing, func(i, j int) bool {
+ if !missing[i].ObservedAt.Equal(missing[j].ObservedAt) {
+ return missing[i].ObservedAt.After(missing[j].ObservedAt)
+ }
+ return missing[i].DiskID < missing[j].DiskID
+ })
+ source := raw.Source
+ if source.ID == "" {
+ source.ID = "disks"
+ }
+ if source.Type == "" {
+ source.Type = "unraid"
+ }
+ if source.CapabilityVersion == "" {
+ source.CapabilityVersion = ContractVersion
+ }
+ source.ObservedAt = raw.ObservedAt.UTC()
+ source.ReceivedAt = raw.ReceivedAt.UTC()
+ source.Freshness = Fresh
+ source.State = "healthy"
+ if now.Sub(raw.ObservedAt) > policy.FreshnessMaxAge {
+ source.Freshness = Stale
+ source.State = StateUnknown
+ source.Reason = "stale_source"
+ }
+ result := Snapshot{ContractVersion: ContractVersion, Source: source, Disks: disks, Total: len(disks), MissingHistory: missing, ObservedAt: raw.ObservedAt.UTC(), ReceivedAt: raw.ReceivedAt.UTC()}
+ if source.State == StateUnknown {
+ result.Source.State = StateUnknown
+ for i := range result.Disks {
+ result.Disks[i].State = StateUnknown
+ result.Disks[i].CapacitySeverity = StateUnknown
+ result.Disks[i].ThermalSeverity = StateUnknown
+ if result.Disks[i].Temperature != nil {
+ result.Disks[i].Temperature.Status = StateUnknown
+ }
+ }
+ }
+ return result, nil
+}
+
+func normalizeDisk(item RawDisk, now time.Time, policy Policy) (Disk, error) {
+ if strings.TrimSpace(item.ID) == "" || strings.TrimSpace(item.Name) == "" || len(item.ID) > 128 || len(item.Name) > 255 {
+ return Disk{}, errors.New("disk identity is invalid")
+ }
+ if item.UsedBytes > item.SizeBytes {
+ return Disk{}, errors.New("disk used bytes exceed capacity")
+ }
+ state := bounded(item.State, StateUnknown)
+ utilization := percent(item.UsedBytes, item.SizeBytes)
+ result := Disk{ID: canonicalIdentity(item.ID), Name: item.Name, Role: bounded(item.Role, "data"), State: state, Model: bounded(item.Model, ""), SerialDisplay: maskSerial(item.Serial), Filesystem: bounded(item.Filesystem, ""), SizeBytes: item.SizeBytes, UsedBytes: item.UsedBytes, FreeBytes: item.SizeBytes - item.UsedBytes, UtilizationPercent: utilization, CapacitySeverity: capacitySeverity(utilization, item.SizeBytes, policy.WarningUtilizationPercent, policy.CriticalUtilizationPercent), ThermalSeverity: StateUnknown}
+ if item.Inodes != nil {
+ if item.Inodes.Used > item.Inodes.Total {
+ return Disk{}, errors.New("disk used inodes exceed total")
+ }
+ result.Inodes = &Inodes{Total: item.Inodes.Total, Used: item.Inodes.Used, Free: item.Inodes.Total - item.Inodes.Used, UtilizationPercent: percent(item.Inodes.Used, item.Inodes.Total)}
+ }
+ smart, err := normalizeSMART(item.SMART, now, policy)
+ if err != nil {
+ return Disk{}, err
+ }
+ result.SMART = smart
+ performance, err := normalizePerformance(item.Performance, now, policy.Performance)
+ if err != nil {
+ return Disk{}, err
+ }
+ result.Performance = performance
+ temperature, err := normalizeTemperature(item.Temperature, now, policy.Performance)
+ if err != nil {
+ return Disk{}, err
+ }
+ result.Temperature = temperature
+ if temperature != nil {
+ result.ThermalSeverity = temperature.Status
+ }
+ spin, err := normalizeSpin(item.Spin)
+ if err != nil {
+ return Disk{}, err
+ }
+ result.Spin = spin
+ return result, nil
+}
+func canonicalIdentity(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
+func capacitySeverity(utilization float64, total uint64, warning, critical float64) string {
+ if total == 0 {
+ return StateUnknown
+ }
+ if utilization >= critical {
+ return "critical"
+ }
+ if utilization >= warning {
+ return "attention"
+ }
+ return "normal"
+}
+func percent(used, total uint64) float64 {
+ if total == 0 {
+ return 0
+ }
+ value := float64(used) / float64(total) * 100
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return 0
+ }
+ return value
+}
+func maskSerial(serial string) string {
+ serial = strings.TrimSpace(serial)
+ if serial == "" {
+ return "niet beschikbaar"
+ }
+ if len(serial) <= 4 {
+ return "verborgen"
+ }
+ return "••••" + serial[len(serial)-4:]
+}
+func bounded(value, fallback string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return fallback
+ }
+ if len(value) > 128 {
+ return value[:128]
+ }
+ return value
+}
+
+func DiskByID(snapshot Snapshot, id string) (Disk, bool) {
+ for _, item := range snapshot.Disks {
+ if item.ID == id {
+ return item, true
+ }
+ }
+ return Disk{}, false
+}
diff --git a/internal/disk/types_test.go b/internal/disk/types_test.go
new file mode 100644
index 0000000..cbcb9ec
--- /dev/null
+++ b/internal/disk/types_test.go
@@ -0,0 +1,108 @@
+package disk
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestNormalizeFortyDiskFixtureCapacityAndPrivacy(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ raw := RawSnapshot{Source: Source{ID: "fixture-disks", Type: "fixture"}, ObservedAt: now, ReceivedAt: now}
+ for i := 0; i < 40; i++ {
+ raw.Disks = append(raw.Disks, RawDisk{ID: "disk-" + string(rune('a'+i%26)) + string(rune('0'+i/26)), Name: "Disk " + string(rune('A'+i%26)), Role: "data", State: StateOnline, Model: "Model-X", Serial: "SERIAL-123456789", Filesystem: "xfs", SizeBytes: 1000, UsedBytes: uint64(i), Inodes: &RawInodes{Total: 100, Used: uint64(i)}})
+ }
+ got, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Total != 40 || len(got.Disks) != 40 {
+ t.Fatalf("total=%d disks=%d", got.Total, len(got.Disks))
+ }
+ if got.Disks[0].UtilizationPercent != 0 {
+ t.Fatalf("first disk capacity=%+v", got.Disks[0])
+ }
+ var usedTen *Disk
+ for i := range got.Disks {
+ if got.Disks[i].UsedBytes == 10 {
+ usedTen = &got.Disks[i]
+ break
+ }
+ }
+ if usedTen == nil || usedTen.FreeBytes != 990 || usedTen.Inodes == nil || usedTen.Inodes.Free != 90 {
+ t.Fatalf("capacity/inodes=%+v", usedTen)
+ }
+ if got.Disks[0].SerialDisplay == "SERIAL-123456789" || got.Disks[0].SerialDisplay != "••••6789" {
+ t.Fatalf("serial=%q", got.Disks[0].SerialDisplay)
+ }
+}
+func TestMissingDiskHistoryIsPreservedAndSorted(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ older := now.Add(-time.Hour)
+ raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, MissingHistory: []RawMissingObservation{{DiskID: "disk-old", Name: "Old", ObservedAt: older, Reason: "removed"}, {DiskID: "disk-new", Name: "New", ObservedAt: now, Reason: "missing"}}}
+ got, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.MissingHistory) != 2 || got.MissingHistory[0].DiskID != "disk-new" || got.MissingHistory[1].Reason != "removed" {
+ t.Fatalf("history=%+v", got.MissingHistory)
+ }
+}
+func TestInvalidCapacityAndStaleUnknown(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{{ID: "disk", Name: "Disk", SizeBytes: 1, UsedBytes: 2}}}
+ if _, err := Normalize(raw, now, Limits{}, Policy{}); err == nil {
+ t.Fatal("expected capacity error")
+ }
+ raw.Disks[0].UsedBytes = 1
+ raw.ObservedAt = now.Add(-2 * time.Minute)
+ got, err := Normalize(raw, now, Limits{}, Policy{FreshnessMaxAge: time.Minute})
+ if err != nil || got.Source.State != StateUnknown || got.Source.Freshness != Stale || got.Disks[0].State != StateUnknown || got.Disks[0].CapacitySeverity != StateUnknown {
+ t.Fatalf("snapshot=%+v err=%v", got, err)
+ }
+}
+func TestAdapterContextAndUnknown(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := (Adapter{}).Snapshot(ctx); !errors.Is(err, context.Canceled) {
+ t.Fatalf("err=%v", err)
+ }
+ got, err := (Adapter{Now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }}).Snapshot(context.Background())
+ if err != nil || got.Source.Reason != "source_unavailable" || got.Source.State != StateUnknown {
+ t.Fatalf("got=%+v err=%v", got, err)
+ }
+}
+
+func TestNormalizeSeparatesCapacityThermalAndAvailability(t *testing.T) {
+ now := time.Date(2026, 8, 11, 23, 45, 0, 0, time.UTC)
+ raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{
+ {ID: " DISK-10 ", Name: "disk10", Role: "data", State: StateOnline, SizeBytes: 10000, UsedBytes: 9999, Temperature: &RawTemperature{Available: true, Celsius: 44, ObservedAt: now}},
+ {ID: "CACHE", Name: "cache", Role: "cache", State: StateOnline, SizeBytes: 100, UsedBytes: 50, Temperature: &RawTemperature{Available: true, Celsius: 61, ObservedAt: now}},
+ }}
+ got, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Disks[0].ID != "cache" || got.Disks[0].State != StateOnline || got.Disks[0].CapacitySeverity != "normal" || got.Disks[0].ThermalSeverity != "critical" {
+ t.Fatalf("cache signals were conflated: %+v", got.Disks[0])
+ }
+ if got.Disks[1].ID != "disk-10" || got.Disks[1].State != StateOnline || got.Disks[1].CapacitySeverity != "critical" || got.Disks[1].ThermalSeverity != "normal" {
+ t.Fatalf("disk signals were conflated: %+v", got.Disks[1])
+ }
+}
+
+func TestNormalizeDiskDuplicatesAreIdempotentAndConflictsFail(t *testing.T) {
+ now := time.Date(2026, 8, 11, 23, 45, 0, 0, time.UTC)
+ item := RawDisk{ID: "DISK-1", Name: "disk1", Role: "data", State: StateOnline, SizeBytes: 100, UsedBytes: 20}
+ got, err := Normalize(RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{item, item}}, now, Limits{}, Policy{})
+ if err != nil || got.Total != 1 {
+ t.Fatalf("duplicate snapshot was not idempotent: %+v err=%v", got, err)
+ }
+ conflict := item
+ conflict.ID = "disk-1"
+ conflict.UsedBytes = 30
+ if _, err := Normalize(RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{item, conflict}}, now, Limits{}, Policy{}); err == nil {
+ t.Fatal("conflicting duplicate disk must fail closed")
+ }
+}
diff --git a/internal/diskapi/handler.go b/internal/diskapi/handler.go
new file mode 100644
index 0000000..d71b9a9
--- /dev/null
+++ b/internal/diskapi/handler.go
@@ -0,0 +1,87 @@
+package diskapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/disk"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Handler struct {
+ Provider disk.Provider
+ MaxPageSize int
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || (r.URL.Path != "/api/v1/disks" && !strings.HasPrefix(r.URL.Path, "/api/v1/disks/")) {
+ http.NotFound(w, r)
+ return
+ }
+ if _, ok := auth.PrincipalFromContext(r.Context()); !ok {
+ problem.Write(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required", "Authentication is required to read disks.", nil)
+ return
+ }
+ if err := r.Context().Err(); err != nil {
+ return
+ }
+ snapshot, err := h.snapshot(r)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return
+ }
+ problem.Write(w, r, http.StatusServiceUnavailable, "DISKS_UNAVAILABLE", "Diskgegevens niet beschikbaar", "De diskgegevens konden niet worden gelezen.", nil)
+ return
+ }
+ if strings.HasPrefix(r.URL.Path, "/api/v1/disks/") {
+ id := strings.TrimPrefix(r.URL.Path, "/api/v1/disks/")
+ item, ok := disk.DiskByID(snapshot, id)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ writeJSON(w, struct {
+ Source disk.Source `json:"source"`
+ Disk disk.Disk `json:"disk"`
+ }{snapshot.Source, item})
+ return
+ }
+ limit := 50
+ if value := r.URL.Query().Get("limit"); value != "" {
+ parsed, parseErr := strconv.Atoi(value)
+ if parseErr != nil {
+ problem.Write(w, r, http.StatusBadRequest, "DISK_QUERY_INVALID", "Invalid disk query", "De disklimiet is ongeldig.", nil)
+ return
+ }
+ limit = parsed
+ }
+ max := h.MaxPageSize
+ if max == 0 {
+ max = 100
+ }
+ if limit < 1 || limit > max {
+ problem.Write(w, r, http.StatusBadRequest, "DISK_QUERY_INVALID", "Invalid disk query", "De disklimiet is ongeldig.", nil)
+ return
+ }
+ if limit < len(snapshot.Disks) {
+ snapshot.Disks = snapshot.Disks[:limit]
+ }
+ writeJSON(w, snapshot)
+}
+func (h Handler) snapshot(r *http.Request) (disk.Snapshot, error) {
+ if h.Provider == nil {
+ return disk.UnknownSnapshot(time.Now().UTC(), "disks", "unraid", "source_unavailable"), nil
+ }
+ return h.Provider.Snapshot(r.Context())
+}
+func writeJSON(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, max-age=5")
+ _ = json.NewEncoder(w).Encode(value)
+}
diff --git a/internal/diskapi/handler_test.go b/internal/diskapi/handler_test.go
new file mode 100644
index 0000000..68b33bb
--- /dev/null
+++ b/internal/diskapi/handler_test.go
@@ -0,0 +1,52 @@
+package diskapi
+
+import (
+ "context"
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/disk"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+type provider struct{ snapshot disk.Snapshot }
+
+func (p provider) Snapshot(context.Context) (disk.Snapshot, error) { return p.snapshot, nil }
+func req(method, path string) *http.Request {
+ r := httptest.NewRequest(method, path, nil)
+ return r.WithContext(auth.WithPrincipal(r.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
+}
+func TestHandlerRequiresAuthenticationAndUnknown(t *testing.T) {
+ u := httptest.NewRecorder()
+ Handler{}.ServeHTTP(u, httptest.NewRequest(http.MethodGet, "/api/v1/disks", nil))
+ if u.Code != http.StatusUnauthorized {
+ t.Fatalf("status=%d", u.Code)
+ }
+ r := httptest.NewRecorder()
+ Handler{}.ServeHTTP(r, req(http.MethodGet, "/api/v1/disks"))
+ if r.Code != http.StatusOK || !strings.Contains(r.Body.String(), `"state":"unknown"`) {
+ t.Fatalf("status=%d body=%s", r.Code, r.Body.String())
+ }
+}
+func TestHandlerListsAndDetailsReadOnly(t *testing.T) {
+ snapshot := disk.UnknownSnapshot(time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), "fixture-disks", "fixture", "test")
+ snapshot.Disks = []disk.Disk{{ID: "disk-1", Name: "Disk 1", State: disk.StateOnline}}
+ snapshot.Total = 1
+ list := httptest.NewRecorder()
+ Handler{Provider: provider{snapshot: snapshot}}.ServeHTTP(list, req(http.MethodGet, "/api/v1/disks?limit=1"))
+ if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"disk-1"`) {
+ t.Fatalf("status=%d body=%s", list.Code, list.Body.String())
+ }
+ detail := httptest.NewRecorder()
+ Handler{Provider: provider{snapshot: snapshot}}.ServeHTTP(detail, req(http.MethodGet, "/api/v1/disks/disk-1"))
+ if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"disk":{"id":"disk-1"`) {
+ t.Fatalf("status=%d body=%s", detail.Code, detail.Body.String())
+ }
+ mutation := httptest.NewRecorder()
+ Handler{}.ServeHTTP(mutation, req(http.MethodPost, "/api/v1/disks/disk-1/format"))
+ if mutation.Code != http.StatusNotFound {
+ t.Fatalf("status=%d", mutation.Code)
+ }
+}
diff --git a/internal/eventapi/handler.go b/internal/eventapi/handler.go
new file mode 100644
index 0000000..cc193ce
--- /dev/null
+++ b/internal/eventapi/handler.go
@@ -0,0 +1,86 @@
+package eventapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/problem"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Event struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Severity string `json:"severity"`
+ EntityID *string `json:"entityId,omitempty"`
+ SourceID *string `json:"sourceId,omitempty"`
+ OccurredAt time.Time `json:"occurredAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Summary string `json:"summary"`
+ Attributes json.RawMessage `json:"attributes"`
+ CorrelationID *string `json:"correlationId,omitempty"`
+}
+
+type Store interface {
+ List(context.Context, int) ([]Event, error)
+}
+
+type PostgresStore struct{ Pool *pgxpool.Pool }
+
+func (s PostgresStore) List(ctx context.Context, limit int) ([]Event, error) {
+ if s.Pool == nil {
+ return nil, errors.New("event database pool is nil")
+ }
+ rows, err := s.Pool.Query(ctx, `SELECT id::text,event_type,severity,entity_id::text,source_id::text,occurred_at,received_at,summary,attributes,correlation_id FROM events ORDER BY occurred_at DESC,id DESC LIMIT $1`, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := make([]Event, 0, limit)
+ for rows.Next() {
+ var item Event
+ if err := rows.Scan(&item.ID, &item.Type, &item.Severity, &item.EntityID, &item.SourceID, &item.OccurredAt, &item.ReceivedAt, &item.Summary, &item.Attributes, &item.CorrelationID); err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ return items, rows.Err()
+}
+
+type Handler struct{ Store Store }
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/events" {
+ http.NotFound(w, r)
+ return
+ }
+ if _, ok := auth.PrincipalFromContext(r.Context()); !ok {
+ problem.Write(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required", "Authentication is required to read events.", nil)
+ return
+ }
+ limit := 100
+ if raw := r.URL.Query().Get("limit"); raw != "" {
+ value, err := strconv.Atoi(raw)
+ if err != nil || value < 1 || value > 100 {
+ problem.Write(w, r, http.StatusBadRequest, "INVALID_LIMIT", "Invalid limit", "The event limit must be between 1 and 100.", nil)
+ return
+ }
+ limit = value
+ }
+ items, err := h.Store.List(r.Context(), limit)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return
+ }
+ problem.Write(w, r, http.StatusServiceUnavailable, "EVENTS_UNAVAILABLE", "Events unavailable", "Gebeurtenissen konden niet worden gelezen.", nil)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, max-age=5")
+ _ = json.NewEncoder(w).Encode(map[string]any{"items": items})
+}
diff --git a/internal/eventapi/handler_test.go b/internal/eventapi/handler_test.go
new file mode 100644
index 0000000..745cd3b
--- /dev/null
+++ b/internal/eventapi/handler_test.go
@@ -0,0 +1,44 @@
+package eventapi
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/itworx/pulse/internal/auth"
+)
+
+type fakeStore struct{ limit int }
+
+func (s *fakeStore) List(_ context.Context, limit int) ([]Event, error) {
+ s.limit = limit
+ return []Event{{ID: "event-1", Type: "discovery.completed", Severity: "info", Summary: "Inventaris bijgewerkt", Attributes: []byte(`{}`)}}, nil
+}
+
+func TestHandlerListsBoundedEvents(t *testing.T) {
+ store := &fakeStore{}
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/events?limit=25", nil)
+ req = req.WithContext(auth.WithPrincipal(req.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
+ res := httptest.NewRecorder()
+ Handler{Store: store}.ServeHTTP(res, req)
+ if res.Code != http.StatusOK || store.limit != 25 || !strings.Contains(res.Body.String(), `"id":"event-1"`) {
+ t.Fatalf("status=%d limit=%d body=%s", res.Code, store.limit, res.Body.String())
+ }
+}
+
+func TestHandlerRejectsInvalidLimitAndAnonymous(t *testing.T) {
+ res := httptest.NewRecorder()
+ Handler{Store: &fakeStore{}}.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/api/v1/events", nil))
+ if res.Code != http.StatusUnauthorized {
+ t.Fatalf("anonymous status=%d", res.Code)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/events?limit=101", nil)
+ req = req.WithContext(auth.WithPrincipal(req.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
+ res = httptest.NewRecorder()
+ Handler{Store: &fakeStore{}}.ServeHTTP(res, req)
+ if res.Code != http.StatusBadRequest {
+ t.Fatalf("limit status=%d", res.Code)
+ }
+}
diff --git a/internal/eventapi/postgres_integration_test.go b/internal/eventapi/postgres_integration_test.go
new file mode 100644
index 0000000..e7dc90d
--- /dev/null
+++ b/internal/eventapi/postgres_integration_test.go
@@ -0,0 +1,61 @@
+package eventapi
+
+import (
+ "context"
+ "crypto/rand"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/database"
+)
+
+func integrationID(t *testing.T) string {
+ t.Helper()
+ var value [16]byte
+ if _, err := rand.Read(value[:]); err != nil {
+ t.Fatal(err)
+ }
+ value[6] = (value[6] & 0x0f) | 0x40
+ value[8] = (value[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%x-%x-%x-%x-%x", value[0:4], value[4:6], value[6:8], value[8:10], value[10:16])
+}
+
+func TestPostgresStoreListsNewestEventsDeterministically(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 2, MinConns: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ if err := database.Migrate(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ sourceID, olderID, newerID := integrationID(t), integrationID(t), integrationID(t)
+ if _, err := pool.Exec(ctx, `INSERT INTO data_sources(id,type,name,configuration_ref) VALUES($1,'fixture','event-api-test','test')`, sourceID); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second)
+ defer stop()
+ _, _ = pool.Exec(cleanup, `DELETE FROM events WHERE source_id=$1`, sourceID)
+ _, _ = pool.Exec(cleanup, `DELETE FROM data_sources WHERE id=$1`, sourceID)
+ })
+ at := time.Date(2099, 8, 12, 2, 0, 0, 0, time.UTC)
+ if _, err := pool.Exec(ctx, `INSERT INTO events(id,event_type,severity,source_id,occurred_at,dedup_key,summary) VALUES($1,'older','info',$3,$4,'older','Ouder'),($2,'newer','warning',$3,$5,'newer','Nieuwer')`, olderID, newerID, sourceID, at, at.Add(time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ items, err := (PostgresStore{Pool: pool}).List(ctx, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) < 2 || items[0].ID != newerID || items[1].ID != olderID {
+ t.Fatalf("events not deterministically newest first: %#v", items)
+ }
+}
diff --git a/internal/forecast/storage.go b/internal/forecast/storage.go
new file mode 100644
index 0000000..e725142
--- /dev/null
+++ b/internal/forecast/storage.go
@@ -0,0 +1,211 @@
+package forecast
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/share"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type HistoryReader interface {
+ Points(context.Context, string, string, time.Time, int) ([]Point, error)
+}
+
+type PostgresHistory struct{ Pool *pgxpool.Pool }
+
+func (r PostgresHistory) Points(ctx context.Context, kind, entityID string, since time.Time, limit int) ([]Point, error) {
+ if r.Pool == nil {
+ return nil, errors.New("capacity history store is unavailable")
+ }
+ if (kind != "share" && kind != "pool" && kind != "disk") || strings.TrimSpace(entityID) == "" || limit < 1 || limit > 512 {
+ return nil, errors.New("capacity history query is invalid")
+ }
+ rows, err := r.Pool.Query(ctx, `SELECT observed_at,used_bytes FROM (
+ SELECT DISTINCT ON (sampled_at) sampled_at,observed_at,used_bytes,source_id
+ FROM capacity_samples
+ WHERE entity_kind=$1 AND entity_id=$2 AND sampled_at >= $3
+ ORDER BY sampled_at DESC,source_id ASC
+ LIMIT $4
+) history ORDER BY sampled_at ASC`, kind, entityID, since.UTC(), limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ points := make([]Point, 0, limit)
+ for rows.Next() {
+ var point Point
+ if err := rows.Scan(&point.ObservedAt, &point.UsedBytes); err != nil {
+ return nil, err
+ }
+ point.ObservedAt = point.ObservedAt.UTC()
+ points = append(points, point)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return points, nil
+}
+
+type StorageProvider struct {
+ Shares share.Provider
+ Pools pool.Provider
+ History HistoryReader
+ Policy Policy
+ Now func() time.Time
+}
+
+func (p StorageProvider) Snapshot(ctx context.Context) (Snapshot, error) {
+ if ctx == nil {
+ return Snapshot{}, errors.New("forecast context is nil")
+ }
+ if err := ctx.Err(); err != nil {
+ return Snapshot{}, err
+ }
+ now := time.Now().UTC()
+ if p.Now != nil {
+ now = p.Now().UTC()
+ }
+ policy := p.Policy.withDefaults()
+ if err := policy.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ view := PolicyView{Enabled: policy.Enabled, WindowSeconds: int64(policy.Window / time.Second), MinPoints: policy.MinPoints, Method: MethodLinearMedian}
+ if !policy.Enabled {
+ view.Method = MethodDisabled
+ }
+ if p.Shares == nil || p.Pools == nil {
+ unknown := UnknownSnapshot(now, "source_unavailable")
+ unknown.Policy = view
+ return unknown, nil
+ }
+ shares, err := p.Shares.Snapshot(ctx)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ pools, err := p.Pools.Snapshot(ctx)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ if len(shares.Shares) == 0 {
+ reason := shares.Source.Reason
+ if reason == "" {
+ reason = "no_capacity_entities"
+ }
+ return Snapshot{ContractVersion: ContractVersion, GeneratedAt: now, Policy: view, Items: []Forecast{}, Reason: reason}, nil
+ }
+ capacities := make(map[string]uint64, len(pools.Pools))
+ for _, item := range pools.Pools {
+ if item.UsableBytes > 0 {
+ capacities[canonical(item.ID)] = item.UsableBytes
+ }
+ }
+ items := make([]Forecast, 0, len(shares.Shares))
+ qualified := 0
+ for _, item := range shares.Shares {
+ points := pointsFromShare(item)
+ if p.History != nil {
+ // Reserve one slot for a current observation that may not have reached
+ // its six-hour history bucket yet.
+ points, err = p.History.Points(ctx, "share", item.ID, now.Add(-policy.Window), policy.MaxPoints-1)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ points = mergeCurrentPoint(points, item)
+ }
+ capacity := capacityForShare(item, capacities)
+ forecast, predictErr := Predict(item.ID, item.Name, "share", capacity, points, now, policy)
+ if predictErr != nil {
+ return Snapshot{}, predictErr
+ }
+ if shares.Source.Freshness == share.Stale || item.SizeState == share.SizeStale {
+ forecast.Method = MethodInsufficient
+ forecast.Confidence = ConfidenceNone
+ forecast.DaysToCapacity = nil
+ forecast.ProjectedAt = nil
+ forecast.Reason = "history_stale"
+ } else if item.SizeState == share.SizeUnknown {
+ forecast.Method = MethodInsufficient
+ forecast.Confidence = ConfidenceNone
+ forecast.DaysToCapacity = nil
+ forecast.ProjectedAt = nil
+ forecast.Reason = "history_unavailable"
+ }
+ if forecast.Confidence == ConfidenceHigh || forecast.Confidence == ConfidenceMedium {
+ qualified++
+ }
+ items = append(items, forecast)
+ }
+ sort.SliceStable(items, func(i, j int) bool {
+ if items[i].Name != items[j].Name {
+ return items[i].Name < items[j].Name
+ }
+ return items[i].EntityID < items[j].EntityID
+ })
+ return Snapshot{ContractVersion: ContractVersion, GeneratedAt: now, Policy: view, Items: items, QualifiedCount: qualified}, nil
+}
+
+func mergeCurrentPoint(points []Point, item share.Share) []Point {
+ if item.SizeObservedAt.IsZero() {
+ return points
+ }
+ result := append([]Point(nil), points...)
+ for index := range result {
+ if result[index].ObservedAt.Equal(item.SizeObservedAt) {
+ result[index].UsedBytes = item.UsedBytes
+ return result
+ }
+ }
+ return append(result, Point{ObservedAt: item.SizeObservedAt, UsedBytes: item.UsedBytes})
+}
+
+func pointsFromShare(item share.Share) []Point {
+ points := make([]Point, 0, len(item.GrowthHistory)+1)
+ for _, point := range item.GrowthHistory {
+ points = append(points, Point{ObservedAt: point.ObservedAt, UsedBytes: point.UsedBytes})
+ }
+ if !item.SizeObservedAt.IsZero() {
+ found := false
+ for index := range points {
+ if points[index].ObservedAt.Equal(item.SizeObservedAt) {
+ points[index].UsedBytes = item.UsedBytes
+ found = true
+ break
+ }
+ }
+ if !found {
+ points = append(points, Point{ObservedAt: item.SizeObservedAt, UsedBytes: item.UsedBytes})
+ }
+ }
+ return points
+}
+
+func capacityForShare(item share.Share, capacities map[string]uint64) uint64 {
+ seen := make(map[string]struct{})
+ var total uint64
+ for _, placement := range item.Placements {
+ id := canonical(placement.PoolID)
+ capacity, exists := capacities[id]
+ if !exists {
+ continue
+ }
+ if _, duplicate := seen[id]; duplicate {
+ continue
+ }
+ seen[id] = struct{}{}
+ if ^uint64(0)-total < capacity {
+ return ^uint64(0)
+ }
+ total += capacity
+ }
+ if total == 0 {
+ total = capacities[canonical(item.StoragePolicy.PrimaryPool)]
+ }
+ return total
+}
+
+func canonical(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
diff --git a/internal/forecast/storage_integration_test.go b/internal/forecast/storage_integration_test.go
new file mode 100644
index 0000000..a3b65fa
--- /dev/null
+++ b/internal/forecast/storage_integration_test.go
@@ -0,0 +1,70 @@
+package forecast
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/agentstore"
+ "github.com/itworx/pulse/internal/database"
+ "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/share"
+)
+
+func TestStorageForecastPostgreSQLHistory(t *testing.T) {
+ dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("PULSE_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ db, err := database.NewPool(ctx, database.Config{URL: dsn})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if err := database.Migrate(ctx, db); err != nil {
+ t.Fatal(err)
+ }
+ run := fmt.Sprintf("forecast-%x", time.Now().UnixNano())
+ now := time.Now().UTC().Truncate(time.Second)
+ for _, point := range []struct {
+ at time.Time
+ used int64
+ }{{now.Add(-14 * 24 * time.Hour), 200}, {now.Add(-7 * 24 * time.Hour), 300}, {now, 400}} {
+ _, err := db.Exec(ctx, `INSERT INTO capacity_samples (entity_kind,entity_id,entity_name,source_id,sampled_at,observed_at,used_bytes,capacity_bytes) VALUES ('share',$1,'Media',$2,$3,$3,$4,0)`, run, run, point.at, point.used)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ store := agentstore.PostgresStore{Pool: db, Clock: func() time.Time { return now }}
+ sharePayload, err := json.Marshal(share.RawSnapshot{Source: share.Source{ID: "forecast-test", Type: "test"}, ObservedAt: now, ReceivedAt: now, Shares: []share.RawShare{{ID: run, Name: "Media forecast", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.RawPlacement{{PoolID: "cache"}}}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ poolPayload, err := json.Marshal(pool.RawSnapshot{Source: pool.Source{ID: "forecast-test", Type: "test"}, ObservedAt: now, ReceivedAt: now, Pools: []pool.RawPool{{ID: "cache", Name: "Cache", State: pool.StateHealthy, UsableBytes: 1000, UsedBytes: 400, Capabilities: pool.Capabilities{Capacity: pool.CapabilityAvailable}}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Put(ctx, agentstore.Snapshot{AgentID: run, Capability: agentstore.CapabilityShares, ObservedAt: now, Payload: sharePayload}); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Put(ctx, agentstore.Snapshot{AgentID: run, Capability: agentstore.CapabilityPools, ObservedAt: now, Payload: poolPayload}); err != nil {
+ t.Fatal(err)
+ }
+ provider := StorageProvider{
+ Shares: staticShares{share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{{ID: run, Name: "Media", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}}}}},
+ Pools: staticPools{pool.Snapshot{Pools: []pool.Pool{{ID: "cache", UsableBytes: 1000}}}},
+ History: PostgresHistory{Pool: db}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now },
+ }
+ snapshot, err := provider.Snapshot(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.QualifiedCount != 1 || len(snapshot.Items) != 1 || snapshot.Items[0].DataPoints != 3 || snapshot.Items[0].ProjectedAt == nil {
+ t.Fatalf("persisted history did not produce a qualified forecast: %+v", snapshot)
+ }
+}
diff --git a/internal/forecast/storage_test.go b/internal/forecast/storage_test.go
new file mode 100644
index 0000000..8e2a1fb
--- /dev/null
+++ b/internal/forecast/storage_test.go
@@ -0,0 +1,93 @@
+package forecast
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/pool"
+ "github.com/itworx/pulse/internal/share"
+)
+
+type staticShares struct{ snapshot share.Snapshot }
+
+func (s staticShares) Snapshot(context.Context) (share.Snapshot, error) { return s.snapshot, nil }
+
+type staticPools struct{ snapshot pool.Snapshot }
+
+func (s staticPools) Snapshot(context.Context) (pool.Snapshot, error) { return s.snapshot, nil }
+
+type fullHistory struct{ points []Point }
+
+func (h fullHistory) Points(_ context.Context, _, _ string, _ time.Time, limit int) ([]Point, error) {
+ if limit != len(h.points) {
+ return nil, errors.New("history query did not reserve the current-point slot")
+ }
+ return h.points, nil
+}
+
+func TestStorageProviderProjectsQualifiedShareHistory(t *testing.T) {
+ now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
+ shareSnapshot := share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{{
+ ID: "media", Name: "Media", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable,
+ Placements: []share.Placement{{PoolID: "cache"}}, GrowthHistory: []share.GrowthPoint{
+ {ObservedAt: now.Add(-14 * 24 * time.Hour), UsedBytes: 200},
+ {ObservedAt: now.Add(-7 * 24 * time.Hour), UsedBytes: 300},
+ {ObservedAt: now, UsedBytes: 400},
+ },
+ }}}
+ poolSnapshot := pool.Snapshot{Pools: []pool.Pool{{ID: " CACHE ", UsableBytes: 1000}}}
+ provider := StorageProvider{Shares: staticShares{shareSnapshot}, Pools: staticPools{poolSnapshot}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }}
+
+ snapshot, err := provider.Snapshot(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.QualifiedCount != 1 || len(snapshot.Items) != 1 || snapshot.Items[0].EntityID != "media" || snapshot.Items[0].ProjectedAt == nil || snapshot.Items[0].CapacityBytes != 1000 {
+ t.Fatalf("qualified storage forecast missing: %+v", snapshot)
+ }
+}
+
+func TestStorageProviderDoesNotCountInsufficientOrStaleHistory(t *testing.T) {
+ now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
+ base := share.Share{ID: "media", Name: "Media", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}, GrowthHistory: []share.GrowthPoint{{ObservedAt: now, UsedBytes: 400}}}
+ provider := StorageProvider{Shares: staticShares{share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{base}}}, Pools: staticPools{pool.Snapshot{Pools: []pool.Pool{{ID: "cache", UsableBytes: 1000}}}}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }}
+
+ insufficient, err := provider.Snapshot(context.Background())
+ if err != nil || insufficient.QualifiedCount != 0 || insufficient.Items[0].Reason != "insufficient_points" {
+ t.Fatalf("insufficient history counted as forecast: %+v, %v", insufficient, err)
+ }
+ base.SizeState = share.SizeStale
+ provider.Shares = staticShares{share.Snapshot{Source: share.Source{Freshness: share.Stale}, Shares: []share.Share{base}}}
+ stale, err := provider.Snapshot(context.Background())
+ if err != nil || stale.QualifiedCount != 0 || stale.Items[0].Reason != "history_stale" || stale.Items[0].ProjectedAt != nil {
+ t.Fatalf("stale history was not explicit: %+v, %v", stale, err)
+ }
+}
+
+func TestStorageProviderEmptySourceNeverCreatesZeroByteEntity(t *testing.T) {
+ now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
+ provider := StorageProvider{Shares: staticShares{share.UnknownSnapshot(now, "shares", "unraid", "source_unavailable")}, Pools: staticPools{pool.Snapshot{}}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }}
+ snapshot, err := provider.Snapshot(context.Background())
+ if err != nil || len(snapshot.Items) != 0 || snapshot.QualifiedCount != 0 || snapshot.Reason != "source_unavailable" {
+ t.Fatalf("empty source created a forecast entity: %+v, %v", snapshot, err)
+ }
+}
+
+func TestStorageProviderKeepsMatureHistoryInsidePointBound(t *testing.T) {
+ now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
+ history := make([]Point, 0, 4)
+ for day := 4; day > 0; day-- {
+ history = append(history, Point{ObservedAt: now.Add(-time.Duration(day) * 24 * time.Hour), UsedBytes: uint64((5 - day) * 100)})
+ }
+ provider := StorageProvider{
+ Shares: staticShares{share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{{ID: "media", Name: "Media", UsedBytes: 500, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}}}}},
+ Pools: staticPools{pool.Snapshot{Pools: []pool.Pool{{ID: "cache", UsableBytes: 1000}}}},
+ History: fullHistory{points: history}, Policy: Policy{Enabled: true, MaxPoints: 5}, Now: func() time.Time { return now },
+ }
+ snapshot, err := provider.Snapshot(context.Background())
+ if err != nil || snapshot.QualifiedCount != 1 || snapshot.Items[0].DataPoints != 5 {
+ t.Fatalf("mature history exceeded its bound: %+v, %v", snapshot, err)
+ }
+}
diff --git a/internal/forecast/types.go b/internal/forecast/types.go
new file mode 100644
index 0000000..90f7958
--- /dev/null
+++ b/internal/forecast/types.go
@@ -0,0 +1,275 @@
+package forecast
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "time"
+)
+
+const ContractVersion = "v1"
+
+const (
+ MethodLinearMedian = "linear_median_rate"
+ MethodDisabled = "disabled"
+ MethodInsufficient = "insufficient_data"
+ ConfidenceHigh = "high"
+ ConfidenceMedium = "medium"
+ ConfidenceLow = "low"
+ ConfidenceNone = "none"
+)
+
+type Point struct {
+ ObservedAt time.Time `json:"observedAt"`
+ UsedBytes uint64 `json:"usedBytes"`
+}
+type Policy struct {
+ Enabled bool
+ Window time.Duration
+ MinPoints int
+ MaxPoints int
+ MinSpan time.Duration
+ BulkRateMultiplier float64
+}
+
+func (p Policy) withDefaults() Policy {
+ if p.Window == 0 {
+ p.Window = 30 * 24 * time.Hour
+ }
+ if p.MinPoints == 0 {
+ p.MinPoints = 3
+ }
+ if p.MaxPoints == 0 {
+ p.MaxPoints = 128
+ }
+ if p.MinSpan == 0 {
+ p.MinSpan = 24 * time.Hour
+ }
+ if p.BulkRateMultiplier == 0 {
+ p.BulkRateMultiplier = 6
+ }
+ return p
+}
+func (p Policy) Validate() error {
+ if p.Window <= 0 || p.Window > 366*24*time.Hour || p.MinPoints < 2 || p.MinPoints > 128 || p.MaxPoints < p.MinPoints || p.MaxPoints > 512 || p.MinSpan <= 0 || p.MinSpan > p.Window || p.BulkRateMultiplier < 2 || p.BulkRateMultiplier > 100 {
+ return errors.New("forecast policy is outside safe bounds")
+ }
+ return nil
+}
+
+type Forecast struct {
+ EntityID string `json:"entityId"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Enabled bool `json:"enabled"`
+ Method string `json:"method"`
+ WindowSeconds int64 `json:"windowSeconds"`
+ DataPoints int `json:"dataPoints"`
+ Confidence string `json:"confidence"`
+ CurrentUsedBytes uint64 `json:"currentUsedBytes"`
+ CapacityBytes uint64 `json:"capacityBytes"`
+ RateBytesPerDay float64 `json:"rateBytesPerDay"`
+ DaysToCapacity *float64 `json:"daysToCapacity,omitempty"`
+ ProjectedAt *time.Time `json:"projectedAt,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+type Snapshot struct {
+ ContractVersion string `json:"contractVersion"`
+ GeneratedAt time.Time `json:"generatedAt"`
+ Policy PolicyView `json:"policy"`
+ Items []Forecast `json:"items"`
+ QualifiedCount int `json:"qualifiedCount"`
+ Reason string `json:"reason,omitempty"`
+}
+type PolicyView struct {
+ Enabled bool `json:"enabled"`
+ WindowSeconds int64 `json:"windowSeconds"`
+ MinPoints int `json:"minPoints"`
+ Method string `json:"method"`
+}
+type Provider interface {
+ Snapshot(context.Context) (Snapshot, error)
+}
+type Adapter struct {
+ Source Provider
+ Policy Policy
+ Now func() time.Time
+}
+
+func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return Snapshot{}, err
+ }
+ if a.Source == nil {
+ now := time.Now().UTC()
+ if a.Now != nil {
+ now = a.Now()
+ }
+ return UnknownSnapshot(now, "source_unavailable"), nil
+ }
+ return a.Source.Snapshot(ctx)
+}
+func UnknownSnapshot(now time.Time, reason string) Snapshot {
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ p := Policy{}.withDefaults()
+ return Snapshot{ContractVersion: ContractVersion, GeneratedAt: now.UTC(), Policy: PolicyView{Enabled: false, WindowSeconds: int64(p.Window / time.Second), MinPoints: p.MinPoints, Method: MethodInsufficient}, Items: []Forecast{}, QualifiedCount: 0, Reason: reason}
+}
+
+func Predict(entityID, name, kind string, capacityBytes uint64, points []Point, now time.Time, policy Policy) (Forecast, error) {
+ policy = policy.withDefaults()
+ if err := policy.Validate(); err != nil {
+ return Forecast{}, err
+ }
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ result := Forecast{EntityID: entityID, Name: name, Kind: kind, Enabled: policy.Enabled, WindowSeconds: int64(policy.Window / time.Second), Confidence: ConfidenceNone, CurrentUsedBytes: lastUsed(points), CapacityBytes: capacityBytes}
+ if !policy.Enabled {
+ result.Method = MethodDisabled
+ result.Reason = "disabled_by_policy"
+ return result, nil
+ }
+ if entityID == "" || name == "" {
+ return Forecast{}, errors.New("forecast identity is invalid")
+ }
+ normalized, err := normalizePoints(points, now, policy)
+ if err != nil {
+ return Forecast{}, err
+ }
+ result.DataPoints = len(normalized)
+ if len(normalized) < policy.MinPoints {
+ result.Method = MethodInsufficient
+ result.Reason = "insufficient_points"
+ return result, nil
+ }
+ span := normalized[len(normalized)-1].ObservedAt.Sub(normalized[0].ObservedAt)
+ if span < policy.MinSpan {
+ result.Method = MethodInsufficient
+ result.Reason = "insufficient_time_span"
+ return result, nil
+ }
+ result.CurrentUsedBytes = normalized[len(normalized)-1].UsedBytes
+ rates, irregular := ratesPerDay(normalized)
+ if irregular {
+ result.Method = MethodLinearMedian
+ result.Confidence = ConfidenceLow
+ result.Reason = "irregular_intervals"
+ return result, nil
+ }
+ rate := median(rates)
+ result.Method = MethodLinearMedian
+ result.RateBytesPerDay = rate
+ if rate <= 0 {
+ result.Confidence = ConfidenceLow
+ result.Reason = "no_positive_growth"
+ return result, nil
+ }
+ if bulkImport(rates, rate, policy.BulkRateMultiplier) {
+ result.Confidence = ConfidenceLow
+ result.Reason = "bulk_import_detected"
+ return result, nil
+ }
+ if capacityBytes == 0 || result.CurrentUsedBytes >= capacityBytes {
+ result.Confidence = ConfidenceLow
+ result.Reason = "capacity_unknown_or_reached"
+ return result, nil
+ }
+ days := float64(capacityBytes-result.CurrentUsedBytes) / rate
+ result.DaysToCapacity = &days
+ projected := normalized[len(normalized)-1].ObservedAt.Add(time.Duration(days*24) * time.Hour).UTC()
+ result.ProjectedAt = &projected
+ if len(normalized) >= 5 && span >= 7*24*time.Hour {
+ result.Confidence = ConfidenceHigh
+ } else {
+ result.Confidence = ConfidenceMedium
+ }
+ return result, nil
+}
+
+func normalizePoints(points []Point, now time.Time, policy Policy) ([]Point, error) {
+ if len(points) > policy.MaxPoints {
+ return nil, errors.New("forecast history exceeds bounds")
+ }
+ copyPoints := make([]Point, 0, len(points))
+ cutoff := now.Add(-policy.Window)
+ for _, point := range points {
+ if point.ObservedAt.IsZero() || point.ObservedAt.After(now.Add(time.Minute)) {
+ return nil, errors.New("forecast timestamp is invalid")
+ }
+ if point.ObservedAt.Before(cutoff) {
+ continue
+ }
+ copyPoints = append(copyPoints, Point{ObservedAt: point.ObservedAt.UTC(), UsedBytes: point.UsedBytes})
+ }
+ sort.SliceStable(copyPoints, func(i, j int) bool { return copyPoints[i].ObservedAt.Before(copyPoints[j].ObservedAt) })
+ dedup := make([]Point, 0, len(copyPoints))
+ for _, point := range copyPoints {
+ if len(dedup) > 0 && dedup[len(dedup)-1].ObservedAt.Equal(point.ObservedAt) {
+ dedup[len(dedup)-1] = point
+ } else {
+ dedup = append(dedup, point)
+ }
+ }
+ return dedup, nil
+}
+func ratesPerDay(points []Point) ([]float64, bool) {
+ rates := make([]float64, 0, len(points)-1)
+ intervals := make([]float64, 0, len(points)-1)
+ for index := 1; index < len(points); index++ {
+ duration := points[index].ObservedAt.Sub(points[index-1].ObservedAt)
+ if duration <= 0 {
+ continue
+ }
+ delta := float64(points[index].UsedBytes) - float64(points[index-1].UsedBytes)
+ rates = append(rates, delta/(duration.Hours()/24))
+ intervals = append(intervals, duration.Hours())
+ }
+ if len(intervals) < 1 {
+ return rates, false
+ }
+ sort.Float64s(intervals)
+ return rates, intervals[len(intervals)-1] > intervals[0]*10
+}
+func bulkImport(rates []float64, typical, multiplier float64) bool {
+ if len(rates) < 2 {
+ return false
+ }
+ if len(rates) == 2 {
+ positive := make([]float64, 0, 2)
+ for _, rate := range rates {
+ if rate > 0 {
+ positive = append(positive, rate)
+ }
+ }
+ if len(positive) == 2 {
+ sort.Float64s(positive)
+ return positive[1] > positive[0]*multiplier
+ }
+ }
+ for _, rate := range rates {
+ if rate > 0 && rate > typical*multiplier {
+ return true
+ }
+ }
+ return false
+}
+func median(values []float64) float64 {
+ if len(values) == 0 {
+ return 0
+ }
+ copyValues := append([]float64(nil), values...)
+ sort.Float64s(copyValues)
+ middle := len(copyValues) / 2
+ if len(copyValues)%2 == 1 {
+ return copyValues[middle]
+ }
+ return (copyValues[middle-1] + copyValues[middle]) / 2
+}
+func lastUsed(points []Point) uint64 {
+ if len(points) == 0 {
+ return 0
+ }
+ return points[len(points)-1].UsedBytes
+}
diff --git a/internal/forecast/types_test.go b/internal/forecast/types_test.go
new file mode 100644
index 0000000..517ffa5
--- /dev/null
+++ b/internal/forecast/types_test.go
@@ -0,0 +1,86 @@
+package forecast
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func points(now time.Time) []Point {
+ return []Point{{ObservedAt: now.Add(-14 * 24 * time.Hour), UsedBytes: 200}, {ObservedAt: now.Add(-7 * 24 * time.Hour), UsedBytes: 300}, {ObservedAt: now, UsedBytes: 400}}
+}
+func TestPredictDisplaysMethodWindowAndQualifiedDate(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ got, err := Predict("share", "Media", "share", 1000, points(now), now, Policy{Enabled: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Method != MethodLinearMedian || got.Confidence != ConfidenceMedium || got.DaysToCapacity == nil || got.ProjectedAt == nil || got.RateBytesPerDay != 100.0/7.0 {
+ t.Fatalf("forecast=%+v", got)
+ }
+}
+func TestPredictRejectsFalsePrecisionForBulkImportAndIrregularHistory(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ bulk := []Point{{ObservedAt: now.Add(-14 * 24 * time.Hour), UsedBytes: 100}, {ObservedAt: now.Add(-7 * 24 * time.Hour), UsedBytes: 110}, {ObservedAt: now, UsedBytes: 1000}}
+ got, err := Predict("share", "Media", "share", 2000, bulk, now, Policy{Enabled: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Confidence != ConfidenceLow || got.DaysToCapacity != nil || got.Reason != "bulk_import_detected" {
+ t.Fatalf("bulk=%+v", got)
+ }
+ irregular := []Point{{ObservedAt: now.Add(-20 * 24 * time.Hour), UsedBytes: 100}, {ObservedAt: now.Add(-19 * 24 * time.Hour), UsedBytes: 110}, {ObservedAt: now, UsedBytes: 300}}
+ got, err = Predict("share", "Media", "share", 2000, irregular, now, Policy{Enabled: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Reason != "irregular_intervals" || got.DaysToCapacity != nil {
+ t.Fatalf("irregular=%+v", got)
+ }
+}
+func TestPredictInsufficientDisabledAndBounded(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ got, err := Predict("share", "Media", "share", 1000, points(now)[:2], now, Policy{Enabled: true})
+ if err != nil || got.Method != MethodInsufficient || got.DaysToCapacity != nil {
+ t.Fatalf("insufficient=%+v err=%v", got, err)
+ }
+ got, err = Predict("share", "Media", "share", 1000, points(now), now, Policy{})
+ if err != nil || got.Method != MethodDisabled || got.Enabled {
+ t.Fatalf("disabled=%+v err=%v", got, err)
+ }
+ tooMany := make([]Point, 129)
+ for i := range tooMany {
+ tooMany[i] = Point{ObservedAt: now.Add(-time.Duration(i) * time.Hour), UsedBytes: uint64(i)}
+ }
+ if _, err = Predict("share", "Media", "share", 1000, tooMany, now, Policy{Enabled: true}); err == nil {
+ t.Fatal("expected bound error")
+ }
+}
+func TestAdapterCancellationAndUnknown(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := (Adapter{}).Snapshot(ctx)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("err=%v", err)
+ }
+ got, err := (Adapter{Now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }}).Snapshot(context.Background())
+ if err != nil || len(got.Items) != 0 || got.QualifiedCount != 0 || got.Reason != "source_unavailable" {
+ t.Fatalf("unknown=%+v err=%v", got, err)
+ }
+}
+
+func BenchmarkPredictTargetScale(b *testing.B) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ history := make([]Point, 128)
+ for index := range history {
+ history[index] = Point{ObservedAt: now.Add(-time.Duration(127-index) * 6 * time.Hour), UsedBytes: uint64(index) * 1024 * 1024 * 1024}
+ }
+ policy := Policy{Enabled: true, Window: 30 * 24 * time.Hour, MinPoints: 3, MaxPoints: 128, MinSpan: 24 * time.Hour, BulkRateMultiplier: 6}
+ b.ReportAllocs()
+ for index := 0; index < b.N; index++ {
+ if _, err := Predict("share", "Media", "share", 512*1024*1024*1024, history, now, policy); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/internal/forecastapi/handler.go b/internal/forecastapi/handler.go
new file mode 100644
index 0000000..e5a3f20
--- /dev/null
+++ b/internal/forecastapi/handler.go
@@ -0,0 +1,49 @@
+package forecastapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/forecast"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Handler struct {
+ Provider forecast.Provider
+}
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/forecasts" {
+ http.NotFound(w, r)
+ return
+ }
+ if _, ok := auth.PrincipalFromContext(r.Context()); !ok {
+ problem.Write(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required", "Authentication is required to read forecasts.", nil)
+ return
+ }
+ if err := r.Context().Err(); err != nil {
+ return
+ }
+ snapshot, err := h.snapshot(r)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return
+ }
+ problem.Write(w, r, http.StatusServiceUnavailable, "FORECASTS_UNAVAILABLE", "Forecastgegevens niet beschikbaar", "De capaciteitsvoorspellingen konden niet worden gelezen.", nil)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, max-age=15")
+ _ = json.NewEncoder(w).Encode(snapshot)
+}
+
+func (h Handler) snapshot(r *http.Request) (forecast.Snapshot, error) {
+ if h.Provider == nil {
+ return forecast.UnknownSnapshot(time.Now().UTC(), "source_unavailable"), nil
+ }
+ return h.Provider.Snapshot(r.Context())
+}
diff --git a/internal/forecastapi/handler_test.go b/internal/forecastapi/handler_test.go
new file mode 100644
index 0000000..51b8cda
--- /dev/null
+++ b/internal/forecastapi/handler_test.go
@@ -0,0 +1,61 @@
+package forecastapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/forecast"
+)
+
+type providerFunc func(context.Context) (forecast.Snapshot, error)
+
+func (f providerFunc) Snapshot(ctx context.Context) (forecast.Snapshot, error) { return f(ctx) }
+
+func authenticatedRequest(method, path string) *http.Request {
+ r := httptest.NewRequest(method, path, nil)
+ return r.WithContext(auth.WithPrincipal(r.Context(), auth.Principal{Subject: "test", Role: auth.RoleViewer}))
+}
+
+func TestHandlerRequiresAuthenticationAndGET(t *testing.T) {
+ h := Handler{}
+ unauthenticated := httptest.NewRecorder()
+ h.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/v1/forecasts", nil))
+ if unauthenticated.Code != http.StatusUnauthorized {
+ t.Fatalf("status=%d", unauthenticated.Code)
+ }
+ method := httptest.NewRecorder()
+ h.ServeHTTP(method, authenticatedRequest(http.MethodPost, "/api/v1/forecasts"))
+ if method.Code != http.StatusNotFound {
+ t.Fatalf("status=%d", method.Code)
+ }
+}
+
+func TestHandlerReturnsBoundedSnapshotAndMapsProviderErrors(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ snapshot := forecast.Snapshot{ContractVersion: forecast.ContractVersion, GeneratedAt: now, QualifiedCount: 0, Items: []forecast.Forecast{{EntityID: "share", Method: forecast.MethodInsufficient, Confidence: forecast.ConfidenceNone, Reason: "insufficient_points"}}}
+ h := Handler{Provider: providerFunc(func(context.Context) (forecast.Snapshot, error) { return snapshot, nil })}
+ recorder := httptest.NewRecorder()
+ h.ServeHTTP(recorder, authenticatedRequest(http.MethodGet, "/api/v1/forecasts"))
+ if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "private, max-age=15" {
+ t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
+ }
+ var got forecast.Snapshot
+ if err := json.NewDecoder(recorder.Body).Decode(&got); err != nil {
+ t.Fatal(err)
+ }
+ if got.QualifiedCount != 0 || len(got.Items) != 1 || got.Items[0].Reason != "insufficient_points" {
+ t.Fatalf("snapshot=%+v", got)
+ }
+
+ failing := Handler{Provider: providerFunc(func(context.Context) (forecast.Snapshot, error) { return forecast.Snapshot{}, context.DeadlineExceeded })}
+ deadline := httptest.NewRecorder()
+ failing.ServeHTTP(deadline, authenticatedRequest(http.MethodGet, "/api/v1/forecasts"))
+ if deadline.Code != http.StatusOK {
+ t.Fatalf("deadline status=%d", deadline.Code)
+ }
+}
diff --git a/internal/freshness/evaluator.go b/internal/freshness/evaluator.go
new file mode 100644
index 0000000..74548b9
--- /dev/null
+++ b/internal/freshness/evaluator.go
@@ -0,0 +1,54 @@
+package freshness
+
+import (
+ "errors"
+ "time"
+
+ "github.com/itworx/pulse/internal/datasource"
+)
+
+type Input struct {
+ SourceID string
+ Required bool
+ Health datasource.SourceHealth
+ Previous datasource.HealthState
+ Now time.Time
+}
+type Result struct {
+ State datasource.HealthState
+ Age *time.Duration
+ LastKnownAt time.Time
+ Recovery bool
+ Event string
+ Warning string
+}
+
+func Evaluate(input Input) (Result, error) {
+ if input.SourceID == "" {
+ return Result{}, errors.New("source id is required")
+ }
+ now := input.Now
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ if err := input.Health.Validate(now); err != nil {
+ return Result{}, err
+ }
+ result := Result{State: input.Health.EffectiveState(now), LastKnownAt: input.Health.ObservedAt}
+ if !input.Health.ObservedAt.IsZero() {
+ age := now.Sub(input.Health.ObservedAt)
+ if age < 0 {
+ age = 0
+ }
+ result.Age = &age
+ }
+ if input.Required && result.State != datasource.HealthHealthy && result.State != datasource.HealthDegraded {
+ result.State = datasource.HealthUnknown
+ result.Warning = "REQUIRED_SOURCE_UNKNOWN"
+ }
+ if input.Previous == datasource.HealthUnknown && (result.State == datasource.HealthHealthy || result.State == datasource.HealthDegraded) {
+ result.Recovery = true
+ result.Event = "DATASOURCE_RECOVERED"
+ }
+ return result, nil
+}
diff --git a/internal/freshness/evaluator_test.go b/internal/freshness/evaluator_test.go
new file mode 100644
index 0000000..3b2db9f
--- /dev/null
+++ b/internal/freshness/evaluator_test.go
@@ -0,0 +1,40 @@
+package freshness
+
+import (
+ "github.com/itworx/pulse/internal/datasource"
+ "testing"
+ "time"
+)
+
+func TestRequiredStaleSourceIsUnknownWithAge(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ result, err := Evaluate(Input{SourceID: "prom", Required: true, Now: now, Health: datasource.SourceHealth{State: datasource.HealthHealthy, ObservedAt: now.Add(-2 * time.Minute), ReceivedAt: now, Policy: datasource.FreshnessPolicy{MaxAge: time.Minute}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.State != datasource.HealthUnknown || result.Age == nil || *result.Age != 2*time.Minute {
+ t.Fatalf("unexpected stale result: %+v", result)
+ }
+}
+func TestRecoveryEventOnlyFollowsUnknown(t *testing.T) {
+ now := time.Now().UTC()
+ health := datasource.SourceHealth{State: datasource.HealthHealthy, ObservedAt: now, ReceivedAt: now, Policy: datasource.FreshnessPolicy{MaxAge: time.Minute}}
+ result, err := Evaluate(Input{SourceID: "prom", Previous: datasource.HealthUnknown, Health: health, Now: now})
+ if err != nil || !result.Recovery || result.Event != "DATASOURCE_RECOVERED" {
+ t.Fatalf("unexpected recovery: %+v %v", result, err)
+ }
+ result, err = Evaluate(Input{SourceID: "prom", Previous: datasource.HealthHealthy, Health: health, Now: now})
+ if err != nil || result.Recovery {
+ t.Fatal("false recovery event")
+ }
+}
+func TestMissingTelemetryNeverBecomesHealthy(t *testing.T) {
+ now := time.Now().UTC()
+ result, err := Evaluate(Input{SourceID: "prom", Required: true, Previous: datasource.HealthUnknown, Now: now, Health: datasource.SourceHealth{State: datasource.HealthUnknown, ReceivedAt: now, Policy: datasource.FreshnessPolicy{MaxAge: time.Minute}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.State == datasource.HealthHealthy {
+ t.Fatal("missing telemetry became healthy")
+ }
+}
diff --git a/internal/host/adapter.go b/internal/host/adapter.go
new file mode 100644
index 0000000..a8e5f72
--- /dev/null
+++ b/internal/host/adapter.go
@@ -0,0 +1,38 @@
+package host
+
+import (
+ "context"
+ "time"
+)
+
+// RawSource is the narrow read-only boundary implemented by an authenticated
+// agent or another approved host telemetry source. It returns one bounded
+// source snapshot; it cannot execute arbitrary host commands.
+type RawSource interface {
+ Snapshot(context.Context) (RawSnapshot, error)
+}
+
+type Adapter struct {
+ Source RawSource
+ Limits Limits
+ Policy Policy
+ Now func() time.Time
+}
+
+func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return Snapshot{}, err
+ }
+ if a.Source == nil {
+ return UnknownSnapshot(time.Now().UTC(), "host", "agent", "source_unavailable"), nil
+ }
+ raw, err := a.Source.Snapshot(ctx)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ now := time.Now().UTC()
+ if a.Now != nil {
+ now = a.Now()
+ }
+ return Normalize(raw, now, a.Limits, a.Policy)
+}
diff --git a/internal/host/hardware.go b/internal/host/hardware.go
new file mode 100644
index 0000000..b797689
--- /dev/null
+++ b/internal/host/hardware.go
@@ -0,0 +1,300 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+)
+
+const (
+ CapabilityThermal = "host.thermal"
+ CapabilityFans = "host.fans"
+ CapabilityGPU = "host.gpu"
+)
+
+type HardwareLimits struct {
+ MaxTemperatures int
+ MaxFans int
+ MaxGPUs int
+ MaxCapabilities int
+}
+
+func (l HardwareLimits) withDefaults() HardwareLimits {
+ if l.MaxTemperatures == 0 {
+ l.MaxTemperatures = 256
+ }
+ if l.MaxFans == 0 {
+ l.MaxFans = 256
+ }
+ if l.MaxGPUs == 0 {
+ l.MaxGPUs = 16
+ }
+ if l.MaxCapabilities == 0 {
+ l.MaxCapabilities = 32
+ }
+ return l
+}
+
+func (l HardwareLimits) Validate() error {
+ if l.MaxTemperatures < 1 || l.MaxTemperatures > 512 || l.MaxFans < 1 || l.MaxFans > 512 || l.MaxGPUs < 1 || l.MaxGPUs > 64 || l.MaxCapabilities < 1 || l.MaxCapabilities > 100 {
+ return errors.New("hardware limits are outside safe bounds")
+ }
+ return nil
+}
+
+type ThermalPolicy struct {
+ AttentionCelsius float64
+ CriticalCelsius float64
+}
+
+func (p ThermalPolicy) withDefaults() ThermalPolicy {
+ if p.AttentionCelsius == 0 {
+ p.AttentionCelsius = 75
+ }
+ if p.CriticalCelsius == 0 {
+ p.CriticalCelsius = 85
+ }
+ return p
+}
+
+func (p ThermalPolicy) Validate() error {
+ if p.AttentionCelsius <= 0 || p.CriticalCelsius <= p.AttentionCelsius || p.CriticalCelsius > 150 {
+ return errors.New("thermal policy is outside safe bounds")
+ }
+ return nil
+}
+
+type Capability struct {
+ ID string `json:"id"`
+ Version string `json:"version"`
+ State string `json:"state"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type RawTemperature struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Celsius float64 `json:"celsius"`
+}
+type Temperature struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Celsius float64 `json:"celsius"`
+}
+
+type RawFan struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ RPM int `json:"rpm"`
+}
+type Fan struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ RPM int `json:"rpm"`
+}
+
+type RawGPU struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Vendor string `json:"vendor,omitempty"`
+ Utilization *float64 `json:"utilizationPercent,omitempty"`
+ MemoryUsedBytes uint64 `json:"memoryUsedBytes,omitempty"`
+ MemoryTotalBytes uint64 `json:"memoryTotalBytes,omitempty"`
+ TemperatureCelsius *float64 `json:"temperatureCelsius,omitempty"`
+}
+type GPU struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Vendor string `json:"vendor,omitempty"`
+ Utilization *float64 `json:"utilizationPercent,omitempty"`
+ MemoryUsedBytes uint64 `json:"memoryUsedBytes,omitempty"`
+ MemoryTotalBytes uint64 `json:"memoryTotalBytes,omitempty"`
+ MemoryUtilizationPercent float64 `json:"memoryUtilizationPercent,omitempty"`
+ TemperatureCelsius *float64 `json:"temperatureCelsius,omitempty"`
+}
+
+type RawHardware struct {
+ Capabilities []Capability `json:"capabilities"`
+ Temperatures []RawTemperature `json:"temperatures"`
+ Fans []RawFan `json:"fans"`
+ GPUs []RawGPU `json:"gpus"`
+}
+
+type HardwareStatus struct {
+ State string `json:"state"`
+ Reasons []StatusReason `json:"reasons,omitempty"`
+}
+
+type HardwareSnapshot struct {
+ Capabilities []Capability `json:"capabilities"`
+ Temperatures []Temperature `json:"temperatures"`
+ Fans []Fan `json:"fans"`
+ GPUs []GPU `json:"gpus"`
+ Status HardwareStatus `json:"status"`
+}
+
+type HardwareSource interface {
+ Hardware(context.Context) (RawHardware, error)
+}
+
+type HardwareAdapter struct {
+ Source HardwareSource
+ Limits HardwareLimits
+ Policy ThermalPolicy
+}
+
+func (a HardwareAdapter) Snapshot(ctx context.Context, sourceID string) (HardwareSnapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return HardwareSnapshot{}, err
+ }
+ if a.Source == nil {
+ return DisabledHardware(), nil
+ }
+ raw, err := a.Source.Hardware(ctx)
+ if err != nil {
+ return HardwareSnapshot{}, err
+ }
+ return NormalizeHardware(raw, sourceID, a.Limits, a.Policy)
+}
+
+func DisabledHardware() HardwareSnapshot {
+ capabilities := []Capability{
+ {ID: CapabilityThermal, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
+ {ID: CapabilityFans, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
+ {ID: CapabilityGPU, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
+ }
+ return HardwareSnapshot{Capabilities: capabilities, Status: HardwareStatus{State: StatusUnknown, Reasons: []StatusReason{{Code: "hardware_capability_absent", Message: "Optionele hardwaretelemetrie is niet beschikbaar."}}}}
+}
+
+func NormalizeHardware(raw RawHardware, sourceID string, limits HardwareLimits, policy ThermalPolicy) (HardwareSnapshot, error) {
+ limits = limits.withDefaults()
+ policy = policy.withDefaults()
+ if err := limits.Validate(); err != nil {
+ return HardwareSnapshot{}, err
+ }
+ if err := policy.Validate(); err != nil {
+ return HardwareSnapshot{}, err
+ }
+ if len(raw.Temperatures) > limits.MaxTemperatures || len(raw.Fans) > limits.MaxFans || len(raw.GPUs) > limits.MaxGPUs || len(raw.Capabilities) > limits.MaxCapabilities {
+ return HardwareSnapshot{}, errors.New("hardware collection exceeds bounds")
+ }
+ capabilities := normalizeCapabilities(raw.Capabilities)
+ for _, required := range []string{CapabilityThermal, CapabilityFans, CapabilityGPU} {
+ if _, ok := capabilityByID(capabilities, required); !ok {
+ capabilities = append(capabilities, Capability{ID: required, Version: ContractVersion, State: "disabled", Reason: "capability_absent"})
+ }
+ }
+ sort.Slice(capabilities, func(i, j int) bool { return capabilities[i].ID < capabilities[j].ID })
+ temperatures := make([]Temperature, 0, len(raw.Temperatures))
+ for _, item := range raw.Temperatures {
+ if err := validateName(item.Name, 128); err != nil || math.IsNaN(item.Celsius) || math.IsInf(item.Celsius, 0) || item.Celsius < -100 || item.Celsius > 150 {
+ return HardwareSnapshot{}, errors.New("invalid temperature sensor")
+ }
+ temperatures = append(temperatures, Temperature{ID: stableSensorID(sourceID, "temperature", item.ID, item.Name), Name: item.Name, Celsius: item.Celsius})
+ }
+ sort.Slice(temperatures, func(i, j int) bool { return temperatures[i].ID < temperatures[j].ID })
+ fans := make([]Fan, 0, len(raw.Fans))
+ for _, item := range raw.Fans {
+ if err := validateName(item.Name, 128); err != nil || item.RPM < 0 || item.RPM > 100000 {
+ return HardwareSnapshot{}, errors.New("invalid fan sensor")
+ }
+ fans = append(fans, Fan{ID: stableSensorID(sourceID, "fan", item.ID, item.Name), Name: item.Name, RPM: item.RPM})
+ }
+ sort.Slice(fans, func(i, j int) bool { return fans[i].ID < fans[j].ID })
+ gpus := make([]GPU, 0, len(raw.GPUs))
+ for _, item := range raw.GPUs {
+ if err := validateName(item.Name, 128); err != nil {
+ return HardwareSnapshot{}, errors.New("invalid GPU name")
+ }
+ if item.Utilization != nil && (*item.Utilization < 0 || *item.Utilization > 100 || math.IsNaN(*item.Utilization) || math.IsInf(*item.Utilization, 0)) {
+ return HardwareSnapshot{}, errors.New("invalid GPU utilization")
+ }
+ if item.MemoryTotalBytes > 0 && item.MemoryUsedBytes > item.MemoryTotalBytes {
+ return HardwareSnapshot{}, errors.New("invalid GPU memory")
+ }
+ if item.TemperatureCelsius != nil && (*item.TemperatureCelsius < -100 || *item.TemperatureCelsius > 150 || math.IsNaN(*item.TemperatureCelsius) || math.IsInf(*item.TemperatureCelsius, 0)) {
+ return HardwareSnapshot{}, errors.New("invalid GPU temperature")
+ }
+ gpu := GPU{ID: stableSensorID(sourceID, "gpu", item.ID, item.Name), Name: item.Name, Vendor: item.Vendor, Utilization: cloneFloat(item.Utilization), MemoryUsedBytes: item.MemoryUsedBytes, MemoryTotalBytes: item.MemoryTotalBytes, TemperatureCelsius: cloneFloat(item.TemperatureCelsius)}
+ if item.MemoryTotalBytes > 0 {
+ gpu.MemoryUtilizationPercent = float64(item.MemoryUsedBytes) / float64(item.MemoryTotalBytes) * 100
+ }
+ gpus = append(gpus, gpu)
+ }
+ sort.Slice(gpus, func(i, j int) bool { return gpus[i].ID < gpus[j].ID })
+ status := evaluateHardwareStatus(temperatures, gpus, policy)
+ return HardwareSnapshot{Capabilities: capabilities, Temperatures: temperatures, Fans: fans, GPUs: gpus, Status: status}, nil
+}
+
+func evaluateHardwareStatus(temperatures []Temperature, gpus []GPU, policy ThermalPolicy) HardwareStatus {
+ result := HardwareStatus{State: StatusHealthy}
+ max := -math.MaxFloat64
+ sensor := ""
+ for _, item := range temperatures {
+ if item.Celsius > max {
+ max = item.Celsius
+ sensor = item.Name
+ }
+ }
+ for _, item := range gpus {
+ if item.TemperatureCelsius != nil && *item.TemperatureCelsius > max {
+ max = *item.TemperatureCelsius
+ sensor = item.Name
+ }
+ }
+ if sensor == "" {
+ return HardwareStatus{State: StatusUnknown, Reasons: []StatusReason{{Code: "thermal_data_absent", Message: "Er is geen betrouwbare temperatuursensor beschikbaar."}}}
+ }
+ if max >= policy.CriticalCelsius {
+ return HardwareStatus{State: "critical", Reasons: []StatusReason{{Code: "thermal_critical", Message: fmt.Sprintf("Temperatuur %.1f °C bij %s overschrijdt de kritieke grens.", max, sensor)}}}
+ }
+ if max >= policy.AttentionCelsius {
+ return HardwareStatus{State: StatusDegraded, Reasons: []StatusReason{{Code: "thermal_attention", Message: fmt.Sprintf("Temperatuur %.1f °C bij %s vraagt aandacht.", max, sensor)}}}
+ }
+ return result
+}
+
+func normalizeCapabilities(values []Capability) []Capability {
+ result := make([]Capability, 0, len(values))
+ seen := map[string]bool{}
+ for _, value := range values {
+ if value.ID == "" || seen[value.ID] {
+ continue
+ }
+ if value.Version == "" {
+ value.Version = ContractVersion
+ }
+ if value.State == "" {
+ value.State = "unavailable"
+ }
+ seen[value.ID] = true
+ result = append(result, value)
+ }
+ return result
+}
+func capabilityByID(values []Capability, id string) (Capability, bool) {
+ for _, value := range values {
+ if value.ID == id {
+ return value, true
+ }
+ }
+ return Capability{}, false
+}
+func validateName(value string, max int) error {
+ if strings.TrimSpace(value) == "" || len(value) > max {
+ return errors.New("name is empty or too long")
+ }
+ return nil
+}
+func stableSensorID(sourceID, kind, externalID, name string) string {
+ identity := strings.TrimSpace(externalID)
+ if identity == "" {
+ identity = strings.TrimSpace(name)
+ }
+ identity = strings.ToLower(strings.TrimSpace(identity))
+ identity = strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-").Replace(identity)
+ return strings.ToLower(strings.TrimSpace(sourceID)) + "/" + kind + "/" + identity
+}
diff --git a/internal/host/hardware_test.go b/internal/host/hardware_test.go
new file mode 100644
index 0000000..29e8f8e
--- /dev/null
+++ b/internal/host/hardware_test.go
@@ -0,0 +1,70 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestNormalizeHardwareAbsentCapabilitiesAreDisabled(t *testing.T) {
+ snapshot, err := NormalizeHardware(RawHardware{}, "agent-1", HardwareLimits{}, ThermalPolicy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(snapshot.Capabilities) != 3 || snapshot.Status.State != StatusUnknown || snapshot.Capabilities[0].State != "disabled" {
+ t.Fatalf("absent hardware was treated as failure: %+v", snapshot)
+ }
+ if snapshot.Capabilities[0].Reason != "capability_absent" {
+ t.Fatalf("capabilities=%+v", snapshot.Capabilities)
+ }
+}
+
+func TestNormalizeHardwareStableIDsAndThermalReason(t *testing.T) {
+ hot := 91.5
+ snapshot, err := NormalizeHardware(RawHardware{
+ Capabilities: []Capability{{ID: CapabilityThermal, Version: ContractVersion, State: "enabled"}, {ID: CapabilityGPU, Version: ContractVersion, State: "enabled"}},
+ Temperatures: []RawTemperature{{ID: "core:0", Name: "CPU package", Celsius: 91.5}},
+ GPUs: []RawGPU{{ID: "pci/0000:01:00.0", Name: "RTX", Vendor: "nvidia", TemperatureCelsius: &hot, Utilization: floatPtr(55)}},
+ }, "agent-1", HardwareLimits{}, ThermalPolicy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Status.State != "critical" || snapshot.Status.Reasons[0].Code != "thermal_critical" {
+ t.Fatalf("status=%+v", snapshot.Status)
+ }
+ if snapshot.Temperatures[0].ID != "agent-1/temperature/core-0" || snapshot.GPUs[0].ID != "agent-1/gpu/pci-0000-01-00.0" {
+ t.Fatalf("unstable IDs: temperatures=%+v gpus=%+v", snapshot.Temperatures, snapshot.GPUs)
+ }
+ if snapshot.GPUs[0].MemoryUtilizationPercent != 0 {
+ t.Fatalf("unexpected GPU memory ratio: %+v", snapshot.GPUs[0])
+ }
+}
+
+func TestNormalizeHardwarePreservesUnsupportedCapability(t *testing.T) {
+ snapshot, err := NormalizeHardware(RawHardware{Capabilities: []Capability{{ID: CapabilityGPU, Version: ContractVersion, State: "unsupported", Reason: "no_driver"}}}, "agent-1", HardwareLimits{}, ThermalPolicy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ capability, ok := capabilityByID(snapshot.Capabilities, CapabilityGPU)
+ if !ok || capability.State != "unsupported" || capability.Reason != "no_driver" {
+ t.Fatalf("capability=%+v", capability)
+ }
+}
+
+type hardwareSource struct{ value RawHardware }
+
+func (s hardwareSource) Hardware(ctx context.Context) (RawHardware, error) {
+ if err := ctx.Err(); err != nil {
+ return RawHardware{}, err
+ }
+ return s.value, nil
+}
+
+func TestHardwareAdapterHonorsCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := (HardwareAdapter{Source: hardwareSource{}}).Snapshot(ctx, "agent-1")
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("err=%v", err)
+ }
+}
diff --git a/internal/host/types.go b/internal/host/types.go
new file mode 100644
index 0000000..262053a
--- /dev/null
+++ b/internal/host/types.go
@@ -0,0 +1,456 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+ "time"
+)
+
+const ContractVersion = "v1"
+
+const (
+ StatusHealthy = "healthy"
+ StatusDegraded = "degraded"
+ StatusUnknown = "unknown"
+ Fresh = "fresh"
+ Stale = "stale"
+ Unavailable = "unavailable"
+)
+
+type Limits struct {
+ MaxCores int
+ MaxFilesystems int
+ MaxInterfaces int
+ MaxWarnings int
+}
+
+func (l Limits) withDefaults() Limits {
+ if l.MaxCores == 0 {
+ l.MaxCores = 256
+ }
+ if l.MaxFilesystems == 0 {
+ l.MaxFilesystems = 256
+ }
+ if l.MaxInterfaces == 0 {
+ l.MaxInterfaces = 128
+ }
+ if l.MaxWarnings == 0 {
+ l.MaxWarnings = 20
+ }
+ return l
+}
+
+func (l Limits) Validate() error {
+ if l.MaxCores < 1 || l.MaxCores > 512 || l.MaxFilesystems < 1 || l.MaxFilesystems > 512 || l.MaxInterfaces < 1 || l.MaxInterfaces > 256 || l.MaxWarnings < 1 || l.MaxWarnings > 100 {
+ return errors.New("host limits are outside safe bounds")
+ }
+ return nil
+}
+
+type Policy struct {
+ FreshnessMaxAge time.Duration
+ HighLoadPerCore float64
+ SaturationLoadPerCore float64
+ HighMemoryPercent float64
+}
+
+func (p Policy) withDefaults() Policy {
+ if p.FreshnessMaxAge == 0 {
+ p.FreshnessMaxAge = 30 * time.Second
+ }
+ if p.HighLoadPerCore == 0 {
+ p.HighLoadPerCore = 1
+ }
+ if p.SaturationLoadPerCore == 0 {
+ p.SaturationLoadPerCore = 2
+ }
+ if p.HighMemoryPercent == 0 {
+ p.HighMemoryPercent = 92
+ }
+ return p
+}
+
+func (p Policy) Validate() error {
+ if p.FreshnessMaxAge <= 0 || p.FreshnessMaxAge > 24*time.Hour || p.HighLoadPerCore <= 0 || p.SaturationLoadPerCore < p.HighLoadPerCore || p.HighMemoryPercent <= 0 || p.HighMemoryPercent > 100 {
+ return errors.New("host policy is outside safe bounds")
+ }
+ return nil
+}
+
+type Source struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ CapabilityVersion string `json:"capabilityVersion"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Freshness string `json:"freshness"`
+ State string `json:"state"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type HostIdentity struct {
+ Name string `json:"name"`
+ Version string `json:"version,omitempty"`
+ Kernel string `json:"kernel,omitempty"`
+ Arch string `json:"architecture,omitempty"`
+}
+
+type RawCPU struct {
+ TotalPercent *float64 `json:"totalPercent,omitempty"`
+ PerCore []float64 `json:"perCore,omitempty"`
+ IOWaitPercent *float64 `json:"iowaitPercent,omitempty"`
+}
+
+type CPU struct {
+ TotalPercent *float64 `json:"totalPercent,omitempty"`
+ PerCore []float64 `json:"perCore,omitempty"`
+ IOWaitPercent *float64 `json:"iowaitPercent,omitempty"`
+}
+
+type RawLoad struct{ One, Five, Fifteen float64 }
+type Load struct {
+ One, Five, Fifteen float64 `json:"-"`
+}
+
+func (l Load) MarshalJSON() ([]byte, error) {
+ return []byte(fmt.Sprintf(`{"one":%s,"five":%s,"fifteen":%s}`, formatNumber(l.One), formatNumber(l.Five), formatNumber(l.Fifteen))), nil
+}
+
+type RawMemory struct {
+ TotalBytes uint64 `json:"totalBytes"`
+ AvailableBytes uint64 `json:"availableBytes"`
+ UsedBytes *uint64 `json:"usedBytes,omitempty"`
+ SwapTotalBytes uint64 `json:"swapTotalBytes,omitempty"`
+ SwapUsedBytes uint64 `json:"swapUsedBytes,omitempty"`
+}
+
+type Memory struct {
+ TotalBytes uint64 `json:"totalBytes"`
+ AvailableBytes uint64 `json:"availableBytes"`
+ UsedBytes uint64 `json:"usedBytes"`
+ UtilizationPercent float64 `json:"utilizationPercent"`
+ SwapTotalBytes uint64 `json:"swapTotalBytes"`
+ SwapUsedBytes uint64 `json:"swapUsedBytes"`
+ SwapUtilizationPercent float64 `json:"swapUtilizationPercent"`
+}
+
+type RawFilesystem struct {
+ Mount string `json:"mount"`
+ Filesystem string `json:"filesystem,omitempty"`
+ CapacityBytes uint64 `json:"capacityBytes"`
+ UsedBytes uint64 `json:"usedBytes"`
+ Inodes *RawInodes `json:"inodes,omitempty"`
+}
+type RawInodes struct{ Total, Used uint64 }
+type Filesystem struct {
+ Mount string `json:"mount"`
+ Filesystem string `json:"filesystem,omitempty"`
+ CapacityBytes uint64 `json:"capacityBytes"`
+ UsedBytes uint64 `json:"usedBytes"`
+ UtilizationPercent float64 `json:"utilizationPercent"`
+ Inodes *Inodes `json:"inodes,omitempty"`
+}
+type Inodes struct {
+ Total uint64 `json:"total"`
+ Used uint64 `json:"used"`
+}
+
+type RawNetworkInterface struct {
+ Name string `json:"name"`
+ State string `json:"state,omitempty"`
+ RxBytes uint64 `json:"rxBytes"`
+ TxBytes uint64 `json:"txBytes"`
+ RxErrors uint64 `json:"rxErrors"`
+ TxErrors uint64 `json:"txErrors"`
+ RxDrops uint64 `json:"rxDrops"`
+ TxDrops uint64 `json:"txDrops"`
+}
+type NetworkInterface struct {
+ Name string `json:"name"`
+ State string `json:"state,omitempty"`
+ RxBytes uint64 `json:"rxBytes"`
+ TxBytes uint64 `json:"txBytes"`
+ RxErrors uint64 `json:"rxErrors"`
+ TxErrors uint64 `json:"txErrors"`
+ RxDrops uint64 `json:"rxDrops"`
+ TxDrops uint64 `json:"txDrops"`
+}
+type RawTime struct {
+ Synchronized bool
+ OffsetSeconds float64
+ Stratum int
+}
+type TimeHealth struct {
+ Synchronized bool `json:"synchronized"`
+ OffsetSeconds float64 `json:"offsetSeconds"`
+ Stratum int `json:"stratum,omitempty"`
+ State string `json:"state"`
+}
+
+type RawSnapshot struct {
+ Source Source `json:"source"`
+ Identity HostIdentity `json:"identity"`
+ UptimeSeconds float64 `json:"uptimeSeconds"`
+ BootTime *time.Time `json:"bootTime,omitempty"`
+ CPU RawCPU `json:"cpu"`
+ Load RawLoad `json:"load"`
+ Memory RawMemory `json:"memory"`
+ Filesystems []RawFilesystem `json:"filesystems"`
+ Network []RawNetworkInterface `json:"network"`
+ Time RawTime `json:"time"`
+ Hardware RawHardware `json:"hardware"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+type StatusReason struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+type Status struct {
+ State string `json:"state"`
+ Reasons []StatusReason `json:"reasons,omitempty"`
+}
+
+type Snapshot struct {
+ ContractVersion string `json:"contractVersion"`
+ Source Source `json:"source"`
+ Identity HostIdentity `json:"identity"`
+ UptimeSeconds float64 `json:"uptimeSeconds"`
+ BootTime *time.Time `json:"bootTime,omitempty"`
+ CPU CPU `json:"cpu"`
+ Load Load `json:"load"`
+ Memory Memory `json:"memory"`
+ Filesystems []Filesystem `json:"filesystems"`
+ Network []NetworkInterface `json:"network"`
+ Time TimeHealth `json:"time"`
+ Hardware HardwareSnapshot `json:"hardware"`
+ Status Status `json:"status"`
+ ObservedAt time.Time `json:"observedAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+type Provider interface {
+ Snapshot(context.Context) (Snapshot, error)
+}
+
+type UnknownProvider struct {
+ SourceID, SourceType, Reason string
+ Policy Policy
+}
+
+func (p UnknownProvider) Snapshot(ctx context.Context) (Snapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return Snapshot{}, err
+ }
+ now := time.Now().UTC()
+ return UnknownSnapshot(now, p.SourceID, p.SourceType, p.Reason), nil
+}
+
+func UnknownSnapshot(now time.Time, sourceID, sourceType, reason string) Snapshot {
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ if reason == "" {
+ reason = "source_unavailable"
+ }
+ snapshot := Snapshot{ContractVersion: ContractVersion, Source: Source{ID: sourceID, Type: sourceType, CapabilityVersion: ContractVersion, ReceivedAt: now, Freshness: Unavailable, State: StatusUnknown, Reason: reason}, Status: Status{State: StatusUnknown, Reasons: []StatusReason{{Code: reason, Message: "De hosttelemetrie is niet beschikbaar."}}}, ReceivedAt: now}
+ snapshot.Hardware = DisabledHardware()
+ return snapshot
+}
+
+func Normalize(raw RawSnapshot, now time.Time, limits Limits, policy Policy) (Snapshot, error) {
+ limits = limits.withDefaults()
+ policy = policy.withDefaults()
+ if err := limits.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ if err := policy.Validate(); err != nil {
+ return Snapshot{}, err
+ }
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ if raw.ReceivedAt.IsZero() {
+ raw.ReceivedAt = now
+ }
+ if raw.ObservedAt.IsZero() {
+ raw.ObservedAt = raw.ReceivedAt
+ }
+ if raw.ObservedAt.After(now.Add(time.Minute)) {
+ return Snapshot{}, errors.New("host observation is materially in the future")
+ }
+ if strings.TrimSpace(raw.Identity.Name) == "" || len(raw.Identity.Name) > 255 {
+ return Snapshot{}, errors.New("host identity name is required and bounded")
+ }
+ if raw.UptimeSeconds < 0 || raw.UptimeSeconds > 100*365*24*60*60 || math.IsNaN(raw.UptimeSeconds) || math.IsInf(raw.UptimeSeconds, 0) {
+ return Snapshot{}, errors.New("host uptime is outside bounds")
+ }
+ if len(raw.CPU.PerCore) > limits.MaxCores || len(raw.Filesystems) > limits.MaxFilesystems || len(raw.Network) > limits.MaxInterfaces || len(raw.Warnings) > limits.MaxWarnings {
+ return Snapshot{}, errors.New("host collection exceeds bounds")
+ }
+ for _, value := range append(append([]float64{}, raw.CPU.PerCore...), raw.CPU.IOWaitPercentValue()) {
+ if err := percent(value); err != nil {
+ return Snapshot{}, err
+ }
+ }
+ if raw.CPU.TotalPercent != nil {
+ if err := percent(*raw.CPU.TotalPercent); err != nil {
+ return Snapshot{}, err
+ }
+ }
+ if raw.Memory.TotalBytes == 0 || raw.Memory.AvailableBytes > raw.Memory.TotalBytes {
+ return Snapshot{}, errors.New("host memory totals are invalid")
+ }
+ if raw.Memory.UsedBytes != nil && *raw.Memory.UsedBytes > raw.Memory.TotalBytes {
+ return Snapshot{}, errors.New("host memory used bytes are invalid")
+ }
+ if raw.Memory.SwapUsedBytes > raw.Memory.SwapTotalBytes && raw.Memory.SwapTotalBytes > 0 {
+ return Snapshot{}, errors.New("host swap values are invalid")
+ }
+ for _, value := range []float64{raw.Load.One, raw.Load.Five, raw.Load.Fifteen, raw.Time.OffsetSeconds} {
+ if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
+ return Snapshot{}, errors.New("host load or time values are invalid")
+ }
+ }
+ filesystems := make([]Filesystem, 0, len(raw.Filesystems))
+ for _, fs := range raw.Filesystems {
+ if strings.TrimSpace(fs.Mount) == "" || len(fs.Mount) > 512 || fs.CapacityBytes == 0 || fs.UsedBytes > fs.CapacityBytes {
+ return Snapshot{}, errors.New("host filesystem values are invalid")
+ }
+ item := Filesystem{Mount: fs.Mount, Filesystem: fs.Filesystem, CapacityBytes: fs.CapacityBytes, UsedBytes: fs.UsedBytes, UtilizationPercent: ratioPercent(fs.UsedBytes, fs.CapacityBytes)}
+ if fs.Inodes != nil {
+ if fs.Inodes.Total == 0 || fs.Inodes.Used > fs.Inodes.Total {
+ return Snapshot{}, errors.New("host inode values are invalid")
+ }
+ item.Inodes = &Inodes{Total: fs.Inodes.Total, Used: fs.Inodes.Used}
+ }
+ filesystems = append(filesystems, item)
+ }
+ sort.Slice(filesystems, func(i, j int) bool { return filesystems[i].Mount < filesystems[j].Mount })
+ network := make([]NetworkInterface, 0, len(raw.Network))
+ for _, iface := range raw.Network {
+ if strings.TrimSpace(iface.Name) == "" || len(iface.Name) > 128 {
+ return Snapshot{}, errors.New("host network interface name is invalid")
+ }
+ network = append(network, NetworkInterface{Name: iface.Name, State: iface.State, RxBytes: iface.RxBytes, TxBytes: iface.TxBytes, RxErrors: iface.RxErrors, TxErrors: iface.TxErrors, RxDrops: iface.RxDrops, TxDrops: iface.TxDrops})
+ }
+ sort.Slice(network, func(i, j int) bool { return network[i].Name < network[j].Name })
+ warnings := append([]string(nil), raw.Warnings...)
+ sort.Strings(warnings)
+ source := raw.Source
+ source.ObservedAt = raw.ObservedAt.UTC()
+ source.ReceivedAt = raw.ReceivedAt.UTC()
+ source.Freshness = Fresh
+ source.State = StatusHealthy
+ if now.Sub(raw.ObservedAt) > policy.FreshnessMaxAge {
+ source.Freshness = Stale
+ source.State = StatusUnknown
+ source.Reason = "stale_source"
+ }
+ if source.ID == "" {
+ source.ID = "host"
+ }
+ if source.Type == "" {
+ source.Type = "agent"
+ }
+ if source.CapabilityVersion == "" {
+ source.CapabilityVersion = ContractVersion
+ }
+ hardware, err := NormalizeHardware(raw.Hardware, source.ID, HardwareLimits{}, ThermalPolicy{})
+ if err != nil {
+ return Snapshot{}, err
+ }
+ var used uint64
+ if raw.Memory.UsedBytes != nil {
+ used = *raw.Memory.UsedBytes
+ } else {
+ used = raw.Memory.TotalBytes - raw.Memory.AvailableBytes
+ }
+ memory := Memory{TotalBytes: raw.Memory.TotalBytes, AvailableBytes: raw.Memory.AvailableBytes, UsedBytes: used, UtilizationPercent: ratioPercent(used, raw.Memory.TotalBytes), SwapTotalBytes: raw.Memory.SwapTotalBytes, SwapUsedBytes: raw.Memory.SwapUsedBytes}
+ if raw.Memory.SwapTotalBytes > 0 {
+ memory.SwapUtilizationPercent = ratioPercent(raw.Memory.SwapUsedBytes, raw.Memory.SwapTotalBytes)
+ }
+ cpu := CPU{TotalPercent: cloneFloat(raw.CPU.TotalPercent), PerCore: append([]float64(nil), raw.CPU.PerCore...), IOWaitPercent: cloneFloat(raw.CPU.IOWaitPercent)}
+ snapshot := Snapshot{ContractVersion: ContractVersion, Source: source, Identity: raw.Identity, UptimeSeconds: raw.UptimeSeconds, BootTime: utcPtr(raw.BootTime), CPU: cpu, Load: Load{One: raw.Load.One, Five: raw.Load.Five, Fifteen: raw.Load.Fifteen}, Memory: memory, Filesystems: filesystems, Network: network, Time: TimeHealth{Synchronized: raw.Time.Synchronized, OffsetSeconds: raw.Time.OffsetSeconds, Stratum: raw.Time.Stratum, State: timeState(raw.Time.Synchronized, source.Freshness)}, ObservedAt: raw.ObservedAt.UTC(), ReceivedAt: raw.ReceivedAt.UTC(), Warnings: warnings}
+ snapshot.Hardware = hardware
+ snapshot.Status = EvaluateStatus(snapshot, now, policy)
+ return snapshot, nil
+}
+
+func EvaluateStatus(snapshot Snapshot, now time.Time, policy Policy) Status {
+ policy = policy.withDefaults()
+ result := Status{State: StatusHealthy}
+ if snapshot.Source.Freshness != Fresh || snapshot.Source.State == StatusUnknown || snapshot.ObservedAt.IsZero() || (!now.IsZero() && now.Sub(snapshot.ObservedAt) > policy.FreshnessMaxAge) {
+ return Status{State: StatusUnknown, Reasons: []StatusReason{{Code: "stale_source", Message: "De hosttelemetrie is verouderd; status is Onbekend."}}}
+ }
+ cores := len(snapshot.CPU.PerCore)
+ if cores < 1 {
+ cores = 1
+ }
+ if snapshot.Load.One >= float64(cores)*policy.SaturationLoadPerCore && snapshot.Load.Five >= float64(cores)*policy.HighLoadPerCore {
+ result.State = StatusDegraded
+ result.Reasons = append(result.Reasons, StatusReason{Code: "load_sustained_saturation", Message: fmt.Sprintf("Load %.2f blijft hoog voor %d cores; verzadiging is waarschijnlijk.", snapshot.Load.One, cores)})
+ } else if snapshot.Load.One >= float64(cores)*policy.HighLoadPerCore {
+ result.State = StatusDegraded
+ result.Reasons = append(result.Reasons, StatusReason{Code: "load_high", Message: fmt.Sprintf("Load %.2f is hoog voor %d cores; controleer de trend.", snapshot.Load.One, cores)})
+ }
+ if snapshot.Memory.UtilizationPercent >= policy.HighMemoryPercent {
+ result.State = StatusDegraded
+ result.Reasons = append(result.Reasons, StatusReason{Code: "memory_high", Message: fmt.Sprintf("Geheugengebruik is %.1f%%; beschikbare marge is beperkt.", snapshot.Memory.UtilizationPercent)})
+ }
+ if !snapshot.Time.Synchronized {
+ result.State = StatusDegraded
+ result.Reasons = append(result.Reasons, StatusReason{Code: "time_unsynchronized", Message: "De hostklok is niet gesynchroniseerd."})
+ }
+ return result
+}
+
+func (r RawCPU) IOWaitPercentValue() float64 {
+ if r.IOWaitPercent == nil {
+ return 0
+ }
+ return *r.IOWaitPercent
+}
+func percent(value float64) error {
+ if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 100 {
+ return errors.New("host percentage is outside 0-100")
+ }
+ return nil
+}
+func ratioPercent(part, total uint64) float64 {
+ if total == 0 {
+ return 0
+ }
+ return float64(part) / float64(total) * 100
+}
+func formatNumber(value float64) string { return fmt.Sprintf("%.6g", value) }
+func cloneFloat(value *float64) *float64 {
+ if value == nil {
+ return nil
+ }
+ copy := *value
+ return ©
+}
+func utcPtr(value *time.Time) *time.Time {
+ if value == nil {
+ return nil
+ }
+ copy := value.UTC()
+ return ©
+}
+func timeState(synchronized bool, freshness string) string {
+ if freshness != Fresh {
+ return StatusUnknown
+ }
+ if synchronized {
+ return StatusHealthy
+ }
+ return StatusDegraded
+}
diff --git a/internal/host/types_test.go b/internal/host/types_test.go
new file mode 100644
index 0000000..c994aec
--- /dev/null
+++ b/internal/host/types_test.go
@@ -0,0 +1,108 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func floatPtr(value float64) *float64 { return &value }
+
+func fixtureRaw(now time.Time) RawSnapshot {
+ used := uint64(6 * 1024 * 1024 * 1024)
+ return RawSnapshot{
+ Source: Source{ID: "agent-1", Type: "agent", CapabilityVersion: ContractVersion},
+ Identity: HostIdentity{Name: "pulse-host", Version: "7.2.2", Arch: "amd64"},
+ UptimeSeconds: 3600,
+ CPU: RawCPU{TotalPercent: floatPtr(42.5), PerCore: []float64{40, 45, 42.5}, IOWaitPercent: floatPtr(2)},
+ Load: RawLoad{One: 1.2, Five: 0.8, Fifteen: 0.4},
+ Memory: RawMemory{TotalBytes: 8 * 1024 * 1024 * 1024, AvailableBytes: 2 * 1024 * 1024 * 1024, UsedBytes: &used},
+ Filesystems: []RawFilesystem{{Mount: "/", Filesystem: "xfs", CapacityBytes: 100, UsedBytes: 25, Inodes: &RawInodes{Total: 1000, Used: 100}}},
+ Network: []RawNetworkInterface{{Name: "eth0", State: "up", RxBytes: 10, TxBytes: 20}},
+ Time: RawTime{Synchronized: true, OffsetSeconds: 0.002, Stratum: 2},
+ ObservedAt: now.Add(-2 * time.Second), ReceivedAt: now,
+ }
+}
+
+func TestNormalizePreservesUnitsAndSortsCollections(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ raw := fixtureRaw(now)
+ raw.Filesystems = append(raw.Filesystems, RawFilesystem{Mount: "/data", CapacityBytes: 1000, UsedBytes: 500})
+ raw.Network = append(raw.Network, RawNetworkInterface{Name: "bond0", State: "up"})
+ snapshot, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.CPU.TotalPercent == nil || *snapshot.CPU.TotalPercent != 42.5 || snapshot.Memory.UsedBytes != 6*1024*1024*1024 || snapshot.Memory.UtilizationPercent != 75 {
+ t.Fatalf("normalized units changed: %+v", snapshot)
+ }
+ if len(snapshot.CPU.PerCore) != 3 || len(snapshot.Filesystems) != 2 || snapshot.Filesystems[0].Mount != "/" || snapshot.Network[0].Name != "bond0" {
+ t.Fatalf("bounded deterministic ordering failed: %+v", snapshot)
+ }
+ if snapshot.Status.State != StatusHealthy || snapshot.Source.Freshness != Fresh || snapshot.Time.State != StatusHealthy {
+ t.Fatalf("unexpected healthy state: %+v source=%+v time=%+v", snapshot.Status, snapshot.Source, snapshot.Time)
+ }
+}
+
+func TestNormalizeStaleSourceIsUnknown(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ raw := fixtureRaw(now)
+ raw.ObservedAt = now.Add(-time.Minute)
+ snapshot, err := Normalize(raw, now, Limits{}, Policy{FreshnessMaxAge: 30 * time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Source.State != StatusUnknown || snapshot.Source.Freshness != Stale || snapshot.Status.State != StatusUnknown {
+ t.Fatalf("stale source was not made unknown: %+v", snapshot)
+ }
+}
+
+func TestHighLoadFixtureExplainsSustainedSaturation(t *testing.T) {
+ now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ raw := fixtureRaw(now)
+ raw.CPU.PerCore = []float64{80, 80, 80, 80}
+ raw.Load = RawLoad{One: 10, Five: 9, Fifteen: 8}
+ snapshot, err := Normalize(raw, now, Limits{}, Policy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Status.State != StatusDegraded || len(snapshot.Status.Reasons) == 0 || snapshot.Status.Reasons[0].Code != "load_sustained_saturation" {
+ t.Fatalf("high-load policy was not explained: %+v", snapshot.Status)
+ }
+}
+
+func TestNormalizeRejectsOutOfRangeAndUnboundedPayloads(t *testing.T) {
+ now := time.Now().UTC()
+ raw := fixtureRaw(now)
+ raw.CPU.TotalPercent = floatPtr(101)
+ if _, err := Normalize(raw, now, Limits{}, Policy{}); err == nil {
+ t.Fatal("expected percentage validation error")
+ }
+ raw = fixtureRaw(now)
+ raw.CPU.PerCore = make([]float64, 257)
+ if _, err := Normalize(raw, now, Limits{}, Policy{}); err == nil {
+ t.Fatal("expected collection bound error")
+ }
+}
+
+type testRawSource struct {
+ raw RawSnapshot
+ err error
+}
+
+func (s testRawSource) Snapshot(ctx context.Context) (RawSnapshot, error) {
+ if err := ctx.Err(); err != nil {
+ return RawSnapshot{}, err
+ }
+ return s.raw, s.err
+}
+
+func TestAdapterHonorsCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ adapter := Adapter{Source: testRawSource{raw: fixtureRaw(time.Now().UTC())}}
+ if _, err := adapter.Snapshot(ctx); !errors.Is(err, context.Canceled) {
+ t.Fatalf("err=%v", err)
+ }
+}
diff --git a/internal/hostapi/handler.go b/internal/hostapi/handler.go
new file mode 100644
index 0000000..c7eff6a
--- /dev/null
+++ b/internal/hostapi/handler.go
@@ -0,0 +1,46 @@
+package hostapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/host"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Handler struct{ Provider host.Provider }
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/host" {
+ http.NotFound(w, r)
+ return
+ }
+ if _, ok := auth.PrincipalFromContext(r.Context()); !ok {
+ problem.Write(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required", "Authentication is required to read host telemetry.", nil)
+ return
+ }
+ if err := r.Context().Err(); err != nil {
+ return
+ }
+ var snapshot host.Snapshot
+ var err error
+ if h.Provider == nil {
+ snapshot = host.UnknownSnapshot(time.Now().UTC(), "host", "agent", "source_unavailable")
+ } else {
+ snapshot, err = h.Provider.Snapshot(r.Context())
+ }
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(r.Context().Err(), context.Canceled) {
+ return
+ }
+ problem.Write(w, r, http.StatusServiceUnavailable, "HOST_UNAVAILABLE", "Hosttelemetrie niet beschikbaar", "De hosttelemetrie kon niet worden gelezen.", nil)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, max-age=5")
+ _ = json.NewEncoder(w).Encode(snapshot)
+}
diff --git a/internal/hostapi/handler_test.go b/internal/hostapi/handler_test.go
new file mode 100644
index 0000000..90015e9
--- /dev/null
+++ b/internal/hostapi/handler_test.go
@@ -0,0 +1,51 @@
+package hostapi
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/host"
+)
+
+type provider struct{ snapshot host.Snapshot }
+
+func (p provider) Snapshot(context.Context) (host.Snapshot, error) { return p.snapshot, nil }
+
+func authenticatedRequest(method, path string) *http.Request {
+ request := httptest.NewRequest(method, path, nil)
+ return request.WithContext(auth.WithPrincipal(request.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
+}
+
+func TestHandlerRequiresAuthenticationAndExposesUnknownFallback(t *testing.T) {
+ unauthenticated := httptest.NewRecorder()
+ Handler{}.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/v1/host", nil))
+ if unauthenticated.Code != http.StatusUnauthorized {
+ t.Fatalf("status=%d body=%s", unauthenticated.Code, unauthenticated.Body.String())
+ }
+
+ response := httptest.NewRecorder()
+ Handler{}.ServeHTTP(response, authenticatedRequest(http.MethodGet, "/api/v1/host"))
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"state":"unknown"`) {
+ t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
+ }
+}
+
+func TestHandlerReturnsProviderSnapshotAndRejectsOtherRoutes(t *testing.T) {
+ snapshot := host.UnknownSnapshot(time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), "agent-1", "agent", "fixture")
+ response := httptest.NewRecorder()
+ Handler{Provider: provider{snapshot: snapshot}}.ServeHTTP(response, authenticatedRequest(http.MethodGet, "/api/v1/host"))
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"id":"agent-1"`) {
+ t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
+ }
+
+ other := httptest.NewRecorder()
+ Handler{}.ServeHTTP(other, authenticatedRequest(http.MethodPost, "/api/v1/host"))
+ if other.Code != http.StatusNotFound {
+ t.Fatalf("status=%d", other.Code)
+ }
+}
diff --git a/internal/hostcollect/clock_linux.go b/internal/hostcollect/clock_linux.go
new file mode 100644
index 0000000..34ddbfb
--- /dev/null
+++ b/internal/hostcollect/clock_linux.go
@@ -0,0 +1,46 @@
+//go:build linux
+
+package hostcollect
+
+import (
+ "math"
+ "syscall"
+
+ "github.com/itworx/pulse/internal/host"
+)
+
+// Bits of the adjtimex status word, defined here because the standard syscall package
+// does not export them on every architecture.
+const (
+ staUnsync = 0x0040 // clock is not synchronised to a reference
+ staNano = 0x2000 // offset and precision are in nanoseconds, not microseconds
+ timeError = 5 // adjtimex state: the clock is not synchronised
+)
+
+// readClockSync asks the kernel about clock discipline with adjtimex(2) in read mode.
+//
+// A zeroed Timex has Modes == 0, which makes the call a pure read: it adjusts nothing.
+// Docker's default seccomp profile still gates adjtimex behind CAP_SYS_TIME, which the
+// agent drops, so the expected in-container result is EPERM. The caller turns that into
+// a warning; see Collector.clock.
+func readClockSync() (host.RawTime, error) {
+ timex := syscall.Timex{}
+ state, err := syscall.Adjtimex(&timex)
+ if err != nil {
+ return host.RawTime{}, err
+ }
+ divisor := 1e6
+ if timex.Status&staNano != 0 {
+ divisor = 1e9
+ }
+ offset := math.Abs(float64(timex.Offset)) / divisor
+ if math.IsNaN(offset) || math.IsInf(offset, 0) {
+ offset = 0
+ }
+ return host.RawTime{
+ Synchronized: timex.Status&staUnsync == 0 && state != timeError,
+ // The kernel exposes no stratum; that belongs to the NTP daemon, which the
+ // agent deliberately does not talk to.
+ OffsetSeconds: offset,
+ }, nil
+}
diff --git a/internal/hostcollect/clock_other.go b/internal/hostcollect/clock_other.go
new file mode 100644
index 0000000..d342cb8
--- /dev/null
+++ b/internal/hostcollect/clock_other.go
@@ -0,0 +1,14 @@
+//go:build !linux
+
+package hostcollect
+
+import (
+ "errors"
+
+ "github.com/itworx/pulse/internal/host"
+)
+
+// readClockSync has no portable equivalent off Linux; the caller degrades to a warning.
+func readClockSync() (host.RawTime, error) {
+ return host.RawTime{}, errors.New("hostcollect: clock synchronisation probe is only available on linux")
+}
diff --git a/internal/hostcollect/collector.go b/internal/hostcollect/collector.go
new file mode 100644
index 0000000..4cac376
--- /dev/null
+++ b/internal/hostcollect/collector.go
@@ -0,0 +1,350 @@
+// Package hostcollect turns the kernel's read-only procfs and sysfs views into the
+// bounded domain snapshots defined by internal/host and internal/process.
+//
+// Everything here is read-only by construction: the collector opens files under a
+// configurable procfs/sysfs root and never writes, executes, or opens a socket. That
+// is what lets pulse-agent run with cap_drop: [ALL] and satisfy ADR-0005 — no Docker
+// socket, no privileged host access, no mutation path.
+//
+// The roots are injectable so the parsers can be exercised against committed fixture
+// directories: the test environment is never the target host, and a collector that can
+// only be tested on the real machine is a collector that is not tested at all.
+//
+// Two properties of /proc drive most of the design:
+//
+// - Utilisation is a delta, not a reading. /proc/stat and /proc//stat expose
+// monotonic counters in USER_HZ ticks, so a single sample cannot yield a percentage.
+// The collector keeps the previous sample and reports percentages only from the
+// second collection onwards.
+// - /proc is a live view of a moving system. Processes disappear between readdir and
+// open, counters reset when a kernel counter wraps or a task is replaced by a new
+// one with the same PID. Every such case degrades one field, never the collection.
+package hostcollect
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "time"
+
+ "github.com/itworx/pulse/internal/host"
+ "github.com/itworx/pulse/internal/process"
+)
+
+const (
+ // DefaultProcRoot is the standard procfs mount point.
+ DefaultProcRoot = "/proc"
+ // DefaultSysRoot is the standard sysfs mount point.
+ DefaultSysRoot = "/sys"
+ // defaultClockTicks is the USER_HZ value the kernel exposes CPU times in. It is
+ // 100 on every supported Linux/architecture combination Pulse targets; sysconf is
+ // unavailable without cgo, so it stays configurable instead of guessed silently.
+ defaultClockTicks = 100
+ // defaultPageSizeFallback is used only when the runtime reports a nonsensical page
+ // size; RSS in /proc//stat is counted in pages, not bytes.
+ defaultPageSizeFallback = 4096
+
+ maxFileBytes = 1 << 20
+ maxProcessFileBytes = 64 << 10
+ // maxCmdlineBytes bounds how much of a command line is read at all. Only the
+ // program name is ever kept, so arguments — which routinely carry tokens and
+ // passwords — are never copied into a snapshot.
+ maxCmdlineBytes = 4 << 10
+ maxNameBytes = 255
+)
+
+// Warning codes attached to a host snapshot when one optional field degrades. They are
+// stable identifiers, safe to log and to render, and never contain host data.
+const (
+ WarningCPUUnavailable = "cpu_source_unavailable"
+ WarningCPUFirstSample = "cpu_awaiting_second_sample"
+ WarningCPUCounterReset = "cpu_counter_reset"
+ WarningCPUTopologyChanged = "cpu_topology_changed"
+ WarningCPUCoresTruncated = "cpu_cores_truncated"
+ WarningLoadUnavailable = "load_source_unavailable"
+ WarningNetworkUnavailable = "network_source_unavailable"
+ WarningNetworkTruncated = "network_interfaces_truncated"
+ WarningMountsUnavailable = "filesystem_source_unavailable"
+ WarningFilesystemPartial = "filesystem_partially_unavailable"
+ WarningFilesystemTruncated = "filesystems_truncated"
+ WarningFilesystemDisabled = "filesystem_root_not_configured"
+ WarningKernelUnavailable = "kernel_version_unavailable"
+ WarningClockProbeAssumed = "clock_sync_unverified_assumed_synchronized"
+)
+
+// FilesystemUsage is the bounded result of one statfs call.
+type FilesystemUsage struct {
+ CapacityBytes uint64
+ UsedBytes uint64
+ InodesTotal uint64
+ InodesUsed uint64
+}
+
+// Options configures a Collector. The zero value is usable and reads the real host.
+type Options struct {
+ // ProcRoot is the procfs mount point, "/proc" by default. In a container it is the
+ // read-only bind mount of the host's /proc (see deploy/compose.yaml).
+ ProcRoot string
+ // SysRoot is the sysfs mount point, "/sys" by default. Only used for interface
+ // operational state today.
+ SysRoot string
+ // FilesystemRoot prefixes every mount point before statfs. It is empty by default,
+ // which disables filesystem collection entirely: inside a container the host mount
+ // points listed in /proc/mounts do not resolve, and statfs of an identically named
+ // path would silently report the container's own overlay instead of the host's
+ // array. Set it to "/" on a host, or to the prefix a host root is mounted at.
+ FilesystemRoot string
+ // HostName overrides the collected host name. /proc/sys/kernel/hostname is read
+ // through the reader's UTS namespace, so inside a container it returns the
+ // container's name even when the host's /proc is bind mounted.
+ HostName string
+ // ClockTicks is USER_HZ; 100 when unset.
+ ClockTicks float64
+ // PageSize is the memory page size in bytes; the runtime value when unset.
+ PageSize int
+ // SourceID identifies this collector in the snapshot's Source block.
+ SourceID string
+ // HostLimits bounds the host snapshot (cores, filesystems, interfaces, warnings).
+ HostLimits host.Limits
+ // ProcessLimits bounds the process inventory.
+ ProcessLimits process.Limits
+ // Now returns the current time; time.Now when unset.
+ Now func() time.Time
+ // StatFS reads usage for one mount point; a real statfs syscall when unset.
+ StatFS func(path string) (FilesystemUsage, error)
+ // ClockSync reports host clock synchronisation; adjtimex(2) in read mode when unset.
+ ClockSync func() (host.RawTime, error)
+}
+
+func (o Options) withDefaults() Options {
+ if o.ProcRoot == "" {
+ o.ProcRoot = DefaultProcRoot
+ }
+ if o.SysRoot == "" {
+ o.SysRoot = DefaultSysRoot
+ }
+ if o.ClockTicks <= 0 {
+ o.ClockTicks = defaultClockTicks
+ }
+ if o.PageSize <= 0 {
+ o.PageSize = os.Getpagesize()
+ }
+ if o.PageSize <= 0 {
+ o.PageSize = defaultPageSizeFallback
+ }
+ if o.SourceID == "" {
+ o.SourceID = "host"
+ }
+ o.HostLimits = hostLimitsWithDefaults(o.HostLimits)
+ o.ProcessLimits = processLimitsWithDefaults(o.ProcessLimits)
+ if o.Now == nil {
+ o.Now = time.Now
+ }
+ if o.StatFS == nil {
+ o.StatFS = statFS
+ }
+ if o.ClockSync == nil {
+ o.ClockSync = readClockSync
+ }
+ return o
+}
+
+// hostLimitsWithDefaults mirrors the defaults internal/host applies during
+// normalization; the collector must bound its output before the domain sees it, and
+// the domain's own defaulting is unexported.
+func hostLimitsWithDefaults(limits host.Limits) host.Limits {
+ if limits.MaxCores == 0 {
+ limits.MaxCores = 256
+ }
+ if limits.MaxFilesystems == 0 {
+ limits.MaxFilesystems = 256
+ }
+ if limits.MaxInterfaces == 0 {
+ limits.MaxInterfaces = 128
+ }
+ if limits.MaxWarnings == 0 {
+ limits.MaxWarnings = 20
+ }
+ return limits
+}
+
+func processLimitsWithDefaults(limits process.Limits) process.Limits {
+ if limits.MaxRows == 0 {
+ limits.MaxRows = 1000
+ }
+ if limits.MaxPageSize == 0 {
+ limits.MaxPageSize = 100
+ }
+ return limits
+}
+
+// Collector reads host and process telemetry. It is safe for concurrent use; the
+// previous samples it needs for delta calculation are guarded by a mutex.
+type Collector struct {
+ options Options
+
+ mu sync.Mutex
+ cpu *cpuSample
+ processes map[processKey]processCPUSample
+}
+
+// New validates the options and returns a ready collector.
+func New(options Options) (*Collector, error) {
+ options = options.withDefaults()
+ if !filepath.IsAbs(options.ProcRoot) && !isTestPath(options.ProcRoot) {
+ return nil, fmt.Errorf("hostcollect: proc root %q must be an absolute path", options.ProcRoot)
+ }
+ if err := options.HostLimits.Validate(); err != nil {
+ return nil, fmt.Errorf("hostcollect: %w", err)
+ }
+ if err := options.ProcessLimits.Validate(); err != nil {
+ return nil, fmt.Errorf("hostcollect: %w", err)
+ }
+ return &Collector{options: options, processes: map[processKey]processCPUSample{}}, nil
+}
+
+// isTestPath allows relative fixture roots so tests can use testdata directories
+// without constructing absolute paths.
+func isTestPath(path string) bool { return path != "" && !filepath.IsAbs(path) }
+
+// Host reads one bounded host snapshot. It fails only when a field the domain requires
+// is unreadable (identity, uptime, memory); every optional field degrades into a
+// warning so a single unreadable file cannot blank the whole capability.
+func (c *Collector) Host(ctx context.Context) (host.RawSnapshot, error) {
+ if c == nil {
+ return host.RawSnapshot{}, errors.New("hostcollect: collector is nil")
+ }
+ if err := ctx.Err(); err != nil {
+ return host.RawSnapshot{}, err
+ }
+ now := c.options.Now().UTC()
+ warnings := newWarningSet(c.options.HostLimits.MaxWarnings)
+
+ identity, err := c.identity()
+ if err != nil {
+ return host.RawSnapshot{}, err
+ }
+ uptimeSeconds, err := c.uptimeSeconds()
+ if err != nil {
+ return host.RawSnapshot{}, err
+ }
+ memory, err := c.memory()
+ if err != nil {
+ return host.RawSnapshot{}, err
+ }
+
+ bootTime := now.Add(-time.Duration(uptimeSeconds * float64(time.Second))).UTC()
+
+ load, err := c.loadAverage()
+ if err != nil {
+ warnings.add(WarningLoadUnavailable)
+ }
+ cpu := c.cpuUsage(now, warnings)
+ network := c.network(warnings)
+ filesystems := c.filesystems(warnings)
+ clock := c.clock(warnings)
+
+ return host.RawSnapshot{
+ Source: host.Source{
+ ID: c.options.SourceID,
+ Type: "agent",
+ CapabilityVersion: host.ContractVersion,
+ ObservedAt: now,
+ },
+ Identity: identity,
+ UptimeSeconds: uptimeSeconds,
+ BootTime: &bootTime,
+ CPU: cpu,
+ Load: load,
+ Memory: memory,
+ Filesystems: filesystems,
+ Network: network,
+ Time: clock,
+ ObservedAt: now,
+ Warnings: warnings.list(),
+ }, nil
+}
+
+func (c *Collector) identity() (host.HostIdentity, error) {
+ identity := host.HostIdentity{Name: c.options.HostName, Arch: runtime.GOARCH}
+ if identity.Name == "" {
+ name, err := readTrimmed(c.procPath("sys", "kernel", "hostname"), maxProcessFileBytes)
+ if err != nil {
+ return host.HostIdentity{}, fmt.Errorf("hostcollect: read host name: %w", err)
+ }
+ identity.Name = name
+ }
+ if identity.Name == "" {
+ return host.HostIdentity{}, errors.New("hostcollect: host name is empty")
+ }
+ if len(identity.Name) > maxNameBytes {
+ identity.Name = identity.Name[:maxNameBytes]
+ }
+ if kernel, err := readTrimmed(c.procPath("sys", "kernel", "osrelease"), maxProcessFileBytes); err == nil {
+ identity.Kernel = truncate(kernel, maxNameBytes)
+ }
+ return identity, nil
+}
+
+// clock reports host clock synchronisation. adjtimex(2) in read mode (Modes == 0)
+// mutates nothing, but Docker's default seccomp profile gates it behind CAP_SYS_TIME,
+// which the agent drops. When the probe is unavailable the collector reports the clock
+// as synchronised and records a warning rather than reporting an unsynchronised clock:
+// the domain maps "not synchronised" to Degraded with an explicit operator-facing
+// reason, and asserting a clock fault we never observed would be a false alarm on every
+// healthy host. The warning keeps the uncertainty visible in the snapshot.
+func (c *Collector) clock(warnings *warningSet) host.RawTime {
+ clock, err := c.options.ClockSync()
+ if err != nil {
+ warnings.add(WarningClockProbeAssumed)
+ return host.RawTime{Synchronized: true}
+ }
+ if clock.OffsetSeconds < 0 {
+ clock.OffsetSeconds = -clock.OffsetSeconds
+ }
+ return clock
+}
+
+func (c *Collector) procPath(elements ...string) string {
+ return filepath.Join(append([]string{c.options.ProcRoot}, elements...)...)
+}
+
+func (c *Collector) sysPath(elements ...string) string {
+ return filepath.Join(append([]string{c.options.SysRoot}, elements...)...)
+}
+
+// warningSet collects deduplicated, bounded warning codes.
+type warningSet struct {
+ max int
+ seen map[string]struct{}
+ items []string
+}
+
+func newWarningSet(max int) *warningSet {
+ if max < 1 {
+ max = 1
+ }
+ return &warningSet{max: max, seen: map[string]struct{}{}}
+}
+
+func (w *warningSet) add(code string) {
+ if code == "" || len(w.items) >= w.max {
+ return
+ }
+ if _, exists := w.seen[code]; exists {
+ return
+ }
+ w.seen[code] = struct{}{}
+ w.items = append(w.items, code)
+}
+
+func (w *warningSet) list() []string {
+ if len(w.items) == 0 {
+ return nil
+ }
+ return append([]string(nil), w.items...)
+}
diff --git a/internal/hostcollect/collector_test.go b/internal/hostcollect/collector_test.go
new file mode 100644
index 0000000..311ba38
--- /dev/null
+++ b/internal/hostcollect/collector_test.go
@@ -0,0 +1,432 @@
+package hostcollect
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/host"
+)
+
+// copyTree copies a fixture directory into a writable temporary root so a test can
+// mutate individual /proc files between collections, which is the only way to exercise
+// delta based metrics.
+func copyTree(t *testing.T, source string) string {
+ t.Helper()
+ destination := t.TempDir()
+ err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ relative, relErr := filepath.Rel(source, path)
+ if relErr != nil {
+ return relErr
+ }
+ target := filepath.Join(destination, relative)
+ if entry.IsDir() {
+ return os.MkdirAll(target, 0o755)
+ }
+ data, readErr := os.ReadFile(path) //nolint:gosec // fixture path is test-controlled
+ if readErr != nil {
+ return readErr
+ }
+ return os.WriteFile(target, data, 0o600)
+ })
+ if err != nil {
+ t.Fatalf("copy fixture tree: %v", err)
+ }
+ return destination
+}
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("create directory: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+}
+
+type fakeClock struct{ current time.Time }
+
+func (c *fakeClock) now() time.Time { return c.current }
+
+func (c *fakeClock) advance(d time.Duration) { c.current = c.current.Add(d) }
+
+func newTestCollector(t *testing.T, options Options) (*Collector, *fakeClock) {
+ t.Helper()
+ clock := &fakeClock{current: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)}
+ if options.ProcRoot == "" {
+ options.ProcRoot = filepath.Join("testdata", "proc-healthy")
+ }
+ if options.SysRoot == "" {
+ options.SysRoot = filepath.Join("testdata", "sys-healthy")
+ }
+ if options.Now == nil {
+ options.Now = clock.now
+ }
+ if options.ClockSync == nil {
+ options.ClockSync = func() (host.RawTime, error) {
+ return host.RawTime{Synchronized: true, OffsetSeconds: 0.0012}, nil
+ }
+ }
+ collector, err := New(options)
+ if err != nil {
+ t.Fatalf("New returned error: %v", err)
+ }
+ return collector, clock
+}
+
+func TestCPUUsageNeedsTwoSamples(t *testing.T) {
+ root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
+ collector, clock := newTestCollector(t, Options{ProcRoot: root})
+
+ first := newWarningSet(20)
+ if cpu := collector.cpuUsage(clock.now(), first); cpu.TotalPercent != nil || cpu.PerCore != nil {
+ t.Fatalf("first sample must not report utilisation: %+v", cpu)
+ }
+ if !containsString(first.list(), WarningCPUFirstSample) {
+ t.Fatalf("expected the first sample warning, got %v", first.list())
+ }
+
+ // Second sample: 200 additional ticks of which 100 idle and 20 iowait, so 40% busy.
+ writeFile(t, filepath.Join(root, "stat"), strings.Join([]string{
+ "cpu 1234647 8901 234567 45679001 12365 0 6789 1234 5000 100",
+ "cpu0 617323 4450 117283 22839500 6182 0 3394 617 2500 50",
+ "cpu1 617324 4451 117284 22839501 6183 0 3395 617 2500 50",
+ "",
+ }, "\n"))
+ clock.advance(10 * time.Second)
+
+ second := newWarningSet(20)
+ cpu := collector.cpuUsage(clock.now(), second)
+ if cpu.TotalPercent == nil {
+ t.Fatal("second sample must report utilisation")
+ }
+ if got := *cpu.TotalPercent; got < 39.9 || got > 40.1 {
+ t.Fatalf("total = %v, want 40", got)
+ }
+ if cpu.IOWaitPercent == nil || *cpu.IOWaitPercent < 9.9 || *cpu.IOWaitPercent > 10.1 {
+ t.Fatalf("iowait = %v, want 10", cpu.IOWaitPercent)
+ }
+ if len(cpu.PerCore) != 2 {
+ t.Fatalf("per core = %v", cpu.PerCore)
+ }
+ if warnings := second.list(); len(warnings) != 0 {
+ t.Fatalf("unexpected warnings: %v", warnings)
+ }
+}
+
+func TestCPUUsageReportsNothingWhenCountersWrap(t *testing.T) {
+ root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
+ collector, clock := newTestCollector(t, Options{ProcRoot: root})
+ collector.cpuUsage(clock.now(), newWarningSet(20))
+
+ writeFile(t, filepath.Join(root, "stat"), "cpu 10 1 2 3 4 0 5 0 0 0\ncpu0 5 0 1 1 2 0 2 0 0 0\ncpu1 5 1 1 2 2 0 3 0 0 0\n")
+ clock.advance(10 * time.Second)
+
+ warnings := newWarningSet(20)
+ cpu := collector.cpuUsage(clock.now(), warnings)
+ if cpu.TotalPercent != nil || cpu.PerCore != nil {
+ t.Fatalf("a wrapped counter must not produce a percentage: %+v", cpu)
+ }
+ if !containsString(warnings.list(), WarningCPUCounterReset) {
+ t.Fatalf("expected a counter reset warning, got %v", warnings.list())
+ }
+}
+
+func TestCPUUsageKeepsTotalWhenCoreCountChanges(t *testing.T) {
+ root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
+ collector, clock := newTestCollector(t, Options{ProcRoot: root})
+ collector.cpuUsage(clock.now(), newWarningSet(20))
+
+ writeFile(t, filepath.Join(root, "stat"), strings.Join([]string{
+ "cpu 1234647 8901 234567 45679001 12365 0 6789 1234 5000 100",
+ "cpu0 617323 4450 117283 22839500 6182 0 3394 617 2500 50",
+ "cpu1 617324 4451 117284 22839501 6183 0 3395 617 2500 50",
+ "cpu2 0 0 0 0 0 0 0 0 0 0",
+ "",
+ }, "\n"))
+ clock.advance(10 * time.Second)
+
+ warnings := newWarningSet(20)
+ cpu := collector.cpuUsage(clock.now(), warnings)
+ if cpu.TotalPercent == nil {
+ t.Fatal("expected the aggregate to survive a topology change")
+ }
+ if cpu.PerCore != nil {
+ t.Fatalf("per core values are not comparable across a topology change: %v", cpu.PerCore)
+ }
+ if !containsString(warnings.list(), WarningCPUTopologyChanged) {
+ t.Fatalf("expected a topology warning, got %v", warnings.list())
+ }
+}
+
+func TestCPUUsageTruncatesToCoreLimit(t *testing.T) {
+ root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
+ collector, clock := newTestCollector(t, Options{ProcRoot: root, HostLimits: host.Limits{MaxCores: 1, MaxFilesystems: 4, MaxInterfaces: 4, MaxWarnings: 5}})
+ collector.cpuUsage(clock.now(), newWarningSet(20))
+
+ writeFile(t, filepath.Join(root, "stat"), strings.Join([]string{
+ "cpu 1234647 8901 234567 45679001 12365 0 6789 1234 5000 100",
+ "cpu0 617323 4450 117283 22839500 6182 0 3394 617 2500 50",
+ "cpu1 617324 4451 117284 22839501 6183 0 3395 617 2500 50",
+ "",
+ }, "\n"))
+ clock.advance(10 * time.Second)
+
+ warnings := newWarningSet(20)
+ cpu := collector.cpuUsage(clock.now(), warnings)
+ if len(cpu.PerCore) != 1 {
+ t.Fatalf("per core = %v, want one entry", cpu.PerCore)
+ }
+ if !containsString(warnings.list(), WarningCPUCoresTruncated) {
+ t.Fatalf("expected a truncation warning, got %v", warnings.list())
+ }
+}
+
+func TestCPUUsageDegradesWhenProcStatIsUnreadable(t *testing.T) {
+ root := t.TempDir()
+ collector, clock := newTestCollector(t, Options{ProcRoot: root})
+ warnings := newWarningSet(20)
+ if cpu := collector.cpuUsage(clock.now(), warnings); cpu.TotalPercent != nil {
+ t.Fatalf("unexpected utilisation: %+v", cpu)
+ }
+ if !containsString(warnings.list(), WarningCPUUnavailable) {
+ t.Fatalf("expected an unavailable warning, got %v", warnings.list())
+ }
+}
+
+func TestNetworkSkipsLoopbackAndReadsOperationalState(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{})
+ warnings := newWarningSet(20)
+ interfaces := collector.network(warnings)
+ if len(interfaces) != 2 {
+ t.Fatalf("interfaces = %+v, want br0 and eth0", interfaces)
+ }
+ if interfaces[0].Name != "br0" || interfaces[1].Name != "eth0" {
+ t.Fatalf("interfaces are not sorted: %+v", interfaces)
+ }
+ if interfaces[1].State != "up" {
+ t.Fatalf("eth0 state = %q, want up", interfaces[1].State)
+ }
+ if interfaces[0].State != "unknown" {
+ t.Fatalf("br0 state = %q", interfaces[0].State)
+ }
+}
+
+func TestNetworkTruncatesToInterfaceLimit(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{HostLimits: host.Limits{MaxCores: 8, MaxFilesystems: 8, MaxInterfaces: 1, MaxWarnings: 5}})
+ warnings := newWarningSet(20)
+ if interfaces := collector.network(warnings); len(interfaces) != 1 {
+ t.Fatalf("interfaces = %+v, want one", interfaces)
+ }
+ if !containsString(warnings.list(), WarningNetworkTruncated) {
+ t.Fatalf("expected a truncation warning, got %v", warnings.list())
+ }
+}
+
+func TestFilesystemsAreDisabledWithoutAnExplicitRoot(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{StatFS: func(string) (FilesystemUsage, error) {
+ t.Fatal("statfs must not be called when no filesystem root is configured")
+ return FilesystemUsage{}, nil
+ }})
+ warnings := newWarningSet(20)
+ if filesystems := collector.filesystems(warnings); filesystems != nil {
+ t.Fatalf("expected no filesystems, got %+v", filesystems)
+ }
+ if !containsString(warnings.list(), WarningFilesystemDisabled) {
+ t.Fatalf("expected the disabled warning, got %v", warnings.list())
+ }
+}
+
+func TestFilesystemsSkipPseudoMountsAndSurviveStatFailures(t *testing.T) {
+ requested := []string{}
+ collector, _ := newTestCollector(t, Options{
+ FilesystemRoot: "/host/root",
+ StatFS: func(path string) (FilesystemUsage, error) {
+ path = filepath.ToSlash(path)
+ requested = append(requested, path)
+ if strings.HasSuffix(path, "/mnt/disk2") {
+ return FilesystemUsage{}, errors.New("stale nfs handle")
+ }
+ if strings.HasSuffix(path, "/boot") {
+ return FilesystemUsage{CapacityBytes: 0}, nil
+ }
+ return FilesystemUsage{CapacityBytes: 1000, UsedBytes: 400, InodesTotal: 100, InodesUsed: 40}, nil
+ },
+ })
+ warnings := newWarningSet(20)
+ filesystems := collector.filesystems(warnings)
+
+ mounts := make([]string, 0, len(filesystems))
+ for _, item := range filesystems {
+ mounts = append(mounts, item.Mount)
+ }
+ want := []string{"/", "/mnt/cache", "/mnt/disk1", "/mnt/disks/Media Backup", "/mnt/user"}
+ if strings.Join(mounts, ",") != strings.Join(want, ",") {
+ t.Fatalf("mounts = %v, want %v", mounts, want)
+ }
+ for _, path := range requested {
+ if !strings.HasPrefix(path, "/host/root") {
+ t.Fatalf("statfs called outside the configured root: %q", path)
+ }
+ }
+ if !containsString(warnings.list(), WarningFilesystemPartial) {
+ t.Fatalf("expected a partial warning, got %v", warnings.list())
+ }
+ if filesystems[0].Inodes == nil || filesystems[0].Inodes.Used != 40 {
+ t.Fatalf("inodes missing: %+v", filesystems[0])
+ }
+}
+
+func TestFilesystemsTruncateToLimit(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{
+ FilesystemRoot: "/",
+ HostLimits: host.Limits{MaxCores: 8, MaxFilesystems: 2, MaxInterfaces: 8, MaxWarnings: 5},
+ StatFS: func(string) (FilesystemUsage, error) {
+ return FilesystemUsage{CapacityBytes: 1000, UsedBytes: 100}, nil
+ },
+ })
+ warnings := newWarningSet(20)
+ if filesystems := collector.filesystems(warnings); len(filesystems) != 2 {
+ t.Fatalf("filesystems = %d, want 2", len(filesystems))
+ }
+ if !containsString(warnings.list(), WarningFilesystemTruncated) {
+ t.Fatalf("expected a truncation warning, got %v", warnings.list())
+ }
+}
+
+func TestHostSnapshotIsAcceptedByTheDomain(t *testing.T) {
+ collector, clock := newTestCollector(t, Options{
+ FilesystemRoot: "/",
+ StatFS: func(string) (FilesystemUsage, error) {
+ return FilesystemUsage{CapacityBytes: 4000, UsedBytes: 2500, InodesTotal: 200, InodesUsed: 30}, nil
+ },
+ })
+ raw, err := collector.Host(context.Background())
+ if err != nil {
+ t.Fatalf("Host returned error: %v", err)
+ }
+ if raw.Identity.Name != "tower" || raw.Identity.Kernel != "6.1.79-Unraid" {
+ t.Fatalf("unexpected identity: %+v", raw.Identity)
+ }
+ if raw.UptimeSeconds != 351282.31 {
+ t.Fatalf("uptime = %v", raw.UptimeSeconds)
+ }
+ if raw.BootTime == nil || !raw.BootTime.Equal(clock.now().Add(-time.Duration(351282.31*float64(time.Second)))) {
+ t.Fatalf("boot time = %v", raw.BootTime)
+ }
+ if raw.Memory.TotalBytes == 0 || raw.Memory.AvailableBytes == 0 {
+ t.Fatalf("memory not collected: %+v", raw.Memory)
+ }
+ if raw.Load.One != 1.52 {
+ t.Fatalf("load = %+v", raw.Load)
+ }
+ if !raw.Time.Synchronized {
+ t.Fatalf("time = %+v", raw.Time)
+ }
+ if !containsString(raw.Warnings, WarningCPUFirstSample) {
+ t.Fatalf("expected the first sample warning, got %v", raw.Warnings)
+ }
+ // The collector's output must pass domain normalization unchanged; that is the
+ // contract the API reads through.
+ if _, err := host.Normalize(raw, clock.now(), host.Limits{}, host.Policy{}); err != nil {
+ t.Fatalf("host.Normalize rejected the collected snapshot: %v", err)
+ }
+ payload, err := json.Marshal(raw)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if len(payload) == 0 {
+ t.Fatal("empty payload")
+ }
+}
+
+func TestHostFailsWhenRequiredSourcesAreMalformed(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{
+ ProcRoot: filepath.Join("testdata", "proc-messy"),
+ SysRoot: filepath.Join("testdata", "sys-healthy"),
+ })
+ if _, err := collector.Host(context.Background()); err == nil {
+ t.Fatal("expected a malformed uptime to fail the whole host collection")
+ }
+}
+
+func TestHostDegradesLoadAndClockIntoWarnings(t *testing.T) {
+ root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
+ writeFile(t, filepath.Join(root, "loadavg"), "broken\n")
+ collector, clock := newTestCollector(t, Options{
+ ProcRoot: root,
+ ClockSync: func() (host.RawTime, error) { return host.RawTime{}, errors.New("operation not permitted") },
+ })
+ raw, err := collector.Host(context.Background())
+ if err != nil {
+ t.Fatalf("Host returned error: %v", err)
+ }
+ if !containsString(raw.Warnings, WarningLoadUnavailable) || !containsString(raw.Warnings, WarningClockProbeAssumed) {
+ t.Fatalf("warnings = %v", raw.Warnings)
+ }
+ if !raw.Time.Synchronized {
+ t.Fatal("an unverifiable clock must not be reported as a confirmed clock fault")
+ }
+ if _, err := host.Normalize(raw, clock.now(), host.Limits{}, host.Policy{}); err != nil {
+ t.Fatalf("host.Normalize rejected the degraded snapshot: %v", err)
+ }
+}
+
+func TestHostHonoursContextCancellation(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{})
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := collector.Host(ctx); !errors.Is(err, context.Canceled) {
+ t.Fatalf("error = %v, want context.Canceled", err)
+ }
+}
+
+func TestHostNameOverrideWinsOverTheNamespaceHostname(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{
+ ProcRoot: filepath.Join("testdata", "proc-messy"),
+ HostName: "tower",
+ })
+ identity, err := collector.identity()
+ if err != nil {
+ t.Fatalf("identity returned error: %v", err)
+ }
+ if identity.Name != "tower" {
+ t.Fatalf("name = %q, want the override", identity.Name)
+ }
+}
+
+func TestNewRejectsUnsafeLimits(t *testing.T) {
+ if _, err := New(Options{HostLimits: host.Limits{MaxCores: 9999}}); err == nil {
+ t.Fatal("expected out of bounds host limits to be rejected")
+ }
+}
+
+func TestWarningSetIsDeduplicatedAndBounded(t *testing.T) {
+ warnings := newWarningSet(2)
+ warnings.add(WarningCPUUnavailable)
+ warnings.add(WarningCPUUnavailable)
+ warnings.add(WarningLoadUnavailable)
+ warnings.add(WarningNetworkUnavailable)
+ if list := warnings.list(); len(list) != 2 {
+ t.Fatalf("warnings = %v, want two", list)
+ }
+}
+
+func containsString(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/hostcollect/cpu.go b/internal/hostcollect/cpu.go
new file mode 100644
index 0000000..f4dcc0d
--- /dev/null
+++ b/internal/hostcollect/cpu.go
@@ -0,0 +1,180 @@
+package hostcollect
+
+import (
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/host"
+)
+
+// cpuTimes holds the three aggregates a utilisation delta needs, all in USER_HZ ticks.
+type cpuTimes struct {
+ total uint64
+ idle uint64
+ iowait uint64
+}
+
+type cpuSample struct {
+ at time.Time
+ all cpuTimes
+ cores []cpuTimes
+ valid bool
+}
+
+// parseProcStat reads the cpu aggregate and per-core lines of /proc/stat.
+//
+// Column layout (proc(5)): user nice system idle iowait irq softirq steal guest
+// guest_nice. guest and guest_nice are deliberately excluded from the total: the
+// kernel already counts guest time inside user and nice, so including them again
+// inflates the denominator and understates utilisation. Old kernels publish fewer
+// columns, so anything past idle is optional.
+func parseProcStat(data []byte) (cpuSample, error) {
+ sample := cpuSample{}
+ type indexedCore struct {
+ index int
+ times cpuTimes
+ }
+ cores := make([]indexedCore, 0, 8)
+ for _, line := range lines(data) {
+ if !strings.HasPrefix(line, "cpu") {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 5 {
+ continue
+ }
+ times, ok := parseCPUTimes(fields[1:])
+ if !ok {
+ continue
+ }
+ if fields[0] == "cpu" {
+ sample.all = times
+ sample.valid = true
+ continue
+ }
+ index, err := strconv.Atoi(strings.TrimPrefix(fields[0], "cpu"))
+ if err != nil || index < 0 {
+ continue
+ }
+ cores = append(cores, indexedCore{index: index, times: times})
+ }
+ if !sample.valid {
+ return cpuSample{}, errNoCPUAggregate
+ }
+ sort.Slice(cores, func(i, j int) bool { return cores[i].index < cores[j].index })
+ sample.cores = make([]cpuTimes, 0, len(cores))
+ for _, core := range cores {
+ sample.cores = append(sample.cores, core.times)
+ }
+ return sample, nil
+}
+
+// parseCPUTimes sums the first eight jiffy columns; a non-numeric column makes the
+// whole line untrustworthy.
+func parseCPUTimes(fields []string) (cpuTimes, bool) {
+ times := cpuTimes{}
+ limit := len(fields)
+ if limit > 8 {
+ limit = 8
+ }
+ for index := 0; index < limit; index++ {
+ value, ok := parseUint(fields[index])
+ if !ok {
+ return cpuTimes{}, false
+ }
+ times.total += value
+ switch index {
+ case 3:
+ times.idle = value
+ case 4:
+ times.iowait = value
+ }
+ }
+ return times, true
+}
+
+// cpuUsage samples /proc/stat and reports utilisation relative to the previous sample.
+// The first call after start (or after a counter reset) reports no percentages at all
+// rather than a fabricated value: an absolute counter carries no utilisation.
+func (c *Collector) cpuUsage(now time.Time, warnings *warningSet) host.RawCPU {
+ data, err := readLimited(c.procPath("stat"), maxFileBytes)
+ if err != nil {
+ warnings.add(WarningCPUUnavailable)
+ return host.RawCPU{}
+ }
+ current, err := parseProcStat(data)
+ if err != nil {
+ warnings.add(WarningCPUUnavailable)
+ return host.RawCPU{}
+ }
+ current.at = now
+
+ c.mu.Lock()
+ previous := c.cpu
+ c.cpu = ¤t
+ c.mu.Unlock()
+
+ if previous == nil || !previous.valid {
+ warnings.add(WarningCPUFirstSample)
+ return host.RawCPU{}
+ }
+ busy, iowait, ok := utilisation(previous.all, current.all)
+ if !ok {
+ warnings.add(WarningCPUCounterReset)
+ return host.RawCPU{}
+ }
+ result := host.RawCPU{TotalPercent: &busy, IOWaitPercent: &iowait}
+ if len(previous.cores) != len(current.cores) {
+ warnings.add(WarningCPUTopologyChanged)
+ return result
+ }
+ cores := make([]float64, 0, len(current.cores))
+ for index := range current.cores {
+ corePercent, _, coreOK := utilisation(previous.cores[index], current.cores[index])
+ if !coreOK {
+ warnings.add(WarningCPUCounterReset)
+ return result
+ }
+ cores = append(cores, corePercent)
+ }
+ if len(cores) > c.options.HostLimits.MaxCores {
+ cores = cores[:c.options.HostLimits.MaxCores]
+ warnings.add(WarningCPUCoresTruncated)
+ }
+ result.PerCore = cores
+ return result
+}
+
+// utilisation converts two counter readings into busy and iowait percentages. Any
+// counter going backwards means the counter wrapped or the source was replaced (a
+// container restart, a CPU hot-unplug, a fixture root swap); the delta is then
+// meaningless and is reported as unusable instead of as a huge or negative spike.
+func utilisation(previous, current cpuTimes) (busyPercent, iowaitPercent float64, ok bool) {
+ if current.total < previous.total || current.idle < previous.idle || current.iowait < previous.iowait {
+ return 0, 0, false
+ }
+ totalDelta := current.total - previous.total
+ if totalDelta == 0 {
+ return 0, 0, false
+ }
+ idleDelta := current.idle - previous.idle
+ iowaitDelta := current.iowait - previous.iowait
+ if idleDelta+iowaitDelta > totalDelta {
+ return 0, 0, false
+ }
+ busy := float64(totalDelta-idleDelta-iowaitDelta) / float64(totalDelta) * 100
+ iowait := float64(iowaitDelta) / float64(totalDelta) * 100
+ return clampPercent(busy), clampPercent(iowait), true
+}
+
+func clampPercent(value float64) float64 {
+ if value < 0 {
+ return 0
+ }
+ if value > 100 {
+ return 100
+ }
+ return value
+}
diff --git a/internal/hostcollect/errors.go b/internal/hostcollect/errors.go
new file mode 100644
index 0000000..88125d1
--- /dev/null
+++ b/internal/hostcollect/errors.go
@@ -0,0 +1,10 @@
+package hostcollect
+
+import "errors"
+
+var (
+ errNoCPUAggregate = errors.New("hostcollect: /proc/stat has no cpu aggregate line")
+ errNoMemTotal = errors.New("hostcollect: /proc/meminfo has no usable MemTotal")
+ errBadLoadAverage = errors.New("hostcollect: /proc/loadavg is malformed")
+ errBadUptime = errors.New("hostcollect: /proc/uptime is malformed")
+)
diff --git a/internal/hostcollect/filesystem.go b/internal/hostcollect/filesystem.go
new file mode 100644
index 0000000..5dae8c3
--- /dev/null
+++ b/internal/hostcollect/filesystem.go
@@ -0,0 +1,139 @@
+package hostcollect
+
+import (
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/itworx/pulse/internal/host"
+)
+
+// pseudoFilesystems never describe usable capacity; reporting them would fill the
+// filesystem list with kernel bookkeeping and push real volumes past the limit.
+var pseudoFilesystems = map[string]struct{}{
+ "autofs": {}, "bpf": {}, "binfmt_misc": {}, "cgroup": {}, "cgroup2": {},
+ "configfs": {}, "debugfs": {}, "devpts": {}, "devtmpfs": {}, "efivarfs": {},
+ "fuse.gvfsd-fuse": {}, "fusectl": {}, "hugetlbfs": {}, "mqueue": {}, "nsfs": {},
+ "overlay": {}, "proc": {}, "pstore": {}, "ramfs": {}, "rpc_pipefs": {},
+ "securityfs": {}, "selinuxfs": {}, "squashfs": {}, "sysfs": {}, "tmpfs": {},
+ "tracefs": {},
+}
+
+type mountEntry struct {
+ device string
+ mount string
+ fsType string
+}
+
+// parseMounts reads /proc/mounts. Fields are space separated and the kernel escapes
+// space, tab, newline and backslash inside the device and mount point as octal
+// sequences, so an Unraid share called "/mnt/user/Media Backup" arrives as
+// "/mnt/user/Media\040Backup" and must be unescaped before it can be used as a path.
+func parseMounts(data []byte) []mountEntry {
+ all := lines(data)
+ entries := make([]mountEntry, 0, len(all))
+ for _, line := range all {
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ continue
+ }
+ entries = append(entries, mountEntry{
+ device: unescapeMountField(fields[0]),
+ mount: unescapeMountField(fields[1]),
+ fsType: fields[2],
+ })
+ }
+ return entries
+}
+
+func unescapeMountField(value string) string {
+ if !strings.Contains(value, `\`) {
+ return value
+ }
+ var builder strings.Builder
+ builder.Grow(len(value))
+ for index := 0; index < len(value); index++ {
+ if value[index] == '\\' && index+3 < len(value) {
+ if decoded, err := strconv.ParseUint(value[index+1:index+4], 8, 8); err == nil {
+ builder.WriteByte(byte(decoded))
+ index += 3
+ continue
+ }
+ }
+ builder.WriteByte(value[index])
+ }
+ return builder.String()
+}
+
+// filesystems reports capacity and inode usage per real mount point.
+//
+// It is disabled unless FilesystemRoot is configured. Inside a container the host's
+// /proc/mounts lists host paths that do not exist in the container's mount namespace;
+// calling statfs on the same string would either fail or — worse, for "/" — silently
+// measure the container's own overlay and report it as the host's root filesystem.
+// Mounting a host root read-only and pointing FilesystemRoot at it is an explicit,
+// auditable decision rather than an accident of naming.
+func (c *Collector) filesystems(warnings *warningSet) []host.RawFilesystem {
+ if c.options.FilesystemRoot == "" {
+ warnings.add(WarningFilesystemDisabled)
+ return nil
+ }
+ data, err := readLimited(c.procPath("mounts"), maxFileBytes)
+ if err != nil {
+ warnings.add(WarningMountsUnavailable)
+ return nil
+ }
+ seen := make(map[string]struct{})
+ results := make([]host.RawFilesystem, 0, 16)
+ failures := 0
+ for _, entry := range parseMounts(data) {
+ if _, pseudo := pseudoFilesystems[entry.fsType]; pseudo {
+ continue
+ }
+ if entry.mount == "" || len(entry.mount) > 512 {
+ continue
+ }
+ if _, duplicate := seen[entry.mount]; duplicate {
+ continue
+ }
+ seen[entry.mount] = struct{}{}
+ usage, statErr := c.options.StatFS(filepath.Join(c.options.FilesystemRoot, entry.mount))
+ if statErr != nil {
+ failures++
+ continue
+ }
+ if usage.CapacityBytes == 0 {
+ continue
+ }
+ if usage.UsedBytes > usage.CapacityBytes {
+ usage.UsedBytes = usage.CapacityBytes
+ }
+ item := host.RawFilesystem{
+ Mount: entry.mount,
+ Filesystem: truncate(entry.fsType, 64),
+ CapacityBytes: usage.CapacityBytes,
+ UsedBytes: usage.UsedBytes,
+ }
+ if usage.InodesTotal > 0 {
+ used := usage.InodesUsed
+ if used > usage.InodesTotal {
+ used = usage.InodesTotal
+ }
+ item.Inodes = &host.RawInodes{Total: usage.InodesTotal, Used: used}
+ }
+ results = append(results, item)
+ }
+ if failures > 0 {
+ warnings.add(WarningFilesystemPartial)
+ }
+ sort.Slice(results, func(i, j int) bool { return results[i].Mount < results[j].Mount })
+ if len(results) > c.options.HostLimits.MaxFilesystems {
+ results = results[:c.options.HostLimits.MaxFilesystems]
+ warnings.add(WarningFilesystemTruncated)
+ }
+ if len(results) == 0 {
+ return nil
+ }
+ return results
+}
diff --git a/internal/hostcollect/loadavg.go b/internal/hostcollect/loadavg.go
new file mode 100644
index 0000000..570e982
--- /dev/null
+++ b/internal/hostcollect/loadavg.go
@@ -0,0 +1,36 @@
+package hostcollect
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/itworx/pulse/internal/host"
+)
+
+// parseLoadAverage reads the three load figures from /proc/loadavg. The remaining
+// fields (runnable/total tasks and last PID) are deliberately ignored: the domain has
+// no place for them and a process count from a PID namespace would be misleading.
+func parseLoadAverage(data []byte) (host.RawLoad, error) {
+ fields := strings.Fields(string(data))
+ if len(fields) < 3 {
+ return host.RawLoad{}, errBadLoadAverage
+ }
+ values := [3]float64{}
+ for index := 0; index < 3; index++ {
+ value, ok := parseFloat(fields[index])
+ if !ok || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
+ return host.RawLoad{}, errBadLoadAverage
+ }
+ values[index] = value
+ }
+ return host.RawLoad{One: values[0], Five: values[1], Fifteen: values[2]}, nil
+}
+
+func (c *Collector) loadAverage() (host.RawLoad, error) {
+ data, err := readLimited(c.procPath("loadavg"), maxProcessFileBytes)
+ if err != nil {
+ return host.RawLoad{}, fmt.Errorf("hostcollect: read loadavg: %w", err)
+ }
+ return parseLoadAverage(data)
+}
diff --git a/internal/hostcollect/memory.go b/internal/hostcollect/memory.go
new file mode 100644
index 0000000..daef7ef
--- /dev/null
+++ b/internal/hostcollect/memory.go
@@ -0,0 +1,71 @@
+package hostcollect
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/itworx/pulse/internal/host"
+)
+
+// parseMeminfo converts /proc/meminfo into bytes.
+//
+// meminfo is kB based ("MemTotal: 16316420 kB"), where kB means KiB, and a handful of
+// counters carry no unit at all. Treating either as bytes is the classic off-by-1024
+// bug, so the unit is read per line instead of assumed.
+func parseMeminfo(data []byte) (host.RawMemory, error) {
+ values := make(map[string]uint64, 8)
+ for _, line := range lines(data) {
+ key, rest, found := strings.Cut(line, ":")
+ if !found {
+ continue
+ }
+ fields := strings.Fields(rest)
+ if len(fields) == 0 {
+ continue
+ }
+ amount, ok := parseUint(fields[0])
+ if !ok {
+ continue
+ }
+ if len(fields) > 1 && strings.EqualFold(fields[1], "kB") {
+ const kib = 1024
+ if amount > (1<<64-1)/kib {
+ continue
+ }
+ amount *= kib
+ }
+ values[strings.TrimSpace(key)] = amount
+ }
+ total := values["MemTotal"]
+ if total == 0 {
+ return host.RawMemory{}, errNoMemTotal
+ }
+ available, ok := values["MemAvailable"]
+ if !ok {
+ // Kernels before 3.14 have no MemAvailable. The traditional approximation is
+ // free plus the reclaimable page cache; it is an estimate, never above total.
+ available = values["MemFree"] + values["Buffers"] + values["Cached"] + values["SReclaimable"]
+ }
+ if available > total {
+ available = total
+ }
+ memory := host.RawMemory{TotalBytes: total, AvailableBytes: available}
+ swapTotal := values["SwapTotal"]
+ if swapTotal > 0 {
+ swapFree := values["SwapFree"]
+ if swapFree > swapTotal {
+ swapFree = swapTotal
+ }
+ memory.SwapTotalBytes = swapTotal
+ memory.SwapUsedBytes = swapTotal - swapFree
+ }
+ return memory, nil
+}
+
+func (c *Collector) memory() (host.RawMemory, error) {
+ data, err := readLimited(c.procPath("meminfo"), maxFileBytes)
+ if err != nil {
+ return host.RawMemory{}, fmt.Errorf("hostcollect: read meminfo: %w", err)
+ }
+ return parseMeminfo(data)
+}
diff --git a/internal/hostcollect/network.go b/internal/hostcollect/network.go
new file mode 100644
index 0000000..e952555
--- /dev/null
+++ b/internal/hostcollect/network.go
@@ -0,0 +1,97 @@
+package hostcollect
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/itworx/pulse/internal/host"
+)
+
+// loopbackInterface is excluded: its counters describe the host talking to itself and
+// only add noise to a network overview.
+const loopbackInterface = "lo"
+
+// parseNetDev reads /proc/net/dev.
+//
+// The format is two header lines followed by " name: rx... tx...". The name is
+// separated by a colon that is NOT always followed by a space — a sufficiently large
+// rx byte counter runs straight into it ("eth0:18446744073709551615 ...") — so the
+// line is split on the first colon rather than on whitespace.
+func parseNetDev(data []byte) []host.RawNetworkInterface {
+ all := lines(data)
+ interfaces := make([]host.RawNetworkInterface, 0, len(all))
+ for _, line := range all {
+ name, rest, found := strings.Cut(line, ":")
+ name = strings.TrimSpace(name)
+ if !found || name == "" || strings.Contains(name, " ") {
+ continue
+ }
+ fields := strings.Fields(rest)
+ if len(fields) < 16 {
+ continue
+ }
+ values := make([]uint64, 16)
+ malformed := false
+ for index := 0; index < 16; index++ {
+ value, ok := parseUint(fields[index])
+ if !ok {
+ malformed = true
+ break
+ }
+ values[index] = value
+ }
+ if malformed {
+ continue
+ }
+ interfaces = append(interfaces, host.RawNetworkInterface{
+ Name: truncate(name, 128),
+ RxBytes: values[0],
+ RxErrors: values[2],
+ RxDrops: values[3],
+ TxBytes: values[8],
+ TxErrors: values[10],
+ TxDrops: values[11],
+ })
+ }
+ return interfaces
+}
+
+func (c *Collector) network(warnings *warningSet) []host.RawNetworkInterface {
+ data, err := readLimited(c.procPath("net", "dev"), maxFileBytes)
+ if err != nil {
+ warnings.add(WarningNetworkUnavailable)
+ return nil
+ }
+ parsed := parseNetDev(data)
+ interfaces := make([]host.RawNetworkInterface, 0, len(parsed))
+ for _, item := range parsed {
+ if item.Name == loopbackInterface {
+ continue
+ }
+ item.State = c.interfaceState(item.Name)
+ interfaces = append(interfaces, item)
+ }
+ sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name })
+ if len(interfaces) > c.options.HostLimits.MaxInterfaces {
+ interfaces = interfaces[:c.options.HostLimits.MaxInterfaces]
+ warnings.add(WarningNetworkTruncated)
+ }
+ if len(interfaces) == 0 {
+ return nil
+ }
+ return interfaces
+}
+
+// interfaceState reads the operational state sysfs publishes ("up", "down",
+// "unknown"). A missing file is normal for virtual interfaces and simply yields no
+// state rather than a warning.
+func (c *Collector) interfaceState(name string) string {
+ if strings.ContainsAny(name, "/\\") {
+ return ""
+ }
+ state, err := readTrimmed(c.sysPath("class", "net", name, "operstate"), 64)
+ if err != nil {
+ return ""
+ }
+ return truncate(state, 32)
+}
diff --git a/internal/hostcollect/parse_test.go b/internal/hostcollect/parse_test.go
new file mode 100644
index 0000000..0a2a049
--- /dev/null
+++ b/internal/hostcollect/parse_test.go
@@ -0,0 +1,199 @@
+package hostcollect
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func readFixture(t *testing.T, elements ...string) []byte {
+ t.Helper()
+ path := filepath.Join(append([]string{"testdata"}, elements...)...)
+ data, err := os.ReadFile(path) //nolint:gosec // fixture path is test-controlled
+ if err != nil {
+ t.Fatalf("read fixture %s: %v", path, err)
+ }
+ return data
+}
+
+func TestParseProcStatReadsAggregateAndCores(t *testing.T) {
+ sample, err := parseProcStat(readFixture(t, "proc-healthy", "stat"))
+ if err != nil {
+ t.Fatalf("parseProcStat returned error: %v", err)
+ }
+ // user+nice+system+idle+iowait+irq+softirq+steal, with guest and guest_nice left
+ // out because the kernel already counts guest time inside user and nice.
+ const wantTotal = 1234567 + 8901 + 234567 + 45678901 + 12345 + 0 + 6789 + 1234
+ if sample.all.total != wantTotal {
+ t.Fatalf("aggregate total = %d, want %d", sample.all.total, wantTotal)
+ }
+ if sample.all.idle != 45678901 || sample.all.iowait != 12345 {
+ t.Fatalf("unexpected idle/iowait: %+v", sample.all)
+ }
+ if len(sample.cores) != 2 {
+ t.Fatalf("cores = %d, want 2", len(sample.cores))
+ }
+}
+
+func TestParseProcStatToleratesOldKernelsAndGarbage(t *testing.T) {
+ sample, err := parseProcStat(readFixture(t, "proc-messy", "stat"))
+ if err != nil {
+ t.Fatalf("parseProcStat returned error: %v", err)
+ }
+ if sample.all.total != 1000+200+300+4000 {
+ t.Fatalf("unexpected total for a four column kernel: %d", sample.all.total)
+ }
+ // cpu0 and cpu2 parse; "cpu-bogus" and the non-numeric "cpu9" line do not.
+ if len(sample.cores) != 2 {
+ t.Fatalf("cores = %d, want 2", len(sample.cores))
+ }
+}
+
+func TestParseProcStatRequiresAggregate(t *testing.T) {
+ if _, err := parseProcStat([]byte("intr 1 2 3\nctxt 4\n")); err == nil {
+ t.Fatal("expected an error when /proc/stat has no cpu line")
+ }
+}
+
+func TestUtilisationComputesDeltaNotAbsoluteValue(t *testing.T) {
+ previous := cpuTimes{total: 1000, idle: 800, iowait: 100}
+ current := cpuTimes{total: 1200, idle: 900, iowait: 150}
+ busy, iowait, ok := utilisation(previous, current)
+ if !ok {
+ t.Fatal("expected a usable delta")
+ }
+ if busy != 25 {
+ t.Fatalf("busy = %v, want 25", busy)
+ }
+ if iowait != 25 {
+ t.Fatalf("iowait = %v, want 25", iowait)
+ }
+}
+
+func TestUtilisationRejectsCounterWrapAndStandstill(t *testing.T) {
+ cases := map[string]struct{ previous, current cpuTimes }{
+ "total wrapped": {cpuTimes{total: 18446744073709551000, idle: 10, iowait: 1}, cpuTimes{total: 400, idle: 20, iowait: 2}},
+ "idle wrapped": {cpuTimes{total: 1000, idle: 900, iowait: 10}, cpuTimes{total: 1100, idle: 5, iowait: 11}},
+ "iowait wrapped": {cpuTimes{total: 1000, idle: 800, iowait: 100}, cpuTimes{total: 1100, idle: 850, iowait: 4}},
+ "no elapsed": {cpuTimes{total: 1000, idle: 800, iowait: 100}, cpuTimes{total: 1000, idle: 800, iowait: 100}},
+ "idle exceeds": {cpuTimes{total: 1000, idle: 800, iowait: 100}, cpuTimes{total: 1010, idle: 900, iowait: 120}},
+ }
+ for name, testCase := range cases {
+ t.Run(name, func(t *testing.T) {
+ if _, _, ok := utilisation(testCase.previous, testCase.current); ok {
+ t.Fatal("expected the delta to be rejected as unusable")
+ }
+ })
+ }
+}
+
+func TestParseMeminfoConvertsKibibytes(t *testing.T) {
+ memory, err := parseMeminfo(readFixture(t, "proc-healthy", "meminfo"))
+ if err != nil {
+ t.Fatalf("parseMeminfo returned error: %v", err)
+ }
+ if memory.TotalBytes != 32819484*1024 {
+ t.Fatalf("total = %d, want %d", memory.TotalBytes, 32819484*1024)
+ }
+ if memory.AvailableBytes != 24680240*1024 {
+ t.Fatalf("available = %d", memory.AvailableBytes)
+ }
+ if memory.SwapTotalBytes != 8388604*1024 || memory.SwapUsedBytes != (8388604-8000000)*1024 {
+ t.Fatalf("unexpected swap: %+v", memory)
+ }
+}
+
+func TestParseMeminfoFallsBackWhenMemAvailableIsAbsent(t *testing.T) {
+ memory, err := parseMeminfo(readFixture(t, "proc-messy", "meminfo"))
+ if err != nil {
+ t.Fatalf("parseMeminfo returned error: %v", err)
+ }
+ want := uint64(123456+23456+2000000+100000) * 1024
+ if memory.AvailableBytes != want {
+ t.Fatalf("available = %d, want %d", memory.AvailableBytes, want)
+ }
+ if memory.SwapTotalBytes != 0 || memory.SwapUsedBytes != 0 {
+ t.Fatalf("swapless host reported swap: %+v", memory)
+ }
+}
+
+func TestParseMeminfoRequiresMemTotal(t *testing.T) {
+ if _, err := parseMeminfo([]byte("MemFree: 100 kB\n")); err == nil {
+ t.Fatal("expected an error without MemTotal")
+ }
+}
+
+func TestParseLoadAverage(t *testing.T) {
+ load, err := parseLoadAverage(readFixture(t, "proc-healthy", "loadavg"))
+ if err != nil {
+ t.Fatalf("parseLoadAverage returned error: %v", err)
+ }
+ if load.One != 1.52 || load.Five != 2.08 || load.Fifteen != 2.35 {
+ t.Fatalf("unexpected load: %+v", load)
+ }
+ for _, malformed := range []string{"not-a-load\n", "1.0 2.0\n", "-1 2 3\n", ""} {
+ if _, err := parseLoadAverage([]byte(malformed)); err == nil {
+ t.Fatalf("expected an error for %q", malformed)
+ }
+ }
+}
+
+func TestParseUptime(t *testing.T) {
+ seconds, err := parseUptime(readFixture(t, "proc-healthy", "uptime"))
+ if err != nil {
+ t.Fatalf("parseUptime returned error: %v", err)
+ }
+ if seconds != 351282.31 {
+ t.Fatalf("uptime = %v", seconds)
+ }
+ for _, malformed := range []string{"", "nonsense\n", "-5 10\n", "999999999999 1\n"} {
+ if _, err := parseUptime([]byte(malformed)); err == nil {
+ t.Fatalf("expected an error for %q", malformed)
+ }
+ }
+}
+
+func TestParseNetDevHandlesMissingSpaceAfterColon(t *testing.T) {
+ interfaces := parseNetDev(readFixture(t, "proc-healthy", "net", "dev"))
+ byName := map[string]uint64{}
+ for _, item := range interfaces {
+ byName[item.Name] = item.RxBytes
+ }
+ // eth0's receive counter runs straight into the colon in the fixture.
+ if byName["eth0"] != 18446744073709551615 {
+ t.Fatalf("eth0 rx = %d", byName["eth0"])
+ }
+ if _, present := byName["wlan0"]; present {
+ t.Fatal("a row with a non-numeric counter must be dropped, not zeroed")
+ }
+ if _, present := byName["tap0"]; present {
+ t.Fatal("a truncated row must be dropped")
+ }
+ if len(interfaces) != 3 {
+ t.Fatalf("interfaces = %d, want lo, eth0 and br0", len(interfaces))
+ }
+ for _, item := range interfaces {
+ if item.Name == "eth0" && (item.RxErrors != 12 || item.RxDrops != 3 || item.TxErrors != 1 || item.TxDrops != 7) {
+ t.Fatalf("unexpected eth0 error counters: %+v", item)
+ }
+ }
+}
+
+func TestParseMountsUnescapesOctalSequences(t *testing.T) {
+ entries := parseMounts(readFixture(t, "proc-healthy", "mounts"))
+ found := false
+ for _, entry := range entries {
+ if entry.mount == "/mnt/disks/Media Backup" {
+ found = true
+ if entry.fsType != "btrfs" {
+ t.Fatalf("unexpected fs type: %q", entry.fsType)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected the escaped mount point to be decoded")
+ }
+ if len(entries) != 12 {
+ t.Fatalf("entries = %d, want 12", len(entries))
+ }
+}
diff --git a/internal/hostcollect/process.go b/internal/hostcollect/process.go
new file mode 100644
index 0000000..4bd2a6d
--- /dev/null
+++ b/internal/hostcollect/process.go
@@ -0,0 +1,352 @@
+package hostcollect
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/itworx/pulse/internal/process"
+)
+
+// maxProcessCPUPercent matches the bound internal/process enforces. A busy multi-core
+// task legitimately exceeds 100%.
+const maxProcessCPUPercent = 10000
+
+// processKey identifies a task across scans. The PID alone is not enough: PIDs are
+// reused, and charging a new process with the CPU time of the dead one it replaced
+// would show a fresh task at an absurd utilisation. The kernel's start time makes the
+// identity stable.
+type processKey struct {
+ pid int
+ startTime uint64
+}
+
+type processCPUSample struct {
+ at time.Time
+ ticks uint64
+}
+
+// processStat is the subset of /proc//stat the inventory needs.
+type processStat struct {
+ pid int
+ comm string
+ state string
+ ticks uint64
+ startTime uint64
+ rssPages uint64
+}
+
+func (s processStat) key() processKey { return processKey{pid: s.pid, startTime: s.startTime} }
+
+// parseProcessStat reads /proc//stat.
+//
+// The second field is the executable name in parentheses and it is neither escaped nor
+// quoted: a process can legitimately be called ") (" or "my app (2)". Splitting the
+// line on whitespace therefore corrupts every field after it. The parser instead cuts
+// at the LAST closing parenthesis, which is the only reliable delimiter.
+func parseProcessStat(data []byte, pid int) (processStat, error) {
+ text := string(data)
+ open := strings.IndexByte(text, '(')
+ closing := strings.LastIndexByte(text, ')')
+ if open < 0 || closing < open {
+ return processStat{}, errors.New("hostcollect: process stat has no comm field")
+ }
+ comm := text[open+1 : closing]
+ fields := strings.Fields(text[closing+1:])
+ // Field 3 (state) becomes index 0 here; the inventory needs up to field 24 (rss).
+ const (
+ stateIndex = 0
+ utimeIndex = 11
+ stimeIndex = 12
+ startTimeIndex = 19
+ rssIndex = 21
+ )
+ if len(fields) <= rssIndex {
+ return processStat{}, errors.New("hostcollect: process stat is truncated")
+ }
+ utime, utimeOK := parseUint(fields[utimeIndex])
+ stime, stimeOK := parseUint(fields[stimeIndex])
+ startTime, startOK := parseUint(fields[startTimeIndex])
+ if !utimeOK || !stimeOK || !startOK {
+ return processStat{}, errors.New("hostcollect: process stat has malformed counters")
+ }
+ // rss is signed in the kernel's own printf but never negative in practice; a
+ // negative or malformed value degrades to zero rather than failing the process.
+ rss, _ := parseUint(fields[rssIndex])
+ return processStat{
+ pid: pid,
+ comm: sanitizeName(comm),
+ state: processState(fields[stateIndex]),
+ ticks: utime + stime,
+ startTime: startTime,
+ rssPages: rss,
+ }, nil
+}
+
+// processState expands the kernel's single-letter state so the UI does not have to.
+func processState(value string) string {
+ if value == "" {
+ return "unknown"
+ }
+ switch value[0] {
+ case 'R':
+ return "running"
+ case 'S':
+ return "sleeping"
+ case 'D':
+ return "uninterruptible"
+ case 'Z':
+ return "zombie"
+ case 'T':
+ return "stopped"
+ case 't':
+ return "tracing-stop"
+ case 'X', 'x':
+ return "dead"
+ case 'I':
+ return "idle"
+ case 'K':
+ return "wakekill"
+ case 'W':
+ return "waking"
+ case 'P':
+ return "parked"
+ default:
+ return "unknown"
+ }
+}
+
+// Processes reads the process inventory.
+//
+// A process that exits between readdir and open is the normal case, not an error: the
+// scan skips it and keeps going. Only a failure that invalidates the whole scan (no
+// uptime, unreadable procfs root) returns an error, because a half-empty inventory
+// published as complete would be worse than no inventory at all.
+func (c *Collector) Processes(ctx context.Context) (process.RawSnapshot, error) {
+ if c == nil {
+ return process.RawSnapshot{}, errors.New("hostcollect: collector is nil")
+ }
+ if err := ctx.Err(); err != nil {
+ return process.RawSnapshot{}, err
+ }
+ now := c.options.Now().UTC()
+ uptimeSeconds, err := c.uptimeSeconds()
+ if err != nil {
+ return process.RawSnapshot{}, err
+ }
+ entries, err := os.ReadDir(c.options.ProcRoot)
+ if err != nil {
+ return process.RawSnapshot{}, fmt.Errorf("hostcollect: list processes: %w", err)
+ }
+
+ stats := make([]processStat, 0, len(entries))
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return process.RawSnapshot{}, err
+ }
+ pid, ok := processID(entry.Name())
+ if !ok {
+ continue
+ }
+ data, readErr := readLimited(c.procPath(entry.Name(), "stat"), maxProcessFileBytes)
+ if readErr != nil {
+ continue
+ }
+ stat, parseErr := parseProcessStat(data, pid)
+ if parseErr != nil {
+ continue
+ }
+ stats = append(stats, stat)
+ }
+
+ percentages := c.cpuPercentages(now, stats)
+ rows := c.rank(stats, percentages)
+
+ processes := make([]process.RawProcess, 0, len(rows))
+ for _, stat := range rows {
+ if err := ctx.Err(); err != nil {
+ return process.RawSnapshot{}, err
+ }
+ row, ok := c.describe(stat, uptimeSeconds, percentages[stat.key()])
+ if !ok {
+ continue
+ }
+ processes = append(processes, row)
+ }
+ sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID })
+
+ return process.RawSnapshot{
+ Source: process.Source{
+ ID: "process",
+ Type: "agent",
+ CapabilityVersion: process.ContractVersion,
+ ObservedAt: now,
+ },
+ Processes: processes,
+ ObservedAt: now,
+ }, nil
+}
+
+// cpuPercentages turns accumulated CPU ticks into a utilisation percentage relative to
+// the previous scan and replaces the retained state with the current one. The map is
+// rebuilt from the live process list every scan, so it cannot grow without bound as
+// processes come and go.
+func (c *Collector) cpuPercentages(now time.Time, stats []processStat) map[processKey]float64 {
+ percentages := make(map[processKey]float64, len(stats))
+ current := make(map[processKey]processCPUSample, len(stats))
+
+ c.mu.Lock()
+ previous := c.processes
+ for _, stat := range stats {
+ key := stat.key()
+ current[key] = processCPUSample{at: now, ticks: stat.ticks}
+ sample, seen := previous[key]
+ if !seen {
+ continue
+ }
+ elapsed := now.Sub(sample.at).Seconds()
+ if elapsed <= 0 || stat.ticks < sample.ticks {
+ continue
+ }
+ percent := float64(stat.ticks-sample.ticks) / c.options.ClockTicks / elapsed * 100
+ if percent < 0 {
+ percent = 0
+ }
+ if percent > maxProcessCPUPercent {
+ percent = maxProcessCPUPercent
+ }
+ percentages[key] = percent
+ }
+ c.processes = current
+ c.mu.Unlock()
+
+ return percentages
+}
+
+// rank keeps the busiest processes when the inventory exceeds MaxRows. Truncating by
+// cost rather than by PID order keeps the rows an operator actually needs.
+func (c *Collector) rank(stats []processStat, percentages map[processKey]float64) []processStat {
+ ordered := append([]processStat(nil), stats...)
+ sort.SliceStable(ordered, func(i, j int) bool {
+ left, right := percentages[ordered[i].key()], percentages[ordered[j].key()]
+ if left != right {
+ return left > right
+ }
+ if ordered[i].rssPages != ordered[j].rssPages {
+ return ordered[i].rssPages > ordered[j].rssPages
+ }
+ return ordered[i].pid < ordered[j].pid
+ })
+ if len(ordered) > c.options.ProcessLimits.MaxRows {
+ ordered = ordered[:c.options.ProcessLimits.MaxRows]
+ }
+ return ordered
+}
+
+// describe fills in the fields that need a second read, for the retained rows only.
+//
+// Only the program name is taken from the command line, never the arguments: argv
+// routinely carries tokens, passwords and connection strings, and the snapshot is
+// stored and rendered. The read itself is bounded, so an enormous argv costs one page,
+// not a copy of the whole command line.
+func (c *Collector) describe(stat processStat, uptimeSeconds, cpuPercent float64) (process.RawProcess, bool) {
+ directory := strconv.Itoa(stat.pid)
+ name := stat.comm
+ if data, err := readLimited(c.procPath(directory, "cmdline"), maxCmdlineBytes); err == nil {
+ if argv0 := commandName(data); argv0 != "" {
+ name = argv0
+ }
+ }
+ if name == "" {
+ // Neither comm nor cmdline is usable — the task is gone or unreadable.
+ return process.RawProcess{}, false
+ }
+ memoryBytes := stat.rssPages * uint64(c.options.PageSize) //nolint:gosec // PageSize is validated positive
+ if rss, ok := c.residentBytes(directory); ok {
+ memoryBytes = rss
+ }
+ runtimeSeconds := uptimeSeconds - float64(stat.startTime)/c.options.ClockTicks
+ if runtimeSeconds < 0 || runtimeSeconds > maxUptimeSeconds {
+ runtimeSeconds = 0
+ }
+ return process.RawProcess{
+ PID: stat.pid,
+ Name: truncate(name, maxNameBytes),
+ State: stat.state,
+ RuntimeSeconds: runtimeSeconds,
+ CPUPercent: cpuPercent,
+ MemoryBytes: memoryBytes,
+ }, true
+}
+
+// residentBytes prefers VmRSS from /proc//status, which the kernel already
+// reports in kB and keeps consistent, over the page count in stat. Kernel threads have
+// no VmRSS at all, which is why the stat value remains the fallback.
+func (c *Collector) residentBytes(directory string) (uint64, bool) {
+ data, err := readLimited(c.procPath(directory, "status"), maxProcessFileBytes)
+ if err != nil {
+ return 0, false
+ }
+ for _, line := range lines(data) {
+ key, rest, found := strings.Cut(line, ":")
+ if !found || key != "VmRSS" {
+ continue
+ }
+ fields := strings.Fields(rest)
+ if len(fields) == 0 {
+ return 0, false
+ }
+ value, ok := parseUint(fields[0])
+ if !ok {
+ return 0, false
+ }
+ if len(fields) > 1 && strings.EqualFold(fields[1], "kB") {
+ const kib = 1024
+ if value > (1<<64-1)/kib {
+ return 0, false
+ }
+ value *= kib
+ }
+ return value, true
+ }
+ return 0, false
+}
+
+// commandName extracts the program name from a NUL separated command line.
+func commandName(data []byte) string {
+ text := string(data)
+ if index := strings.IndexByte(text, 0); index >= 0 {
+ text = text[:index]
+ }
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ""
+ }
+ return sanitizeName(filepath.Base(text))
+}
+
+// sanitizeName drops control characters so a hostile process name cannot inject
+// terminal escapes or NUL bytes into logs and stored snapshots.
+func sanitizeName(value string) string {
+ cleaned := strings.Map(func(r rune) rune {
+ if r < 0x20 || r == 0x7f {
+ return -1
+ }
+ return r
+ }, value)
+ return truncate(strings.TrimSpace(cleaned), maxNameBytes)
+}
+
+func processID(name string) (int, bool) {
+ pid, err := strconv.Atoi(name)
+ if err != nil || pid < 1 {
+ return 0, false
+ }
+ return pid, true
+}
diff --git a/internal/hostcollect/process_test.go b/internal/hostcollect/process_test.go
new file mode 100644
index 0000000..67b09cc
--- /dev/null
+++ b/internal/hostcollect/process_test.go
@@ -0,0 +1,261 @@
+package hostcollect
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/itworx/pulse/internal/process"
+)
+
+func processByPID(t *testing.T, snapshot process.RawSnapshot, pid int) process.RawProcess {
+ t.Helper()
+ for _, item := range snapshot.Processes {
+ if item.PID == pid {
+ return item
+ }
+ }
+ t.Fatalf("pid %d not found in %+v", pid, snapshot.Processes)
+ return process.RawProcess{}
+}
+
+func TestParseProcessStatHandlesParenthesesInTheProgramName(t *testing.T) {
+ stat, err := parseProcessStat(readFixture(t, "proc-healthy", "1234", "stat"), 1234)
+ if err != nil {
+ t.Fatalf("parseProcessStat returned error: %v", err)
+ }
+ if stat.comm != "my app) (2)" {
+ t.Fatalf("comm = %q; the parser must cut at the last closing parenthesis", stat.comm)
+ }
+ if stat.ticks != 100000+20000 {
+ t.Fatalf("ticks = %d", stat.ticks)
+ }
+ if stat.startTime != 5000 || stat.rssPages != 250000 {
+ t.Fatalf("unexpected stat: %+v", stat)
+ }
+ if stat.state != "sleeping" {
+ t.Fatalf("state = %q", stat.state)
+ }
+}
+
+func TestParseProcessStatRejectsMalformedInput(t *testing.T) {
+ cases := map[string][]byte{
+ "truncated": readFixture(t, "proc-healthy", "9999", "stat"),
+ "no comm": []byte("1 systemd S 0 1\n"),
+ "empty": {},
+ "bad number": []byte("1 (x) S 0 0 0 0 0 0 0 0 0 0 nan nan 0 0 0 0 0 0 0 0 0 0\n"),
+ }
+ for name, data := range cases {
+ t.Run(name, func(t *testing.T) {
+ if _, err := parseProcessStat(data, 1); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+ }
+}
+
+func TestProcessesCollectsInventoryAndSkipsUnreadableTasks(t *testing.T) {
+ collector, clock := newTestCollector(t, Options{PageSize: 4096})
+ snapshot, err := collector.Processes(context.Background())
+ if err != nil {
+ t.Fatalf("Processes returned error: %v", err)
+ }
+ if len(snapshot.Processes) != 5 {
+ t.Fatalf("processes = %d, want 5 (1, 2, 1234, 4567, 5555): %+v", len(snapshot.Processes), snapshot.Processes)
+ }
+ // 9999 has a truncated stat file; 3131 exists in the directory listing and even has
+ // a cmdline, but its stat is already gone — the task exited between readdir and
+ // open, which must cost one row and not the whole collection.
+ for _, pid := range []int{9999, 3131} {
+ for _, item := range snapshot.Processes {
+ if item.PID == pid {
+ t.Fatalf("pid %d must be skipped: malformed stat or a task that vanished mid-scan", pid)
+ }
+ }
+ }
+
+ init := processByPID(t, snapshot, 1)
+ if init.Name != "init" {
+ t.Fatalf("name = %q, want the command line's program name", init.Name)
+ }
+ if init.MemoryBytes != 12844*1024 {
+ t.Fatalf("memory = %d, want VmRSS from status", init.MemoryBytes)
+ }
+ if init.RuntimeSeconds < 351281 || init.RuntimeSeconds > 351283 {
+ t.Fatalf("runtime = %v", init.RuntimeSeconds)
+ }
+
+ kernelThread := processByPID(t, snapshot, 2)
+ if kernelThread.Name != "kthreadd" {
+ t.Fatalf("a kernel thread with an empty cmdline must fall back to comm, got %q", kernelThread.Name)
+ }
+ if kernelThread.MemoryBytes != 0 {
+ t.Fatalf("memory = %d, want 0 for a kernel thread", kernelThread.MemoryBytes)
+ }
+
+ // 5555 has a stat file but no status or cmdline: it exited between the two reads.
+ vanished := processByPID(t, snapshot, 5555)
+ if vanished.Name != "short-lived" || vanished.MemoryBytes != 500*4096 {
+ t.Fatalf("unexpected fallback for a task that vanished between reads: %+v", vanished)
+ }
+ if vanished.State != "zombie" {
+ t.Fatalf("state = %q", vanished.State)
+ }
+
+ if _, err := process.Normalize(snapshot, clock.now(), process.Limits{}); err != nil {
+ t.Fatalf("process.Normalize rejected the collected snapshot: %v", err)
+ }
+}
+
+func TestProcessesNeverCopyCommandLineArguments(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{})
+ snapshot, err := collector.Processes(context.Background())
+ if err != nil {
+ t.Fatalf("Processes returned error: %v", err)
+ }
+ payload, err := json.Marshal(snapshot)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ for _, secret := range []string{"SUPER-SECRET-VALUE", "hunter2", "--token", "--password", "virtio-net-pci"} {
+ if strings.Contains(string(payload), secret) {
+ t.Fatalf("snapshot leaked command line argument %q", secret)
+ }
+ }
+ weird := processByPID(t, snapshot, 1234)
+ if weird.Name != "weird app (2)" {
+ t.Fatalf("name = %q, want only the program name", weird.Name)
+ }
+ // The 29 KiB command line of pid 4567 is read bounded and reduced to its base name.
+ huge := processByPID(t, snapshot, 4567)
+ if huge.Name != "qemu-system-x86_64" {
+ t.Fatalf("name = %q", huge.Name)
+ }
+ if len(huge.Name) > maxNameBytes {
+ t.Fatalf("name length = %d", len(huge.Name))
+ }
+}
+
+func TestProcessesEnforceTheRowLimitByCost(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{ProcessLimits: process.Limits{MaxRows: 2, MaxPageSize: 10}})
+ snapshot, err := collector.Processes(context.Background())
+ if err != nil {
+ t.Fatalf("Processes returned error: %v", err)
+ }
+ if len(snapshot.Processes) != 2 {
+ t.Fatalf("processes = %d, want the limit of 2", len(snapshot.Processes))
+ }
+ // Without a previous sample every CPU percentage is zero, so the tie breaks on
+ // resident pages: the two largest tasks survive.
+ if snapshot.Processes[0].PID != 1234 || snapshot.Processes[1].PID != 4567 {
+ t.Fatalf("unexpected survivors: %+v", snapshot.Processes)
+ }
+}
+
+func TestProcessCPUPercentIsADeltaAndSurvivesPIDReuse(t *testing.T) {
+ root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
+ collector, clock := newTestCollector(t, Options{ProcRoot: root})
+
+ first, err := collector.Processes(context.Background())
+ if err != nil {
+ t.Fatalf("first scan: %v", err)
+ }
+ if processByPID(t, first, 1234).CPUPercent != 0 {
+ t.Fatal("a single sample cannot yield CPU utilisation")
+ }
+
+ // 500 extra ticks over 10 seconds at 100 USER_HZ is exactly 50% of one core.
+ writeFile(t, filepath.Join(root, "1234", "stat"),
+ "1234 (my app) (2)) S 1 1234 1234 0 -1 4194304 55555 0 12 0 100400 20100 0 0 20 0 8 0 5000 987654321 250000 0 1 1 0 0 0 0 0 0 0 0 0 0 17 5 0 0 0 0 0\n")
+ // pid 1 is replaced by a new task that reuses the PID: a different start time.
+ writeFile(t, filepath.Join(root, "1", "stat"),
+ "1 (systemd) S 0 1 1 0 -1 4194560 12345 678910 12 34 90456 789 1000 2000 20 0 1 0 99 172032000 3210 0 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0\n")
+ clock.advance(10 * time.Second)
+
+ second, err := collector.Processes(context.Background())
+ if err != nil {
+ t.Fatalf("second scan: %v", err)
+ }
+ if got := processByPID(t, second, 1234).CPUPercent; got < 49.9 || got > 50.1 {
+ t.Fatalf("cpu = %v, want 50", got)
+ }
+ if got := processByPID(t, second, 1).CPUPercent; got != 0 {
+ t.Fatalf("cpu = %v; a reused PID must not inherit the previous task's ticks", got)
+ }
+ if _, err := process.Normalize(second, clock.now(), process.Limits{}); err != nil {
+ t.Fatalf("process.Normalize rejected the snapshot: %v", err)
+ }
+}
+
+func TestProcessCPUPercentIgnoresBackwardsCounters(t *testing.T) {
+ root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
+ collector, clock := newTestCollector(t, Options{ProcRoot: root})
+ if _, err := collector.Processes(context.Background()); err != nil {
+ t.Fatalf("first scan: %v", err)
+ }
+ writeFile(t, filepath.Join(root, "1234", "stat"),
+ "1234 (my app) (2)) S 1 1234 1234 0 -1 4194304 55555 0 12 0 1 1 0 0 20 0 8 0 5000 987654321 250000 0 1 1 0 0 0 0 0 0 0 0 0 0 17 5 0 0 0 0 0\n")
+ clock.advance(10 * time.Second)
+
+ second, err := collector.Processes(context.Background())
+ if err != nil {
+ t.Fatalf("second scan: %v", err)
+ }
+ if got := processByPID(t, second, 1234).CPUPercent; got != 0 {
+ t.Fatalf("cpu = %v, want 0 for a counter that went backwards", got)
+ }
+}
+
+func TestProcessesRetainedStateStaysBounded(t *testing.T) {
+ root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
+ collector, clock := newTestCollector(t, Options{ProcRoot: root})
+ if _, err := collector.Processes(context.Background()); err != nil {
+ t.Fatalf("first scan: %v", err)
+ }
+ if err := os.RemoveAll(filepath.Join(root, "1234")); err != nil {
+ t.Fatalf("remove: %v", err)
+ }
+ clock.advance(10 * time.Second)
+ if _, err := collector.Processes(context.Background()); err != nil {
+ t.Fatalf("second scan: %v", err)
+ }
+ collector.mu.Lock()
+ retained := len(collector.processes)
+ collector.mu.Unlock()
+ if retained != 4 {
+ t.Fatalf("retained samples = %d, want only the live tasks", retained)
+ }
+}
+
+func TestProcessesFailWhenTheProcfsRootIsUnusable(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{ProcRoot: filepath.Join("testdata", "does-not-exist")})
+ if _, err := collector.Processes(context.Background()); err == nil {
+ t.Fatal("expected an error when the procfs root cannot be read")
+ }
+}
+
+func TestProcessesHonourContextCancellation(t *testing.T) {
+ collector, _ := newTestCollector(t, Options{})
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := collector.Processes(ctx); !errors.Is(err, context.Canceled) {
+ t.Fatalf("error = %v, want context.Canceled", err)
+ }
+}
+
+func TestSanitizeNameDropsControlCharacters(t *testing.T) {
+ if got := sanitizeName("bad\x00name\x1b[31m"); got != "badname[31m" {
+ t.Fatalf("sanitizeName = %q", got)
+ }
+ if got := commandName([]byte("/usr/bin/env\x00FOO=bar\x00")); got != "env" {
+ t.Fatalf("commandName = %q", got)
+ }
+ if got := commandName(nil); got != "" {
+ t.Fatalf("commandName = %q", got)
+ }
+}
diff --git a/internal/hostcollect/procfs.go b/internal/hostcollect/procfs.go
new file mode 100644
index 0000000..4fc0902
--- /dev/null
+++ b/internal/hostcollect/procfs.go
@@ -0,0 +1,67 @@
+package hostcollect
+
+import (
+ "io"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// readLimited reads at most limit bytes from path. Every /proc file the collector
+// touches is small, but /proc is a kernel interface and a bounded read keeps a
+// pathological or hostile file from becoming an unbounded allocation.
+func readLimited(path string, limit int64) ([]byte, error) {
+ file, err := os.Open(path) //nolint:gosec // paths are derived from the configured procfs root
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = file.Close() }()
+ data, err := io.ReadAll(io.LimitReader(file, limit))
+ if err != nil {
+ return nil, err
+ }
+ return data, nil
+}
+
+func readTrimmed(path string, limit int64) (string, error) {
+ data, err := readLimited(path, limit)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(data)), nil
+}
+
+// parseUint accepts the decimal unsigned values procfs uses and reports failure
+// instead of guessing, so a malformed line degrades one field rather than producing a
+// plausible-looking zero.
+func parseUint(value string) (uint64, bool) {
+ parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
+ if err != nil {
+ return 0, false
+ }
+ return parsed, true
+}
+
+func parseFloat(value string) (float64, bool) {
+ parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
+ if err != nil {
+ return 0, false
+ }
+ return parsed, true
+}
+
+func truncate(value string, max int) string {
+ if len(value) > max {
+ return value[:max]
+ }
+ return value
+}
+
+// lines splits procfs content without allocating a scanner per file.
+func lines(data []byte) []string {
+ text := strings.TrimRight(string(data), "\n")
+ if text == "" {
+ return nil
+ }
+ return strings.Split(text, "\n")
+}
diff --git a/internal/hostcollect/statfs_linux.go b/internal/hostcollect/statfs_linux.go
new file mode 100644
index 0000000..dabde26
--- /dev/null
+++ b/internal/hostcollect/statfs_linux.go
@@ -0,0 +1,39 @@
+//go:build linux
+
+package hostcollect
+
+import "syscall"
+
+// statFS reads capacity and inode usage for one mount point. statfs(2) is a read-only
+// syscall that needs no capability, which keeps the agent inside cap_drop: [ALL].
+//
+// "Used" follows df: total blocks minus free blocks, so the filesystem's reserved
+// blocks count as used rather than as available headroom the operator does not have.
+func statFS(path string) (FilesystemUsage, error) {
+ var stat syscall.Statfs_t
+ if err := syscall.Statfs(path, &stat); err != nil {
+ return FilesystemUsage{}, err
+ }
+ blockSize := uint64(stat.Bsize) //nolint:gosec,unconvert // Bsize is int64 on some arches, uint32 on others
+ if blockSize == 0 {
+ return FilesystemUsage{}, nil
+ }
+ blocks := stat.Blocks
+ free := stat.Bfree
+ if free > blocks {
+ free = blocks
+ }
+ usage := FilesystemUsage{
+ CapacityBytes: blocks * blockSize,
+ UsedBytes: (blocks - free) * blockSize,
+ InodesTotal: stat.Files,
+ }
+ if stat.Files > 0 {
+ freeInodes := stat.Ffree
+ if freeInodes > stat.Files {
+ freeInodes = stat.Files
+ }
+ usage.InodesUsed = stat.Files - freeInodes
+ }
+ return usage, nil
+}
diff --git a/internal/hostcollect/statfs_other.go b/internal/hostcollect/statfs_other.go
new file mode 100644
index 0000000..80cbea5
--- /dev/null
+++ b/internal/hostcollect/statfs_other.go
@@ -0,0 +1,11 @@
+//go:build !linux
+
+package hostcollect
+
+import "errors"
+
+// statFS is unavailable off Linux. The package still builds and its parsers stay
+// testable on a developer machine; filesystem usage simply degrades to a warning.
+func statFS(string) (FilesystemUsage, error) {
+ return FilesystemUsage{}, errors.New("hostcollect: statfs is only available on linux")
+}
diff --git a/internal/hostcollect/testdata/proc-healthy/1/cmdline b/internal/hostcollect/testdata/proc-healthy/1/cmdline
new file mode 100644
index 0000000000000000000000000000000000000000..b6da6de5162df16b3ed192c1ab216af6eb8947dd
GIT binary patch
literal 20
bcmdNdPRh*F&&