From db8093b60876a608cbdfa84637534628306d71c5 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Wed, 30 Sep 2026 01:14:48 +0000 Subject: [PATCH] Publish curated ITWorx-Pulse source from 0e0633c8ac91cb71312a26837f791035a78d81b8 --- .gitea/workflows/public-validation.yml | 9 +- CHANGELOG.md | 10 + PACKAGE_VERSION | 2 +- PUBLIC_SOURCE_EXPORT.md | 2 +- PUBLIC_SOURCE_MANIFEST.json | 382 ++++++++++++++---- README.md | 107 +++-- SECURITY.md | 2 +- apps/web/package.json | 2 +- apps/web/playwright.config.ts | 1 + apps/web/src/AlertOperationsPanel.tsx | 31 +- apps/web/src/AlertRulesPage.tsx | 35 +- apps/web/src/App.tsx | 160 +++----- apps/web/src/AppShell.tsx | 162 ++++++++ apps/web/src/ApplicationPage.tsx | 16 +- apps/web/src/DashboardEditor.tsx | 142 +++++-- apps/web/src/DashboardManagerPage.tsx | 76 ++++ apps/web/src/DashboardRuntimeWidget.tsx | 207 +++++++--- apps/web/src/EventsPage.tsx | 2 +- apps/web/src/ExploreMetricsPage.tsx | 130 ++++++ apps/web/src/IncidentPage.tsx | 96 ++++- apps/web/src/MetricWidgets.tsx | 47 ++- apps/web/src/OperatorCommandBar.tsx | 229 +++++++++++ apps/web/src/RouteErrorBoundary.tsx | 18 + apps/web/src/SettingsCapabilityPanel.tsx | 32 ++ apps/web/src/SettingsPage.tsx | 31 ++ apps/web/src/StoragePage.tsx | 17 +- apps/web/src/TopologyPage.tsx | 26 +- apps/web/src/WidgetConfigDrawer.tsx | 30 +- apps/web/src/apiClient.ts | 48 +++ apps/web/src/copy.ts | 95 ++++- apps/web/src/dashboard-manager.css | 39 ++ apps/web/src/dashboard-spatial-runtime.css | 9 + apps/web/src/dashboard-spatial.css | 7 + apps/web/src/dashboardClient.ts | 107 +++++ apps/web/src/dashboardEditorM15Copy.ts | 11 + apps/web/src/dashboardManagerCopy.ts | 3 + apps/web/src/dashboardSpatial.ts | 61 +++ apps/web/src/dashboardTemplates.ts | 120 ++++++ apps/web/src/liveBuffer.ts | 10 +- apps/web/src/main.tsx | 4 +- apps/web/src/metricClient.ts | 20 +- apps/web/src/operator-flow.css | 34 ++ apps/web/src/operator-shell.css | 57 +++ apps/web/src/operatorContext.ts | 148 +++++++ apps/web/src/operatorWorkspace.ts | 97 +++++ apps/web/src/routes.ts | 2 +- apps/web/src/sessionClient.ts | 33 ++ apps/web/src/styles.css | 96 ++++- apps/web/src/widgetCatalog.ts | 40 ++ apps/web/tests/e2e/accessibility.spec.ts | 29 +- apps/web/tests/e2e/alert-workspace.spec.ts | 6 + .../tests/e2e/dashboard-editor-polish.spec.ts | 25 +- .../web/tests/e2e/dashboard-lifecycle.spec.ts | 94 +++++ .../e2e/dashboard-widget-runtime.spec.ts | 40 ++ apps/web/tests/e2e/event-timeline.spec.ts | 4 +- apps/web/tests/e2e/explore-metrics.spec.ts | 47 +++ apps/web/tests/e2e/large-lists.spec.ts | 9 +- .../tests/e2e/management-workspace.spec.ts | 27 +- apps/web/tests/e2e/mobile-incident.spec.ts | 30 +- apps/web/tests/e2e/responsive-polish.spec.ts | 6 +- apps/web/tests/unit/AppOverview.test.tsx | 17 +- apps/web/tests/unit/DashboardCache.test.tsx | 6 +- apps/web/tests/unit/DashboardEditor.test.tsx | 71 ++++ .../tests/unit/DashboardManagerPage.test.tsx | 104 +++++ .../unit/DashboardRuntimeWidget.test.tsx | 54 +++ .../tests/unit/ExploreMetricsPage.test.tsx | 49 +++ apps/web/tests/unit/MetricWidgets.test.tsx | 18 +- .../tests/unit/RouteErrorBoundary.test.tsx | 15 + apps/web/tests/unit/apiClient.test.ts | 39 ++ apps/web/tests/unit/dashboardSpatial.test.ts | 23 ++ .../web/tests/unit/dashboardTemplates.test.ts | 17 + apps/web/tests/unit/historicalSamples.test.ts | 12 + apps/web/tests/unit/metricClient.test.ts | 27 ++ apps/web/tests/unit/operatorContext.test.ts | 40 ++ apps/web/tests/unit/operatorWorkspace.test.ts | 61 +++ apps/web/tests/unit/sessionClient.test.ts | 38 ++ apps/web/tests/unit/widgetCatalog.test.ts | 25 ++ cmd/api/main.go | 2 + docs/architecture/API_CONTRACT.md | 3 +- docs/screenshots/overview-demo.png | Bin 0 -> 92135 bytes internal/sessionapi/handler.go | 53 +++ internal/sessionapi/handler_test.go | 62 +++ package.json | 2 +- public-source.allowlist | 1 + scripts/integration-smoke.ps1 | 36 +- scripts/public-verify.ps1 | 1 + specs/api-routes.json | 1 + specs/widget-instance.schema.json | 15 +- 88 files changed, 3685 insertions(+), 469 deletions(-) create mode 100644 apps/web/src/AppShell.tsx create mode 100644 apps/web/src/DashboardManagerPage.tsx create mode 100644 apps/web/src/ExploreMetricsPage.tsx create mode 100644 apps/web/src/OperatorCommandBar.tsx create mode 100644 apps/web/src/RouteErrorBoundary.tsx create mode 100644 apps/web/src/SettingsCapabilityPanel.tsx create mode 100644 apps/web/src/SettingsPage.tsx create mode 100644 apps/web/src/apiClient.ts create mode 100644 apps/web/src/dashboard-manager.css create mode 100644 apps/web/src/dashboard-spatial-runtime.css create mode 100644 apps/web/src/dashboard-spatial.css create mode 100644 apps/web/src/dashboardClient.ts create mode 100644 apps/web/src/dashboardEditorM15Copy.ts create mode 100644 apps/web/src/dashboardManagerCopy.ts create mode 100644 apps/web/src/dashboardSpatial.ts create mode 100644 apps/web/src/dashboardTemplates.ts create mode 100644 apps/web/src/operator-flow.css create mode 100644 apps/web/src/operator-shell.css create mode 100644 apps/web/src/operatorContext.ts create mode 100644 apps/web/src/operatorWorkspace.ts create mode 100644 apps/web/src/sessionClient.ts create mode 100644 apps/web/src/widgetCatalog.ts create mode 100644 apps/web/tests/e2e/dashboard-lifecycle.spec.ts create mode 100644 apps/web/tests/e2e/dashboard-widget-runtime.spec.ts create mode 100644 apps/web/tests/e2e/explore-metrics.spec.ts create mode 100644 apps/web/tests/unit/DashboardEditor.test.tsx create mode 100644 apps/web/tests/unit/DashboardManagerPage.test.tsx create mode 100644 apps/web/tests/unit/DashboardRuntimeWidget.test.tsx create mode 100644 apps/web/tests/unit/ExploreMetricsPage.test.tsx create mode 100644 apps/web/tests/unit/RouteErrorBoundary.test.tsx create mode 100644 apps/web/tests/unit/apiClient.test.ts create mode 100644 apps/web/tests/unit/dashboardSpatial.test.ts create mode 100644 apps/web/tests/unit/dashboardTemplates.test.ts create mode 100644 apps/web/tests/unit/historicalSamples.test.ts create mode 100644 apps/web/tests/unit/metricClient.test.ts create mode 100644 apps/web/tests/unit/operatorContext.test.ts create mode 100644 apps/web/tests/unit/operatorWorkspace.test.ts create mode 100644 apps/web/tests/unit/sessionClient.test.ts create mode 100644 apps/web/tests/unit/widgetCatalog.test.ts create mode 100644 docs/screenshots/overview-demo.png create mode 100644 internal/sessionapi/handler.go create mode 100644 internal/sessionapi/handler_test.go diff --git a/.gitea/workflows/public-validation.yml b/.gitea/workflows/public-validation.yml index 6d16da4..c2ae659 100644 --- a/.gitea/workflows/public-validation.yml +++ b/.gitea/workflows/public-validation.yml @@ -14,7 +14,7 @@ concurrency: jobs: validate: if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }} - runs-on: ubuntu-latest + runs-on: linux-validation timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -22,13 +22,13 @@ jobs: with: go-version-file: go.mod cache: true + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: '10.33.0' - 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 @@ -42,6 +42,7 @@ jobs: "$validation_python" tools/check_api_contract.py "$validation_python" tools/validate_contracts.py "$validation_python" tools/check_wiring.py + "$validation_python" tools/check_release_truth.py "$validation_python" tools/check_secrets.py - run: bash deploy/verify-image-digests.sh - name: Export reviewed public source diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a99eb7..b1e1d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable user-facing changes are recorded here. Private deployment evidence and environment-specific rollout details are deliberately excluded. +## 1.6.0 + +- Reworked Pulse around domain-oriented operator navigation with breadcrumbs, contextual back navigation and mobile-first response routes. +- Added entity-aware command search across resources, alerts and incidents, plus browser-local favorites, saved views and recent context. +- Made Overview problems actionable and connected metric widgets, incidents and topology to bounded Explore investigation flows. +- Added dashboard lifecycle management, a spatial composer, a broader bounded widget runtime, visual widget selection and additional dashboard templates. +- Added guided alert templates, stronger incident ownership/acknowledgement flows and topology dependency/impact inspection. +- Turned Applications into workload hubs and Storage into a capacity cockpit with qualified forecasting while preserving explicit Unknown/stale/partial states. +- Added capability-aware Settings, route-level failure containment and additional operator-workspace regression coverage. + ## 1.5.0 - Added a mature Dutch operator interface with responsive overview, inventory, storage, service, alert, incident, settings, and wallboard flows. diff --git a/PACKAGE_VERSION b/PACKAGE_VERSION index bc80560..dc1e644 100644 --- a/PACKAGE_VERSION +++ b/PACKAGE_VERSION @@ -1 +1 @@ -1.5.0 +1.6.0 diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 637cfec..8d097b2 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,5 +1,5 @@ # Curated public source export -Generated from private canonical revision `abead7212b6a4120180d53388768a05bac4655b3`. +Generated from private canonical revision `0e0633c8ac91cb71312a26837f791035a78d81b8`. 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 index 2e18b94..958b1df 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "abead7212b6a4120180d53388768a05bac4655b3", + "sourceRevision": "0e0633c8ac91cb71312a26837f791035a78d81b8", "files": [ { "path": ".dockerignore", @@ -24,8 +24,8 @@ }, { "path": ".gitea/workflows/public-validation.yml", - "bytes": 2057, - "sha256": "b7cb4ddeaf8de09eb137d7fa7456ec99e72689d67a4dcb6e8d767f183db71178" + "bytes": 2120, + "sha256": "e8f42e40a25be6095ab3d88763f0500ced14d397ac234ccea2f6af8a2f2d5dc6" }, { "path": ".gitignore", @@ -40,12 +40,12 @@ { "path": "apps/web/package.json", "bytes": 900, - "sha256": "5b6dae0ca3800a557424dee5445a19d5ca93de3afd62eec17499ff550cb4534d" + "sha256": "dd64b2014b1c4c99f54515637aed8da6a68698de789d236490c4a47daf10dd79" }, { "path": "apps/web/playwright.config.ts", - "bytes": 1399, - "sha256": "0cacb3ad04697c9e8160bd8a0461d8d8df191f1050cb158c96ccc29b6e1790e6" + "bytes": 1442, + "sha256": "f4ffeed61fadd4e88ac8c56d87209fb2276cd90d40feb495ada8a890ce464b13" }, { "path": "apps/web/public/pulse-icon.svg", @@ -59,23 +59,33 @@ }, { "path": "apps/web/src/AlertOperationsPanel.tsx", - "bytes": 7043, - "sha256": "e44d4c0c83d057cc1e5c6f1f653fac95e8bf3637b413e78c31d87c01a62072ae" + "bytes": 9058, + "sha256": "509b4d780615b983d085a237c947ced217966a3ec3ed960d8d8258f003bf803a" }, { "path": "apps/web/src/AlertRulesPage.tsx", - "bytes": 22530, - "sha256": "8232f652ffcd93ec4b7f207fb17eba6233162f3c0edc63af83c0fe960da41bdb" + "bytes": 26528, + "sha256": "cfe2c0eab9a46943e371ed67240fa51b9086b89a7d70a83fd81a0f27d5092fa8" + }, + { + "path": "apps/web/src/apiClient.ts", + "bytes": 2547, + "sha256": "9e353f0a9776b6859b271820db9d33daaf4f0c3cfc8831d0960a05297d40dec6" }, { "path": "apps/web/src/App.tsx", - "bytes": 80954, - "sha256": "3df1d18ec0e66ebe06bfb70c8f8460bf7216b036ad46c47509fb7bad842de262" + "bytes": 72381, + "sha256": "3356e5454cca7f2a48369ce2502a2e0f4a3359ff14e2a586a4e667febb049ddf" }, { "path": "apps/web/src/ApplicationPage.tsx", - "bytes": 6061, - "sha256": "766634dd8f9aa401adbf3d93705b4be5e40f44d61573fa8cc384af0507d91fe8" + "bytes": 8350, + "sha256": "694f6f506809c786af15ad2332bfc811a235a4188e22b002ca8f0f3632960b78" + }, + { + "path": "apps/web/src/AppShell.tsx", + "bytes": 13125, + "sha256": "c2713c6f6719d324a38bdbce548a2ed451ba6878c19c21adf8abc82431504d8b" }, { "path": "apps/web/src/ArrayPage.tsx", @@ -99,24 +109,69 @@ }, { "path": "apps/web/src/copy.ts", - "bytes": 68847, - "sha256": "2fcd7cef068486551f6a8b571a90a554a83a3250265c793c6ba61fc0616becec" + "bytes": 73352, + "sha256": "132b60c96d1b3895dc3fec1997f426712a8ffd35f97ca4fb467fcfcb4f38ac9f" + }, + { + "path": "apps/web/src/dashboard-manager.css", + "bytes": 5373, + "sha256": "41bd7b0f6b7e61b06b5ebaed9cd8efc7f9d1fada3663fdbfee84f6c066348eba" + }, + { + "path": "apps/web/src/dashboard-spatial-runtime.css", + "bytes": 1170, + "sha256": "2e3f83b29c67df41d3968a5764fa1700b61e03d8ac706abde3e0270b5ede8293" + }, + { + "path": "apps/web/src/dashboard-spatial.css", + "bytes": 922, + "sha256": "7fb0cd46624235afe423a5d7dee0dcfb20ac377d5ac064203ec1e9c4049afc92" + }, + { + "path": "apps/web/src/dashboardClient.ts", + "bytes": 5292, + "sha256": "2cef01a2f21967b5a33667071101c65a830ac054de1653328ec9969430d18ab0" }, { "path": "apps/web/src/DashboardEditor.tsx", - "bytes": 24553, - "sha256": "2f59d5a4fdd404ab0e483ff9a74f7915df3adeec698459f03d6971d2d30b71fb" + "bytes": 30803, + "sha256": "497a8961f705942f823cd812aef8d7dd07dbc93ff8c8b67545a082c3f5694602" + }, + { + "path": "apps/web/src/dashboardEditorM15Copy.ts", + "bytes": 647, + "sha256": "73e69482f4cf99a7e34a57ce487a3d0cf2dd1b26801ac76bcbdd085a439464e5" + }, + { + "path": "apps/web/src/dashboardManagerCopy.ts", + "bytes": 2326, + "sha256": "f5a6663b6c20e3c4c1807ffb1d273526e68dd7ff0eee610bf3c50e1cf4f84459" + }, + { + "path": "apps/web/src/DashboardManagerPage.tsx", + "bytes": 12599, + "sha256": "96d3b2792a5943761af3d3f83a89e12fc07dc9daf27033ec9363708ac6748cd2" }, { "path": "apps/web/src/DashboardRuntimeWidget.tsx", - "bytes": 11733, - "sha256": "afcc31e9d15792a0af3155efa72ceabbb4e69fdc1e076dee65c300148ba82245" + "bytes": 21591, + "sha256": "33023e2224a6e1116b14e1f5f700be857daa8de2ad1fe1cd8d9839b2c8e9f77d" }, { "path": "apps/web/src/dashboardScope.ts", "bytes": 1109, "sha256": "d1c845a4861972ed07eceb7cad8e21e932f5d4a1712b0bfaaeb8a8a42dc13394" }, + { + "path": "apps/web/src/dashboardSpatial.ts", + "bytes": 3450, + "sha256": "619f3940e8529d3d9c5daf9fa4d6e322c5adc64f9586ce7fef86d65d3e998398" + }, + { + "path": "apps/web/src/dashboardTemplates.ts", + "bytes": 11949, + "sha256": "1316759bfdb1a94c7bd474a070d6cce2d8ca885441eec66f08aa223b65ed5e4c" + }, { "path": "apps/web/src/DashboardTransfer.tsx", "bytes": 4629, @@ -134,8 +189,13 @@ }, { "path": "apps/web/src/EventsPage.tsx", - "bytes": 9585, - "sha256": "cebd5cf96501fe351b8d9c1c8d28878e46ccf9e56e55654e88a21f0ef6122395" + "bytes": 9556, + "sha256": "8cb52052fbd628e1081c7b187d8c1ff57607813226cc95b61e770a529ad686fa" + }, + { + "path": "apps/web/src/ExploreMetricsPage.tsx", + "bytes": 14301, + "sha256": "9cadc51ac373aa8d4092b69c92f0eb933208f8f68071a0d8c08cd1f86a25ca7f" }, { "path": "apps/web/src/HostPage.tsx", @@ -144,8 +204,8 @@ }, { "path": "apps/web/src/IncidentPage.tsx", - "bytes": 10748, - "sha256": "b458bd558072f51a6f3495d0773c7e8a06d87f20f64b784f46b6612fdccf65aa" + "bytes": 18851, + "sha256": "69221a476717130847bfd38d72ceeeaee028d64c7890a1c20c3c4317b689a9d0" }, { "path": "apps/web/src/InventoryPage.tsx", @@ -159,8 +219,8 @@ }, { "path": "apps/web/src/liveBuffer.ts", - "bytes": 6399, - "sha256": "3321274415d90f6340f4a54f7167e0b3fee1553ad5b6eb7962ad0e9e8c3192d0" + "bytes": 6546, + "sha256": "d02ebb898604878f796b5caeac6680a32a8afb7b07ebb48478e06e8a2f42b4a0" }, { "path": "apps/web/src/liveClient.ts", @@ -174,18 +234,18 @@ }, { "path": "apps/web/src/main.tsx", - "bytes": 232, - "sha256": "055735c1f2a805ceeca8a1376b884c7a8ae1b9d69ad42753b2d23b4e277bfebb" + "bytes": 334, + "sha256": "c067554e9f788493dea29d0c6bd0ab061354e75921b5c16a25493a62acb5d100" }, { "path": "apps/web/src/metricClient.ts", - "bytes": 3443, - "sha256": "9210889b9083ad7badcf0d1b16ea7f7e8f498b0c91a9ab0a60f5c28e6eb078bb" + "bytes": 4345, + "sha256": "c410952b4b4b6a9ddfa3c97604fee4dd71116a8ddc8e6a47401839cf263fa63f" }, { "path": "apps/web/src/MetricWidgets.tsx", - "bytes": 21735, - "sha256": "8b367cc1aeed5bb170dc74a48e8c6f7e595fe0117ad8e2a4dcabc8ce028d58d0" + "bytes": 26447, + "sha256": "ac88167a9a5155251f2cafd8fb5d19e2c98a57331c995097521211d762bcfec2" }, { "path": "apps/web/src/NetworkPage.tsx", @@ -207,6 +267,31 @@ "bytes": 4433, "sha256": "4806735150b7630ac159ab21bd8604abb24a54b791ab7894164f00e3c26c3eda" }, + { + "path": "apps/web/src/operator-flow.css", + "bytes": 5245, + "sha256": "acc44711a38175d2d748521ffebf6b8bce9c42f623abd5412c8aaf6cd06b8bee" + }, + { + "path": "apps/web/src/operator-shell.css", + "bytes": 9902, + "sha256": "623d9797dd7834b291536edf13538c360a86075d18fea5f5e38fdeb14f1da6e1" + }, + { + "path": "apps/web/src/OperatorCommandBar.tsx", + "bytes": 20493, + "sha256": "7f31ffe9d10951a4bb72874fa074e8afa86c41929c573f2c2f6892299de22aa2" + }, + { + "path": "apps/web/src/operatorContext.ts", + "bytes": 6389, + "sha256": "408120571b1784dce24ad6d474a4f559d4b19de48e87b7104b64da89d94faba8" + }, + { + "path": "apps/web/src/operatorWorkspace.ts", + "bytes": 3677, + "sha256": "baef847549331d45123b9570408961a2784444f4c87ac285a86d8b2ab4af9bfb" + }, { "path": "apps/web/src/overviewSignals.ts", "bytes": 2441, @@ -227,16 +312,36 @@ "bytes": 7291, "sha256": "a84c5e3363ee193417b143d496efa97de9ee5776d70876a29d2ff2ae7eab6abe" }, + { + "path": "apps/web/src/RouteErrorBoundary.tsx", + "bytes": 1247, + "sha256": "96503a02fe6f81e799d82945c8a78402db718b03822dca4474aba670a1eb1a5c" + }, { "path": "apps/web/src/routes.ts", - "bytes": 1409, - "sha256": "541fb16e6eed1f89be5e67dcc9f212105a2ec8d1c3bc3dd212163829b4a65405" + "bytes": 1421, + "sha256": "413add6d509d34eafb64f0104a356e337949af107786c640c3ac761e411c0c31" }, { "path": "apps/web/src/ServicePage.tsx", "bytes": 20623, "sha256": "b564458e24fdf92ed01bd0c8bfefb60a73ad0d3dd1cbd1e7fb839924e5db881c" }, + { + "path": "apps/web/src/sessionClient.ts", + "bytes": 1991, + "sha256": "f530ea1e9abd9ea0bac7d0cf80edcc162fe9a48428f5153644b7064ffcd46a6f" + }, + { + "path": "apps/web/src/SettingsCapabilityPanel.tsx", + "bytes": 3247, + "sha256": "f2ac0f270326829f031f76e7b867e1b92077ab00f40a073cefc40e82b5bd7e73" + }, + { + "path": "apps/web/src/SettingsPage.tsx", + "bytes": 6562, + "sha256": "4133f91be0a9bc60b86d84064e3bc6fff5a3b0da604822349c5cc5b8ae767d73" + }, { "path": "apps/web/src/SharePage.tsx", "bytes": 8212, @@ -254,8 +359,8 @@ }, { "path": "apps/web/src/StoragePage.tsx", - "bytes": 6521, - "sha256": "0c1939c57e0ea79fadf8a057263bed9be2ca5bf2a1d9f4cc661035f6da7450d1" + "bytes": 9113, + "sha256": "b6d912f43a4f7914508247e0caf9d4751a7c8662deaa14e7e2060fe896eb18cf" }, { "path": "apps/web/src/StorageVisuals.tsx", @@ -264,8 +369,8 @@ }, { "path": "apps/web/src/styles.css", - "bytes": 116139, - "sha256": "a75362cfd509e2d0385322024e007c157b771b2504b41dda8a0fbdeb7a742f71" + "bytes": 124094, + "sha256": "2bf8e5236b9c58dc8e795b5c2a11d2e7c80342929062de0660cbe44edba4c358" }, { "path": "apps/web/src/systemStatus.ts", @@ -279,8 +384,8 @@ }, { "path": "apps/web/src/TopologyPage.tsx", - "bytes": 9295, - "sha256": "7b3b7b7a4d8af2464887a13825e834ac350356597e259d98dcd3365713f2ea47" + "bytes": 13604, + "sha256": "0a02d03b50c447d4524d5b9b872c4891a3d1ee4600610162a45c71b1c9490e2d" }, { "path": "apps/web/src/useLiveMetric.ts", @@ -302,20 +407,25 @@ "bytes": 1246, "sha256": "4c7e2cc144eb49a6cabb7a1a436761db69c20adbb08100ae5300cab71a926982" }, + { + "path": "apps/web/src/widgetCatalog.ts", + "bytes": 2637, + "sha256": "0d380d2d434e31b6f6aadc5afc47b464428dcb6bbebebdbb107765d1653ac039" + }, { "path": "apps/web/src/WidgetConfigDrawer.tsx", - "bytes": 9669, - "sha256": "5da5698c08e0148360c988d2b5b00e3cb8c97008c3328e6507348027bca3383d" + "bytes": 13701, + "sha256": "2afd8a5a37d4cda39bf7447f0e6120b9661199475c90adab6c426274ec1c3d1e" }, { "path": "apps/web/tests/e2e/accessibility.spec.ts", - "bytes": 7458, - "sha256": "35944ead608edc8ed86870d6ae14dd939d3c5da739c11fa36c717af112ad11d3" + "bytes": 8294, + "sha256": "8ee02bf808740156bad16569e96bcb3902386590e880748dcbce136a2b97189b" }, { "path": "apps/web/tests/e2e/alert-workspace.spec.ts", - "bytes": 5618, - "sha256": "ebb6c7d538c73aa5b0d7ed699adadbdf34d9cec85eea993478ff10963b5115f5" + "bytes": 5957, + "sha256": "2b589e88009139b733b83e5d069ac486daa9212b94242746638215a34194b7ee" }, { "path": "apps/web/tests/e2e/capacity-real.spec.ts", @@ -329,18 +439,33 @@ }, { "path": "apps/web/tests/e2e/dashboard-editor-polish.spec.ts", - "bytes": 6441, - "sha256": "31845e92fe0c253307baff8d2e833ae52c60794f4976c619b881770803abc8b6" + "bytes": 7963, + "sha256": "ff7694dc025aea7aeceed6df1e293579acecceff9db38ef9674930e980333306" + }, + { + "path": "apps/web/tests/e2e/dashboard-lifecycle.spec.ts", + "bytes": 7477, + "sha256": "137fe810250a95b93a288212d0685c3549ac9ef1cbc1c8b543e8d1361ae5cb91" }, { "path": "apps/web/tests/e2e/dashboard-real-sources.spec.ts", "bytes": 2676, "sha256": "0d5bcffdadd3a2ce585a5395400928c5be85c2c1797c863737175d0fa7e32262" }, + { + "path": "apps/web/tests/e2e/dashboard-widget-runtime.spec.ts", + "bytes": 3921, + "sha256": "e6131d469c0a09222d345c361cd908d718a091baaa52183ecc74090dacf70cf8" + }, { "path": "apps/web/tests/e2e/event-timeline.spec.ts", - "bytes": 4065, - "sha256": "d1916f121fdebc99249b25f21291c4c80ae5988329ed5abbb5e4c26fb7bf5f92" + "bytes": 4232, + "sha256": "b36e27ae800b45c1cadddf6e00d2ae69ff2993f2a18f64d10453b85dfb3fb07a" + }, + { + "path": "apps/web/tests/e2e/explore-metrics.spec.ts", + "bytes": 4528, + "sha256": "6c6ef4857477f0e7c44c6a3dc986db8ddcb312d9b17c2e680566fbf5cbfbbef9" }, { "path": "apps/web/tests/e2e/inventory-real.spec.ts", @@ -349,8 +474,8 @@ }, { "path": "apps/web/tests/e2e/large-lists.spec.ts", - "bytes": 7534, - "sha256": "4465cce0cfb1f1dedce6bf276e047ddd3428f1852aef0b7003797ae9417a319d" + "bytes": 7673, + "sha256": "174758c857dd9c45c81d30fe1fbbacbc40fbb346e68293455c29844514e001e9" }, { "path": "apps/web/tests/e2e/localized-alert-editor.spec.ts", @@ -359,13 +484,13 @@ }, { "path": "apps/web/tests/e2e/management-workspace.spec.ts", - "bytes": 5055, - "sha256": "221dc1c37a3fc380c0a27fba57b8154907eac215d8a54a29033aa7c906fc0f63" + "bytes": 6212, + "sha256": "089a70dc697c0f73f96bc6f09a44191524f861fc531e68aa967bdba602695c36" }, { "path": "apps/web/tests/e2e/mobile-incident.spec.ts", - "bytes": 2724, - "sha256": "5bcdfde067adb2280009d42fd265b51f52230f94368e81e4e1da772b70fa393e" + "bytes": 4698, + "sha256": "6dd76d5308f1fa648fe6c9bf06acb8af9bac1f409d29fe02702a89b5bf89a288" }, { "path": "apps/web/tests/e2e/real-stack.spec.ts", @@ -379,8 +504,8 @@ }, { "path": "apps/web/tests/e2e/responsive-polish.spec.ts", - "bytes": 4497, - "sha256": "5a1303cb00934b80743332b9ce962010e99f7057509a8c3fb9db5d12729c428a" + "bytes": 4654, + "sha256": "c1265bf9b406addde34bf023ef4ddd1afc4028483a7c5b0e798f82fb2f752602" }, { "path": "apps/web/tests/e2e/service-states-real.spec.ts", @@ -422,6 +547,11 @@ "bytes": 8259, "sha256": "f1b1b9c7bda5ec54196a4f11fa77f83ded78c139f2ffb65a6a4dc758aa3cf87e" }, + { + "path": "apps/web/tests/unit/apiClient.test.ts", + "bytes": 2494, + "sha256": "0cbe093ddae804bbef14a22819b348e5a14b30a8bb3e8cc3234743ecd10bf334" + }, { "path": "apps/web/tests/unit/ApplicationPage.test.tsx", "bytes": 2117, @@ -429,8 +559,8 @@ }, { "path": "apps/web/tests/unit/AppOverview.test.tsx", - "bytes": 18353, - "sha256": "0dd586b1a1d02c9ad8738c3e0dff0429d838477e8cbab58037ea08e6c41639c7" + "bytes": 18424, + "sha256": "a6a74b4f38e56e2e95f1c30767e007c8c524c51da3d67207e2e108234c0f2378" }, { "path": "apps/web/tests/unit/ArrayPage.test.tsx", @@ -454,19 +584,54 @@ }, { "path": "apps/web/tests/unit/DashboardCache.test.tsx", - "bytes": 1306, - "sha256": "b191df5032d853ac678908f125e8e6ab8fdb761fe0547bb906521c37dcbca2cb" + "bytes": 1361, + "sha256": "d547fbc37bd640b1a9b01375623ed89c20804a7e35fd2b3f373b6844caef9994" + }, + { + "path": "apps/web/tests/unit/DashboardEditor.test.tsx", + "bytes": 5022, + "sha256": "ce878a1d425420db9640eefef4a5498c44bedf2e00f18b3ae691950216a243df" + }, + { + "path": "apps/web/tests/unit/DashboardManagerPage.test.tsx", + "bytes": 7033, + "sha256": "57b8a8f11243d05cee18d1b371903e43483f17c10c4de4338913a579a712d99a" + }, + { + "path": "apps/web/tests/unit/DashboardRuntimeWidget.test.tsx", + "bytes": 3749, + "sha256": "786dbbe81cf177f84d55a03e466763c09987f9a874e080aa69e7b860d22df108" }, { "path": "apps/web/tests/unit/dashboardScope.test.ts", "bytes": 767, "sha256": "b9772bd9da6d0e2c4933c980d519cb478fb24de0ae678e95e40c37fb84a70aee" }, + { + "path": "apps/web/tests/unit/dashboardSpatial.test.ts", + "bytes": 1255, + "sha256": "f0ec6fd47e951b95183c01793712984f66048d90bb6127018ef0b633ed706552" + }, + { + "path": "apps/web/tests/unit/dashboardTemplates.test.ts", + "bytes": 1405, + "sha256": "5fd721c61d5db3ba6e261b98ffe4c0e0ccd01683025473596dcd446cd35d05e3" + }, { "path": "apps/web/tests/unit/EventsPage.test.tsx", "bytes": 3781, "sha256": "474887a6b4d7f86418e6f42fde0513a26188c3d6868db93a0d94d62ae80848a2" }, + { + "path": "apps/web/tests/unit/ExploreMetricsPage.test.tsx", + "bytes": 4223, + "sha256": "49d5495b5321b89dbae472b83b33f9c3fd7ee36d9905b33d777bbefdb9891806" + }, + { + "path": "apps/web/tests/unit/historicalSamples.test.ts", + "bytes": 659, + "sha256": "478ac71321c10cfce630e1463e70a0982aa0298624ea947e65ded4accbfa71c6" + }, { "path": "apps/web/tests/unit/InventoryPage.test.tsx", "bytes": 4120, @@ -482,10 +647,15 @@ "bytes": 779, "sha256": "b9ce76ed4b9f8ad987ca654c2439f29fe3b53a707453b855bdacd41b77c36044" }, + { + "path": "apps/web/tests/unit/metricClient.test.ts", + "bytes": 1204, + "sha256": "d566a3917c2ebeaaaea0b4bc96959eeeb143ccabf7db3821431d57fd9beb08fc" + }, { "path": "apps/web/tests/unit/MetricWidgets.test.tsx", - "bytes": 3660, - "sha256": "e53b9b9df1bd402f852bf91dcb3954f5f54b80a42105012fd9248f864ece9286" + "bytes": 4819, + "sha256": "91571c654796efb0e25c2c501c94b6115772ca453884eeb74568261233ac456e" }, { "path": "apps/web/tests/unit/NetworkPage.test.tsx", @@ -502,6 +672,16 @@ "bytes": 2735, "sha256": "0420a4fe6a8c2bbe27e5bbc54c655748ea218fc70ec16bcb45561d66d0110a34" }, + { + "path": "apps/web/tests/unit/operatorContext.test.ts", + "bytes": 1509, + "sha256": "8c566a05b8d96147f0d5b1ef57ceccbc4092897426ae46e898ce1a8da670b4fd" + }, + { + "path": "apps/web/tests/unit/operatorWorkspace.test.ts", + "bytes": 2425, + "sha256": "618c1def8920e1b6f87b9b03e9c45b4b6b71fc9b4a75021472620a7bc533cbaa" + }, { "path": "apps/web/tests/unit/overviewSignals.test.ts", "bytes": 1571, @@ -517,6 +697,11 @@ "bytes": 3257, "sha256": "7977ce5372b0a82639d2b93fdd91a97ebd0c888ff586e68f144033f9eef220c4" }, + { + "path": "apps/web/tests/unit/RouteErrorBoundary.test.tsx", + "bytes": 864, + "sha256": "cd5b18909592b59781fab12506c6042bedb33bd1f3e2d8c026b226bab306d70f" + }, { "path": "apps/web/tests/unit/routing.test.tsx", "bytes": 1366, @@ -527,6 +712,11 @@ "bytes": 3022, "sha256": "27b22902980f5ee3441775554d8dc23759cf830fde53365dfceb71585bc4c84c" }, + { + "path": "apps/web/tests/unit/sessionClient.test.ts", + "bytes": 1737, + "sha256": "3311ce73d50ca5da3037e73ecc95876ef4d9d96887c189f4d6ab2fae1b2214e1" + }, { "path": "apps/web/tests/unit/SourceStatusDetails.test.tsx", "bytes": 1237, @@ -557,6 +747,11 @@ "bytes": 982, "sha256": "b8cc31caa1968720117bd98a65c0254fa1d68167864655009fbddb4b032e2c1f" }, + { + "path": "apps/web/tests/unit/widgetCatalog.test.ts", + "bytes": 1367, + "sha256": "664e5abfd124761aeabcb6ae692e79d6c06e72359bc851b68f1cfba1ea0e987c" + }, { "path": "apps/web/tsconfig.json", "bytes": 558, @@ -569,8 +764,8 @@ }, { "path": "CHANGELOG.md", - "bytes": 1360, - "sha256": "376bb43c941d0a1fc1f8f5d9b937375d22d271555402bd326e7843a34c8129d7" + "bytes": 2338, + "sha256": "8b78b908fb8ce6dcd9ec4b77f2cf5ea115af4f022be205664168e4fd5ae747ff" }, { "path": "cmd/agent/agent_test.go", @@ -594,8 +789,8 @@ }, { "path": "cmd/api/main.go", - "bytes": 28573, - "sha256": "5ad373b907a97886b2871da6a1186b77fd711854c24bac2dfa6b7940cf96df58" + "bytes": 28775, + "sha256": "eb000954df6f46183de0036763967e4e007b9896e8fc73e881e3755b4f425a32" }, { "path": "cmd/api/session_live_test.go", @@ -799,8 +994,8 @@ }, { "path": "docs/architecture/API_CONTRACT.md", - "bytes": 33239, - "sha256": "10165dd3ebe6506aa02326c5b03852a37d3551ff3b0a294df8191399064af086" + "bytes": 33501, + "sha256": "f341b9c8cbdccd3a447dea52b7422743dbad7ca820da30d34b7420ff4f18a79e" }, { "path": "docs/architecture/DATA_MODEL.md", @@ -937,6 +1132,11 @@ "bytes": 1281, "sha256": "3f19cef17b3502aba8cd8f89be329123fa66da2240346f548fc01bfd8d355b11" }, + { + "path": "docs/screenshots/overview-demo.png", + "bytes": 92135, + "sha256": "94c763289068f147b4a42d35a89a7a7663415a9f99a19232bc1b0ab3c45c994a" + }, { "path": "fixtures/README.md", "bytes": 770, @@ -2652,6 +2852,16 @@ "bytes": 9315, "sha256": "9d832c68dfacc37e2a30555dbb420290d91f942a805957c292a78ba8b02eabef" }, + { + "path": "internal/sessionapi/handler_test.go", + "bytes": 2106, + "sha256": "ba6d23a943dccaa658a0aa5d063a0fc64be2a8b22f01b0fbeda7e6884ee7cb5d" + }, + { + "path": "internal/sessionapi/handler.go", + "bytes": 1666, + "sha256": "57142f702d87f7cd5f53a8a8afd423115f4e2f385d409e66f56fd69975c2d48b" + }, { "path": "internal/share/types_test.go", "bytes": 3063, @@ -2855,12 +3065,12 @@ { "path": "PACKAGE_VERSION", "bytes": 6, - "sha256": "acb57a7135b2d7d6e665f67f056e21353023b93835f54def1ae523bf76f1bfb3" + "sha256": "cb1d9a30bc4ab8a397f39b7d786bb82aca0b816077eefc35d7c822017d75f6a5" }, { "path": "package.json", "bytes": 658, - "sha256": "9675238f7793c66b5554261ad7c21efd0347bff43f675c816d2810371335824c" + "sha256": "a05226c6b4d274ea8bf08696c4ecbd90bbc23fdbb7b76de1867b76fe73531e85" }, { "path": "pnpm-lock.yaml", @@ -2874,13 +3084,13 @@ }, { "path": "public-source.allowlist", - "bytes": 2458, - "sha256": "8deff1dae7aa66b6bfa57500c6d198421ef9ea4a297e76dd2e0beed94d005726" + "bytes": 2493, + "sha256": "7df95293cc01b709856145355e765eef60ed1d8ac7514b7421d7ba7faea25923" }, { "path": "README.md", - "bytes": 4690, - "sha256": "5dfc28281b63f0bd9c58e322115926a1aab104f33662d1aaccbf88fb428b2a0e" + "bytes": 4686, + "sha256": "ce9dec254576717be9953e8463d5d415fe572ff3f56f523861645b4bfd6656a7" }, { "path": "requirements-dev.txt", @@ -2904,8 +3114,8 @@ }, { "path": "scripts/integration-smoke.ps1", - "bytes": 10781, - "sha256": "c7c14a2d4418fdfc12d7bec6f71288c45ecb2e370447cb356830d9c0638199d1" + "bytes": 12073, + "sha256": "c88c5d576f40dd92a80e7a70569f6a7b382719d209a8cb654ce880835cd66958" }, { "path": "scripts/lint.ps1", @@ -2919,8 +3129,8 @@ }, { "path": "scripts/public-verify.ps1", - "bytes": 1730, - "sha256": "60260a455045a02b52b3f40969e9a72c6aaacf29f6f66800cd5a8718bf4cf4a0" + "bytes": 1802, + "sha256": "82bed68caaaa791d5c64671529a5f47bfd22da43305e7ec2a34b64bc7a6670a0" }, { "path": "scripts/run-trivy-fs-scan.sh", @@ -2944,8 +3154,8 @@ }, { "path": "SECURITY.md", - "bytes": 1706, - "sha256": "9a0002a2fda4d245dd48c9b4483bbeebb01192b7ec0edfe47b8eb6cc4f090377" + "bytes": 1704, + "sha256": "3f41c5263e31f08df33d4fa2bc502cbb042c077f97b9120bbee6a829bfb28bee" }, { "path": "specs/alert-rule-set.schema.json", @@ -2959,8 +3169,8 @@ }, { "path": "specs/api-routes.json", - "bytes": 13627, - "sha256": "a02c47d8c6c2676ef93c2e13263aee72fdf950c96ef31a7e0210141e3efe6863" + "bytes": 13773, + "sha256": "511107130907579e5a43aa44d147bb8176519e29b2fb1e039d9b157ca8c91e86" }, { "path": "specs/capability.schema.json", @@ -3014,8 +3224,8 @@ }, { "path": "specs/widget-instance.schema.json", - "bytes": 4943, - "sha256": "b6987c270484bc37af508eabd3f58cb145e9879d4cde87e5dec3a430ce83ca3b" + "bytes": 5379, + "sha256": "fcdc757631719d43c84d5989e9bc00363e2a40deec542a527f94c2baf2fe409d" }, { "path": "tools/analyze-wallboard-soak.mjs", diff --git a/README.md b/README.md index 299834c..a25aca3 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,37 @@ # 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. +

ITWorx Pulse icon

+

A read-only operations view for self-hosted infrastructure.

+

Interface · Capabilities · Quick start · Deployment

-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. +ITWorx Pulse brings Unraid host, container, storage, network and service telemetry into one operator-focused interface. It helps teams see what needs attention, investigate the underlying resource, and keep dashboards, alerts, incidents and wallboards in the same context. -Actuele release: **v1.5.0**. +Current release: **v1.6.0**. See the [changelog](CHANGELOG.md) for release history. -## Wat je als gebruiker krijgt +Pulse is operationally **read-only**: it observes and explains infrastructure, but does not start, stop, delete or repair workloads. Missing or stale telemetry appears as **Unknown**, never as a reassuring green status. -- 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. +## See Pulse in action -## Platform in één oogopslag +![ITWorx Pulse overview using isolated test telemetry](docs/screenshots/overview-demo.png) -```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 -``` +*This capture uses isolated validation fixtures and does not show a production host.* -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. +## Capabilities -## Snel lokaal proberen +| Area | What operators can do | +| --- | --- | +| Overview and search | Move from an attention item to the exact host, container, service or storage resource. | +| Infrastructure detail | Inspect hosts, processes, containers, applications, arrays, disks, pools, shares and networks. | +| Dashboards | Compose versioned desktop, tablet, mobile and wallboard layouts. | +| Metrics | Explore bounded historical and live Prometheus queries without writing PromQL. | +| Alerts and incidents | Manage pending and recovery states, suppression, maintenance, timelines, notes and ownership. | +| Access and recovery | Use Authentik/OIDC roles, audit trails, verifiable backups and clean-room restore procedures. | -Vereisten: Go 1.26.6, Node.js 24+, pnpm 10.33+, Python 3, PowerShell 7 en Docker Compose. +The global operator bar keeps time context, entity search, favorites, saved views and recent resources available while investigating. The interface supports responsive layouts and accessible keyboard navigation. + +## Try it locally + +Requirements: Go 1.26.6, Node.js 24+, pnpm 10.33+, Python 3, PowerShell 7 and Docker Compose. ```powershell Copy-Item .env.example .env @@ -42,51 +39,39 @@ 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. +Open . The development stack uses explicit mock authentication and isolated local data. Do not put production credentials or production telemetry in this environment. Stop the stack with `docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml down --volumes` when its local data can be discarded. -Stop en verwijder alleen deze lokale stack met: +## Architecture -```powershell -docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml down --volumes +```mermaid +flowchart LR + Browser[Browser and wallboard] --> Web[Pulse web and API] + Web --> DB[(PostgreSQL domain state)] + Web --> Metrics[Prometheus metrics] + Worker[Pulse worker] --> DB + Agent[Read-only Unraid agent] --> Worker ``` -## Valideren +The web/API tier has no Docker socket or host-root access. Only the agent receives explicitly configured read-only sources needed for observation. PostgreSQL stores configuration, inventory, dashboards, alerts and audit; the worker handles discovery, probes and notifications. -De private engineeringrepository gebruikt een uitgebreidere evidencegate. Een publieke source-export valideert de productcode met: +## Production deployment + +Begin with the [public deployment guide](docs/PUBLIC_DEPLOYMENT.md). A production instance needs HTTPS, Authentik/OIDC, private PostgreSQL, externally managed secrets, a defined Prometheus source, least-privilege Unraid API access where required, a backup directory, and a reviewed network and mount projection. The supplied configuration fails closed when mandatory production settings are absent. Validate backup, restore and rollback before relying on alerts. + +Run the public source verification with: ```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`. +This checks Go tests and vet, frontend tests and build, contracts, wiring, secret markers, image digests and the public-source boundary. The optional Docker integration smoke is in `scripts/integration-smoke.ps1`. -## Productie +## Further reading -Begin bij [`docs/PUBLIC_DEPLOYMENT.md`](docs/PUBLIC_DEPLOYMENT.md). Productie vereist onder meer: +- [System architecture](docs/architecture/SYSTEM_ARCHITECTURE.md) and [API contract](docs/architecture/API_CONTRACT.md) +- [Security threat model](docs/architecture/SECURITY_THREAT_MODEL.md) +- [Development setup](docs/operations/DEVELOPMENT_SETUP.md) +- [Backup and restore](docs/operations/BACKUP_RESTORE.md) +- [Public source boundary](docs/PUBLIC_SOURCE_BOUNDARY.md) -- 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. +First-party source is licensed under **AGPL-3.0-or-later**; see [LICENSE](LICENSE). For vulnerabilities, follow [SECURITY.md](SECURITY.md) rather than posting sensitive details in an issue. diff --git a/SECURITY.md b/SECURITY.md index be9e2fe..e548598 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## 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. +Security fixes target the current release line on `main`. Older releases may receive a fix when the same issue is still relevant and a safe backport is practical. ## Reporting vulnerabilities diff --git a/apps/web/package.json b/apps/web/package.json index 7d17800..c04bc39 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@itworx/pulse-web", "private": true, - "version": "1.5.0", + "version": "1.6.0", "type": "module", "scripts": { "build": "vite build", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 6a313ef..ec48bca 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, use: { baseURL: realStackBaseURL || localBaseURL, trace: 'retain-on-failure', diff --git a/apps/web/src/AlertOperationsPanel.tsx b/apps/web/src/AlertOperationsPanel.tsx index 51f28f1..e233957 100644 --- a/apps/web/src/AlertOperationsPanel.tsx +++ b/apps/web/src/AlertOperationsPanel.tsx @@ -24,6 +24,8 @@ export function AlertOperationsPanel() { const [message, setMessage] = useState(''); const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [view, setView] = useState('active'); + const [query, setQuery] = useState(() => new URLSearchParams(window.location.search).get('q') ?? ''); + const requestedAlert = new URLSearchParams(window.location.search).get('alert') ?? ''; async function load(signal?: AbortSignal) { try { @@ -43,6 +45,17 @@ export function AlertOperationsPanel() { return () => controller.abort(); }, []); + useEffect(() => { + if (!requestedAlert || state !== 'ready') return; + const controller = new AbortController(); + fetch('/api/v1/alerts/' + encodeURIComponent(requestedAlert) + '?occurrenceLimit=50', { signal: controller.signal }).then(async (response) => { + if (!response.ok) return; + const value = await response.json() as { alert?: AlertItem }; + if (value.alert) setSelected(value.alert); + }).catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setMessage(copy.alerts.operationsError); }); + return () => controller.abort(); + }, [requestedAlert, state]); + const ordered = useMemo(() => items.slice().sort((left, right) => (severityOrder[left.severity] ?? 9) - (severityOrder[right.severity] ?? 9) || (stateOrder[left.state] ?? 9) - (stateOrder[right.state] ?? 9) @@ -52,12 +65,25 @@ export function AlertOperationsPanel() { 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 normalizedQuery = query.trim().toLocaleLowerCase('nl-BE'); + const filtered = ordered.filter((item) => { + const inView = view === 'critical' ? isActive(item) && item.severity === 'critical' : view === 'acknowledged' ? item.state === 'acknowledged' : isActive(item); + const matches = !normalizedQuery || `${item.ruleName} ${item.entityName ?? ''} ${item.reason} ${item.state} ${item.severity}`.toLocaleLowerCase('nl-BE').includes(normalizedQuery); + return inView && matches; + }); 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); + if (response.ok) { + const next = ((await response.json()) as { alert: AlertItem }).alert; + setSelected(next); + const params = new URLSearchParams(window.location.search); + params.set('section', 'operations'); + params.set('alert', item.id); + if (query.trim()) params.set('q', query.trim()); else params.delete('q'); + window.history.replaceState(window.history.state, '', window.location.pathname + '?' + params.toString()); + } } async function operate(item: AlertItem, acknowledge: boolean) { @@ -83,6 +109,7 @@ export function AlertOperationsPanel() {

{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) =>
  • {presentStatus(item.severity)} diff --git a/apps/web/src/AlertRulesPage.tsx b/apps/web/src/AlertRulesPage.tsx index d2fbd69..c9a0703 100644 --- a/apps/web/src/AlertRulesPage.tsx +++ b/apps/web/src/AlertRulesPage.tsx @@ -134,6 +134,37 @@ export function AlertRulesPage() { })); } + function metricByHints(...hints: string[]): MetricDefinition | undefined { + return metrics.find((metric) => { + const value = metric.semanticName.toLowerCase(); + return hints.every((hint) => value.includes(hint)); + }); + } + + function applyTemplate(template: 'cpu' | 'storage' | 'service' | 'source' | 'events') { + setSelectedId(null); setPreview(null); setMessage(''); + const next = newRule(); + if (template === 'cpu') { + const metric = metricByHints('cpu') ?? metrics.find((item) => item.semanticName.toLowerCase().includes('processor')); + next.name = 'Hoge CPU-belasting'; next.severity = 'attention'; next.pendingSeconds = 300; next.resolveSeconds = 180; + next.condition = { inputType: 'metric', metric: metric?.semanticName ?? '', operator: '>', threshold: 85, recoveryThreshold: 75, aggregation: metric?.defaultAggregation ?? 'avg', windowSeconds: 300 }; + } else if (template === 'storage') { + const metric = metricByHints('storage') ?? metricByHints('capacity') ?? metrics.find((item) => item.semanticName.toLowerCase().includes('utilization')); + next.name = 'Storagecapaciteit hoog'; next.severity = 'critical'; next.pendingSeconds = 600; next.resolveSeconds = 300; + next.condition = { inputType: 'metric', metric: metric?.semanticName ?? '', operator: '>', threshold: 90, recoveryThreshold: 85, aggregation: metric?.defaultAggregation ?? 'max', windowSeconds: 600 }; + } else if (template === 'service') { + next.name = 'Service niet beschikbaar'; next.severity = 'critical'; next.pendingSeconds = 60; next.resolveSeconds = 120; + next.condition = { inputType: 'entity-status', operator: '==', threshold: 'down', recoveryThreshold: null, aggregation: 'none', windowSeconds: 60 }; + } else if (template === 'source') { + next.name = 'Databron verouderd'; next.severity = 'degraded'; next.pendingSeconds = 120; next.resolveSeconds = 120; + next.condition = { inputType: 'datasource-health', operator: '==', threshold: 'stale', recoveryThreshold: null, aggregation: 'none', windowSeconds: 120 }; + } else { + next.name = 'Herhaalde events'; next.severity = 'attention'; next.pendingSeconds = 0; next.resolveSeconds = 300; + next.condition = { inputType: 'event', operator: '>=', threshold: 3, recoveryThreshold: null, aggregation: 'count', windowSeconds: 900 }; + } + setDraft(next); + } + async function save() { const validation = validateDraft(draft, metrics); if (validation) { setMessage(validation); return; } @@ -210,6 +241,8 @@ export function AlertRulesPage() {

{copy.alerts.editor}

{selected ? copy.alerts.editRule : copy.alerts.createRule}

{selected && v{selected.currentVersion}}
+ {!selected &&

Snel starten

Kies een veilige basisregel

De template vult alleen de regelconfiguratie in. Controleer metric, scope en drempels vóór opslaan.

} +
  1. 1Signaal
  2. 2Voorwaarde
  3. 3Timing
  4. 4Veiligheid
  5. 5Preview
@@ -226,7 +259,7 @@ export function AlertRulesPage() {
{selected && }
{message &&

{message}

} -

{copy.alerts.preview}

{copy.alerts.previewTitle}

{copy.alerts.previewDetail}

{preview &&

{presentStatus(preview.state)}: {presentReason(preview.reason)}

}
+

{copy.alerts.preview}

{copy.alerts.previewTitle}

{copy.alerts.previewDetail}

{preview &&
{preview.wouldFire ? 'Deze voorbeeldwaarde zou de regel activeren.' : 'Deze voorbeeldwaarde activeert de regel niet.'}{presentStatus(preview.state)} · {presentReason(preview.reason)}
}
} {section === 'controls' && } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fdd50dd..98ba1a7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,7 @@ 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 { widgetLabel } from './widgetCatalog'; import { copy } from './copy'; import { routeFromLocation, type RoutePath } from './routes'; import { HostPage } from './HostPage'; @@ -27,68 +28,28 @@ 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 { wallboardPlacement, wallboardSlideIndex } from './wallboardLayout'; import { OperationalSignalPath, type OperationalSignalStage } from './OperationalSignalPath'; +import { operatorRangeSelection, setOperatorRangePreset, useOperatorContext, type OperatorRangePreset } from './operatorContext'; import { containerSignalTone, signalToneFromState, signalToneRank, sourceSignalTone, worstSignalTone, type SignalTone } from './overviewSignals'; +import { spatialStyle } from './dashboardSpatial'; +import { readSessionCapabilities } from './sessionClient'; +import { navigation, navigate, OperatorAppShell } from './AppShell'; +import { SettingsPage } from './SettingsPage'; +import { ApiProblemError, requestJSON } from './apiClient'; +import { RouteErrorBoundary } from './RouteErrorBoundary'; // 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 DashboardManagerPage = lazy(() => import('./DashboardManagerPage').then((module) => ({ default: module.DashboardManagerPage }))); +const ExploreMetricsPage = lazy(() => import('./ExploreMetricsPage').then((module) => ({ default: module.ExploreMetricsPage }))); 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); }}>{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 ; -} -function navigate(path: string) { - window.history.pushState({}, '', path); - window.dispatchEvent(new PopStateEvent('popstate')); -} - function StatusBadge({ label, tone = 'unknown' }: { label: string; tone?: 'unknown' | 'ready' }) { return {label}; } @@ -322,20 +283,22 @@ function OverviewPage() { .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') })); + .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'), route: '/pools/' + encodeURIComponent(pool.id) })); const resourceLabels: Record = { host: copy.navigation.host, containers: copy.overview.signalWorkloads, pools: copy.overview.signalStorage, services: copy.navigation.services, incidents: copy.overview.signalIncidents }; + const resourceRoutes: Record = { host: '/host', containers: '/containers', pools: '/storage', services: '/services', incidents: '/incidents' }; 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 }]; + return [{ id: `resource:${resource}`, label: `${resourceLabels[resource]}: ${signalResourceLabel(state, 'unknown')}`, reason: resourceStateDetail(state) ?? copy.overview.resourceUnavailableDetail, route: resourceRoutes[resource] }]; }); - 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 systemProblems = systemResourceState === 'ready' || systemResourceState === 'loading' ? [] : [{ id: 'resource:system-status', label: `${copy.overview.sources}: ${signalResourceLabel(systemResourceState, 'unknown')}`, reason: resourceStateDetail(systemResourceState) ?? copy.overview.resourceUnavailableDetail, route: '/status' }]; + const partialProblems: Array<{ id: string; label: string; reason: string; route: string }> = []; + if (overview.containersPartial) partialProblems.push({ id: 'partial:containers', label: `${copy.overview.signalWorkloads}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail, route: '/containers' }); + if (overview.poolsPartial) partialProblems.push({ id: 'partial:pools', label: `${copy.overview.signalStorage}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail, route: '/storage' }); + if (overview.servicesPartial) partialProblems.push({ id: 'partial:services', label: `${copy.navigation.services}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail, route: '/services' }); + if (overview.incidentsPartial) partialProblems.push({ id: 'partial:incidents', label: `${copy.overview.signalIncidents}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail, route: '/incidents' }); + const sourceProblems = statusProblems(snapshot.status).map((problem) => ({ ...problem, route: '/status' })); + const problems = [...systemProblems, ...resourceProblems, ...partialProblems, ...poolProblems, ...sourceProblems].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; @@ -386,11 +349,11 @@ function OverviewPage() {

    {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) =>
    1. {index === 0 ? '!' : '?'}{problem.label}{problem.reason}
    2. )}
    :

    {resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}

    }
    {overviewNeedsAuthentication && }
    +

    {copy.overview.actionQueue}

    {copy.overview.problems}

    {problems.length}
    {problems.length ?
      {problems.map((problem, index) =>
    1. )}
    :

    {resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}

    }
    {overviewNeedsAuthentication && }
    -

    {copy.overview.storagePools}

    {copy.overview.capacity}

    {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}

    }
    +

    {copy.overview.storagePools}

    {copy.overview.capacity}

    {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
    • ; })}
    :

    {copy.overview.noPools}

    }

    Nu

    {copy.navigation.containers}

    {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}

    {overview.resources.incidents !== 'ready' ?

    {resourceStateDetail(overview.resources.incidents)}

    : orderedIncidents.length ?
      {orderedIncidents.slice(0, 5).map((incident) =>
    • )}
    :

    {copy.overview.noIncidents}

    }
    +

    {copy.overview.recentIncidents}

    {copy.navigation.incidents}

    {overview.resources.incidents !== 'ready' ?

    {resourceStateDetail(overview.resources.incidents)}

    : orderedIncidents.length ?
      {orderedIncidents.slice(0, 5).map((incident) =>
    • )}
    :

    {copy.overview.noIncidents}

    }
    ; } @@ -400,7 +363,6 @@ type DashboardSummary = { id: string; slug: string; name: string; description: s 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); @@ -409,12 +371,6 @@ function field(record: ApiRecord | undefined, name: string): 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([]); @@ -425,12 +381,12 @@ function DashboardsPage() { // 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) => { + requestJSON<{ items?: ApiRecord[] }>('/api/v1/dashboards?limit=100', { signal: controller.signal, retries: 1 }).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'); + setState(error instanceof ApiProblemError && error.status === 401 ? 'unauthorized' : 'error'); }); return () => controller.abort(); }, [reload]); @@ -448,8 +404,6 @@ class WidgetBoundary extends Component<{ title: string; children: ReactNode }, { 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(); @@ -484,9 +438,7 @@ function wallboardSlideFor(widget: DashboardWidget): number { 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; + if (viewport !== 'wallboard') return spatialStyle(layout, viewport) 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; } @@ -496,7 +448,7 @@ function DashboardWidgetView({ widget, viewport, onFilter, metric }: { widget: D 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 typeLabel = widgetLabel(widget.type); 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'); @@ -515,19 +467,28 @@ function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0, const [document, setDocument] = useState({}); const [widgets, setWidgets] = useState([]); const [editing, setEditing] = useState(false); + const [canEdit, setCanEdit] = 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'); + useEffect(() => { + if (wallboard) return undefined; + const controller = new AbortController(); + readSessionCapabilities(controller.signal).then((session) => setCanEdit(session.permissions.edit)).catch(() => setCanEdit(false)); + return () => controller.abort(); + }, [wallboard]); + const operator = useOperatorContext(); + const timeRange = wallboard ? 'live' : operatorRangeSelection(operator); + const setTimeRange = (value: string) => setOperatorRangePreset(value as OperatorRangePreset); 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) => { + requestJSON<{ dashboard: ApiRecord; version: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(dashboardId), { signal: controller.signal, retries: 1 }).then((data) => { const rawDocument = field(data.version, 'document') ?? {}; setDocument(rawDocument); setCrossFilter(filterFromURL(rawDocument)); @@ -538,7 +499,7 @@ function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0, setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; - if (error instanceof ApiError && error.status === 401) { + if (error instanceof ApiProblemError && error.status === 401) { setState('unauthorized'); return; } @@ -550,11 +511,10 @@ function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0, }); 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'; + const aggregate: RuntimeState = values.includes('usable') || values.includes('partial') || values.includes('stale') ? 'usable' : values.includes('error') ? 'error' : values.length > 0 && values.every((value) => value === 'empty' || value === 'capability-disabled') ? 'empty' : 'loading'; onRuntimeState?.(aggregate); }, [onRuntimeState, runtimeStates]); useEffect(() => { if (wallboard) setRuntimeStates({}); }, [wallboard, wallboardSlide]); @@ -575,8 +535,8 @@ function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0, 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 && }

    {copy.dashboards.viewMode}

    {wallboard ?

    {summary.name}

    :

    {summary.name}

    }

    {summary.description || copy.dashboards.noDescription}

    {wallboard && {copy.wallboard.readOnly}}{copy.dashboards.version} {summary.currentVersion}{!wallboard && }
    {!wallboard &&
    {crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView}{usableCount} van {shown.length} {copy.dashboards.widgetsWithData}{crossFilter && }
    }{shown.length === 0 ?

    {copy.dashboards.noMatchingWidgets}

    {copy.dashboards.clearFilterHint}

    : <>

    {copy.dashboards.widgetCollection}

    {shown.map((widget) => {['semantic-metric', 'inventory', 'events'].includes(String(field(widget.data, 'sourceType') ?? '')) ? applyCrossFilter(widget)} /> : })}
    }
    ; + const usableCount = Object.values(runtimeStates).filter((value) => value === 'usable' || value === 'partial' || value === 'stale').length; + return
    {!wallboard && }

    {copy.dashboards.viewMode}

    {wallboard ?

    {summary.name}

    :

    {summary.name}

    }

    {summary.description || copy.dashboards.noDescription}

    {wallboard && {copy.wallboard.readOnly}}{copy.dashboards.version} {summary.currentVersion}{!wallboard && canEdit && }
    {!wallboard &&
    {crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView}{usableCount} van {shown.length} {copy.dashboards.widgetsWithData}{crossFilter && }
    }{shown.length === 0 ?

    {copy.dashboards.noMatchingWidgets}

    {copy.dashboards.clearFilterHint}

    : <>

    {copy.dashboards.widgetCollection}

    {shown.map((widget) => {['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'].includes(String(field(widget.data, 'sourceType') ?? '')) ? applyCrossFilter(widget)} /> : })}
    }
    ; } type WallboardPriorityData = { serviceProblems: number; openIncidents: number; loading: boolean; unavailable: boolean }; @@ -708,31 +668,6 @@ function WallboardPage() { ; } 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) => )}
    - ; -} function StatePage({ kind }: { kind: 'loading' | 'error' | 'unauthorized' }) { if (kind === 'loading') return
    ; if (kind === 'unauthorized') { @@ -774,7 +709,8 @@ function Page({ route }: { route: RoutePath }) { case '/storage': return ; case '/capacity': return ; case '/inventory': return ; - case '/dashboards': return ; + case '/dashboards': return ; + case '/explore': return ; case '/wallboard': return ; case '/alerts': return ; case '/events': return ; @@ -801,7 +737,7 @@ function App() { const currentNavigation = navigation.find((item) => item.path === route) ?? navigation.find((item) => item.path !== '/' && route.startsWith(item.path + '/')) ?? navigation[0]; - if (route === '/wallboard') return ; - return
    {copy.accessibility.skipToContent}
    ServerTower
    Live verbondenAlle bronnen · alleen-lezen
    ; + if (route === '/wallboard') return ; + return ; } export default App; diff --git a/apps/web/src/AppShell.tsx b/apps/web/src/AppShell.tsx new file mode 100644 index 0000000..aad7251 --- /dev/null +++ b/apps/web/src/AppShell.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { copy } from './copy'; +import { OperatorCommandBar } from './OperatorCommandBar'; +import { routeFromLocation, type RoutePath } from './routes'; +import { + WORKSPACE_EVENT, + isFavorite, + readWorkspaceState, + recordRecent, + removeSavedView, + saveCurrentView, + toggleFavorite, +} from './operatorWorkspace'; + +type SectionId = 'command' | 'infrastructure' | 'workloads' | 'services' | 'response' | 'manage'; +type NavigationDefinition = { + path: RoutePath; + label: string; + icon: string; + group: string; + section: SectionId; + primary: boolean; +}; + +export const navigation = [ + { path: '/', label: copy.navigation.overview, icon: '⌂', group: copy.navigation.groups.command, section: 'command', primary: true }, + { path: '/dashboards', label: copy.navigation.dashboards, icon: '▦', group: copy.navigation.groups.command, section: 'command', primary: true }, + { path: '/explore', label: copy.navigation.explore, icon: '⌁', group: copy.navigation.groups.command, section: 'command', primary: true }, + + { path: '/host', label: copy.navigation.host, icon: '▣', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: true }, + { path: '/processes', label: copy.navigation.processes, icon: '≋', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false }, + { path: '/storage', label: copy.navigation.storage, icon: '▤', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: true }, + { path: '/array', label: copy.navigation.array, icon: '▥', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false }, + { path: '/disks', label: copy.navigation.disks, icon: '◉', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false }, + { path: '/pools', label: copy.navigation.pools, icon: '◫', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false }, + { path: '/shares', label: copy.navigation.shares, icon: '⇄', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false }, + { path: '/capacity', label: copy.navigation.capacity, icon: '⌁', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false }, + { path: '/network', label: copy.navigation.network, icon: '⌘', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false }, + + { path: '/applications', label: copy.navigation.applications, icon: '◆', group: copy.navigation.groups.workloads, section: 'workloads', primary: true }, + { path: '/containers', label: copy.navigation.containers, icon: '⬡', group: copy.navigation.groups.workloads, section: 'workloads', primary: false }, + + { path: '/services', label: copy.navigation.services, icon: '◉', group: copy.navigation.groups.services, section: 'services', primary: true }, + { path: '/topology', label: copy.navigation.topology, icon: '⌬', group: copy.navigation.groups.services, section: 'services', primary: false }, + + { path: '/incidents', label: copy.navigation.incidents, icon: '△', group: copy.navigation.groups.response, section: 'response', primary: true }, + { path: '/alerts', label: copy.navigation.alerts, icon: '!', group: copy.navigation.groups.response, section: 'response', primary: false }, + { path: '/events', label: copy.navigation.events, icon: '≡', group: copy.navigation.groups.response, section: 'response', primary: false }, + + { path: '/inventory', label: copy.navigation.inventory, icon: '▥', group: copy.navigation.groups.manage, section: 'manage', primary: true }, + { path: '/settings', label: copy.navigation.settings, icon: '⚙', group: copy.navigation.groups.manage, section: 'manage', primary: true }, + { path: '/status', label: copy.navigation.status, icon: '♥', group: copy.navigation.groups.manage, section: 'manage', primary: false }, + { path: '/onboarding', label: copy.navigation.onboarding, icon: '→', group: copy.navigation.groups.manage, section: 'manage', primary: false }, + { path: '/wallboard', label: copy.navigation.wallboard, icon: '▰', group: copy.navigation.groups.manage, section: 'manage', primary: false }, +] as const satisfies ReadonlyArray; + +export type NavigationItem = (typeof navigation)[number]; + +const sectionLabels: Record = { + command: 'Operator', + infrastructure: 'Infrastructuur', + workloads: 'Workloads', + services: 'Services', + response: 'Response', + manage: 'Beheer', +}; + +const primaryNavigation = navigation.filter((item) => item.primary); +const mobilePrimaryOrder = new Map([['/', 0], ['/incidents', 1], ['/containers', 2], ['/storage', 3], ['/services', 4]]); +const mobilePrimaryNavigation = navigation.filter((item) => mobilePrimaryOrder.has(item.path)).sort((left, right) => mobilePrimaryOrder.get(left.path)! - mobilePrimaryOrder.get(right.path)!); +const mobileMoreNavigation = navigation.filter((item) => !mobilePrimaryOrder.has(item.path)); +const navigationGroups = Array.from(new Set(primaryNavigation.map((item) => item.group))); + +function matchesRoute(route: RoutePath, path: RoutePath): boolean { + return route === path || (path !== '/' && route.startsWith(path + '/')); +} + +function routeDefinition(route: RoutePath): NavigationItem { + return [...navigation] + .sort((left, right) => right.path.length - left.path.length) + .find((item) => matchesRoute(route, item.path)) ?? navigation[0]; +} + +function detailSegment(route: RoutePath, item: NavigationItem): string | null { + if (route === item.path) return null; + const raw = route.slice(item.path.length + 1).split('/')[0]; + if (!raw) return null; + try { return decodeURIComponent(raw).slice(0, 80); } catch { return raw.slice(0, 80); } +} + +export function navigate(path: string): void { + const from = window.location.pathname + window.location.search; + if (path === from) return; + window.history.pushState({ pulseFrom: from }, '', path); + window.dispatchEvent(new PopStateEvent('popstate')); +} + +function NavigationLink({ item, label = item.label, domain = false }: { item: NavigationItem; label?: string; domain?: boolean }) { + const active = matchesRoute(routeFromLocation(window.location.pathname), item.path); + return
  • { event.preventDefault(); navigate(item.path); }}>{label}
  • ; +} + +function DesktopNavigation({ route }: { route: RoutePath }) { + const activeItem = routeDefinition(route); + const activeGroup = activeItem.group; + const [openGroups, setOpenGroups] = useState>(() => new Set([navigationGroups[0], activeGroup])); + useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]); + return ; +} + +function ShellStatusBadge({ label, tone }: { label: string; tone: 'unknown' | 'ready' }) { + return {label}; +} + +function WorkspaceContext({ route, currentNavigation }: { route: RoutePath; currentNavigation: NavigationItem }) { + const [, setVersion] = useState(0); + useEffect(() => { + const refresh = () => setVersion((value) => value + 1); + window.addEventListener(WORKSPACE_EVENT, refresh); + return () => window.removeEventListener(WORKSPACE_EVENT, refresh); + }, []); + + const item = routeDefinition(route); + const sectionItems = navigation.filter((entry) => entry.section === item.section && entry.path !== '/wallboard'); + const detail = detailSegment(route, item); + const currentPath = window.location.pathname + window.location.search; + const favorite = isFavorite(currentPath); + const saved = readWorkspaceState().views.some((entry) => entry.path === currentPath); + const from = typeof window.history.state?.pulseFrom === 'string' ? window.history.state.pulseFrom as string : ''; + const fallback = sectionItems.find((entry) => entry.primary)?.path ?? '/'; + const backTarget = from && from !== currentPath ? from : (route !== fallback ? fallback : ''); + const pageLabel = detail ? currentNavigation.label + ' · ' + detail : currentNavigation.label; + + useEffect(() => { recordRecent(pageLabel, currentPath); }, [pageLabel, currentPath]); + + return
    +
    + {backTarget && } + +
    +
    + + +
    + +
    ; +} + +export function OperatorAppShell({ route, shellStatus, currentNavigation, children }: { route: RoutePath; shellStatus: { label: string; tone: 'unknown' | 'ready'; detail: string }; currentNavigation: NavigationItem; children: ReactNode }) { + const resolved = useMemo(() => routeDefinition(route), [route]); + return
    {copy.accessibility.skipToContent}
    {children}
    ; +} diff --git a/apps/web/src/ApplicationPage.tsx b/apps/web/src/ApplicationPage.tsx index 336adf2..7339e8c 100644 --- a/apps/web/src/ApplicationPage.tsx +++ b/apps/web/src/ApplicationPage.tsx @@ -16,8 +16,20 @@ function Badge({ state }: { state: string }) { function Source({ source }: { source: ApplicationSnapshot['source'] }) { return ; } + +function componentRoute(item: ComponentItem): string { + const kind = item.kind.toLowerCase(); + const id = encodeURIComponent(item.id); + if (kind.includes('container')) return '/containers/' + id; + if (kind.includes('service') || kind.includes('probe') || kind.includes('endpoint')) return '/services/' + id; + return '/inventory/' + id; +} +function navigate(path: string): void { + window.history.pushState({ pulseFrom: window.location.pathname + window.location.search }, '', path); + window.dispatchEvent(new PopStateEvent('popstate')); +} 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}
    )}
    ; + return

    Workload context

    {copy.applications.components}

    {items.length}
    {items.length === 0 ?

    Voor deze applicatie zijn geen componentrelaties aangeleverd.

    :
      {items.map((item) =>
    • )}
    }
    Technische componentidentiteiten
    {items.map((item) =>
    {item.name}
    {item.id}
    )}
    ; } export function ApplicationPage({ id }: { id?: string }) { @@ -40,7 +52,7 @@ export function ApplicationPage({ id }: { id?: string }) { if (id) { const detail = data as ApplicationDetail; const app = detail.application; - return <>

    {copy.applications.detailEyebrow}

    {app.name}

    {copy.applications.detailIntro}

    {copy.applications.source}

    {detail.source?.id || copy.applications.unknown}

    {app.reasons?.map((reason) =>

    {presentReason(reason.code)}

    )}
    ; + return <>

    {copy.applications.detailEyebrow}

    {app.name}

    {copy.applications.detailIntro}

    Status

    {app.status}{app.overridden ? copy.applications.overridden : copy.applications.discovered}

    Componenten

    {app.components.length}{app.components.filter((item) => item.critical).length} kritisch

    Containers

    {app.components.filter((item) => item.kind.toLowerCase().includes('container')).length}Direct navigeerbaar

    Services

    {app.components.filter((item) => item.kind.toLowerCase().includes('service') || item.kind.toLowerCase().includes('probe') || item.kind.toLowerCase().includes('endpoint')).length}Healthcontext

    {copy.applications.source}

    {detail.source?.id || copy.applications.unknown}

    {app.reasons?.map((reason) =>

    {presentReason(reason.code)}

    )}
    ; } const snapshot = data as ApplicationSnapshot; return <>

    {copy.applications.eyebrow}

    {copy.applications.title}

    {copy.applications.intro}

    {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/DashboardEditor.tsx b/apps/web/src/DashboardEditor.tsx index f85ff97..733113d 100644 --- a/apps/web/src/DashboardEditor.tsx +++ b/apps/web/src/DashboardEditor.tsx @@ -3,6 +3,8 @@ import { copy } from './copy'; import { WidgetConfigDrawer, type PreviewResult, type ValidationErrors } from './WidgetConfigDrawer'; import { DashboardVariablesEditor, validateVariables, type DashboardVariable } from './DashboardVariablesEditor'; import { DashboardTransfer } from './DashboardTransfer'; +import { movePlacement, normalizePlacement, overlaps, resizePlacement, resolvePlacement, spatialColumns, spatialStyle, type SpatialPlacement, type SpatialViewport } from './dashboardSpatial'; +import { isWidgetType, widgetLabel, widgetTypes } from './widgetCatalog'; export type RecordValue = Record; export type EditorWidget = { id: string; type: string; title: string; description?: string; data?: RecordValue; visualization?: RecordValue; behavior?: RecordValue; layouts?: RecordValue }; @@ -14,15 +16,26 @@ 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 }; +export const types: string[] = [...widgetTypes]; +export const labels: Record = Object.fromEntries(widgetTypes.map((type) => [type, widgetLabel(type)])); + +const widgetGroups = [ + { id: 'metrics', label: 'Metrics', types: ['stat', 'gauge', 'timeseries', 'query-inspector'] }, + { id: 'operations', label: 'Operations', types: ['status-grid', 'ranked-list', 'table', 'alert-summary', 'event-timeline'] }, + { id: 'infrastructure', label: 'Infrastructure', types: ['storage-map', 'service-matrix', 'topology', 'heatmap'] }, + { id: 'content', label: 'Content', types: ['text'] }, +] as const; +const widgetGlyphs: Record = { + stat: '12', gauge: '◔', timeseries: '⌁', 'query-inspector': '⌕', 'status-grid': '▦', + 'ranked-list': '≣', table: '▤', 'alert-summary': '!', 'event-timeline': '⋮', + 'storage-map': '◫', 'service-matrix': '◇', topology: '⌬', heatmap: '▦', text: 'T', +}; const sourceTypes = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text']; -const ranges = ['live', '15m', '1h', '6h', '24h', '7d']; +const ranges = ['live', '15m', '1h', '6h', '24h', '7d', '30d']; const aggregations = ['avg', 'min', 'max', 'sum', 'last']; -type Viewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard'; +type Viewport = SpatialViewport; 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; @@ -31,24 +44,24 @@ function read(value: RecordValue | undefined, name: string): 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 { +function layout(widget: EditorWidget, viewport: Viewport = 'desktop'): SpatialPlacement { const layouts = widget.layouts ?? {}; const fallback = read(layouts, 'desktop') ?? {}; - return { ...fallback, ...(read(layouts, viewport) ?? {}) }; + return normalizePlacement({ ...fallback, ...(read(layouts, viewport) ?? {}) }, viewport); } -function withLayout(widget: EditorWidget, viewport: Viewport, nextLayout: RecordValue): EditorWidget { - return { ...widget, layouts: { ...(widget.layouts ?? {}), [viewport]: nextLayout } }; +function withLayout(widget: EditorWidget, viewport: Viewport, nextLayout: SpatialPlacement): EditorWidget { + return { ...widget, layouts: { ...(widget.layouts ?? {}), [viewport]: normalizePlacement(nextLayout as unknown as RecordValue, viewport) } }; } function layoutWidth(widget: EditorWidget, viewport: Viewport): number { - return Math.min(viewportColumns[viewport], Math.max(1, Number(layout(widget, viewport).w ?? 6) || 1)); + return layout(widget, viewport).w; } 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') ?? {}) }; + const desktop = normalizePlacement({ ...base, ...(read(layouts, 'desktop') ?? {}) }, 'desktop'); + const tablet = normalizePlacement({ ...desktop, ...(read(layouts, 'tablet') ?? {}) }, 'tablet'); + const mobile = normalizePlacement({ ...desktop, ...(read(layouts, 'mobile') ?? {}) }, 'mobile'); + const wallboard = normalizePlacement({ ...desktop, ...(read(layouts, 'wallboard') ?? {}) }, '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 { @@ -56,13 +69,35 @@ function newWidget(type: string, index: number): EditorWidget { 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 normalizeWidgets(widgets: EditorWidget[]): EditorWidget[] { + const normalized = widgets.map((widget, index) => normalizeWidget(copyWidget(widget), index)); + for (const viewport of viewports) { + const placed: Array<{ id: string; placement: SpatialPlacement }> = []; + for (let index = 0; index < normalized.length; index += 1) { + const current = layout(normalized[index], viewport); + const next = viewport === 'mobile' ? current : resolvePlacement(current, placed, viewport); + normalized[index] = withLayout(normalized[index], viewport, next); + placed.push({ id: normalized[index].id, placement: next }); + } + } + return normalized; +} + +export function validateDashboardLayouts(widgets: EditorWidget[]): boolean { + for (const viewport of viewports.filter((value) => value !== 'mobile')) { + const visible = widgets.map((widget) => ({ id: widget.id, placement: layout(widget, viewport) })).filter((item) => item.placement.visible !== false); + if (visible.some((item, index) => visible.slice(index + 1).some((other) => overlaps(item.placement, other.placement)))) return false; + } + return true; +} + 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 (!isWidgetType(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 ?? {}; @@ -83,7 +118,12 @@ export function validateWidget(widget: EditorWidget): ValidationErrors { 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); + const thresholds = Array.isArray(visualization.thresholds) ? visualization.thresholds as Array> : []; + if (thresholds.length > 10 || thresholds.some((threshold) => numberValue(threshold.value) === undefined || typeof threshold.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(threshold.color))) errors['visualization.thresholds'] = validation.thresholdsInvalid; + const behavior = widget.behavior ?? {}; + const link = behavior.link; + if (typeof link === 'string' && link !== '' && (link.length > 1000 || !link.startsWith('/') || link.startsWith('//') || /[\u0000-\u001f]/.test(link))) errors['behavior.link'] = validation.linkInvalid; + const interval = numberValue(behavior.liveIntervalSeconds); if (interval !== undefined && (!Number.isInteger(interval) || interval < 1 || interval > 300)) errors['behavior.liveIntervalSeconds'] = validation.intervalRange; return errors; } @@ -91,15 +131,15 @@ export function validateWidget(widget: EditorWidget): ValidationErrors { 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 normalizedInitial = normalizeWidgets(initial); + const [draft, setDraft] = useState(normalizedInitial); 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 [savedSnapshot, setSavedSnapshot] = useState(() => JSON.stringify({ widgets: normalizedInitial, 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); @@ -109,8 +149,8 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav 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 resize = useRef<{ id: string; x: number; y: number; placement: SpatialPlacement } | null>(null); + const drag = useRef<{ id: string; x: number; y: number; placement: SpatialPlacement } | 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); @@ -122,6 +162,13 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav setFuture([]); return next; }); + const mutateWidgetPlacement = (id: string, change: (current: SpatialPlacement, others: Array<{ id: string; placement: SpatialPlacement }>) => SpatialPlacement) => applyDraft((items) => { + const target = items.find((item) => item.id === id); + if (!target || read(target.behavior, 'locked')) return items; + const others = items.filter((item) => item.id !== id).map((item) => ({ id: item.id, placement: layout(item, activeViewport) })); + const next = change(layout(target, activeViewport), others); + return items.map((item) => item.id === id ? withLayout(item, activeViewport, next) : item); + }); 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) => { @@ -133,43 +180,61 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav 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 }; + const widget = draft.find((item) => item.id === id); + if (!widget) return; + drag.current = { id, x: event.clientX, y: event.clientY, placement: layout(widget, activeViewport) }; 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 deltaY = event.clientY - active.y; + if (activeViewport === 'mobile') { + if (Math.abs(deltaY) < 28) return; + move(id, deltaY > 0 ? 1 : -1); + drag.current = { ...active, x: event.clientX, y: event.clientY }; + return; + } + const grid = event.currentTarget.parentElement?.getBoundingClientRect(); + const columnWidth = grid ? grid.width / spatialColumns[activeViewport] : 48; + const dx = Math.round((event.clientX - active.x) / Math.max(24, columnWidth)); + const dy = Math.round(deltaY / 48); + if (dx === 0 && dy === 0) return; + mutateWidgetPlacement(id, (current, others) => movePlacement(current, dx, dy, others, activeViewport)); + drag.current = { id, x: event.clientX, y: event.clientY, placement: { ...active.placement, x: active.placement.x + dx, y: Math.max(0, active.placement.y + dy) } }; }; const onWidgetPointerUp = (event: ReactPointerEvent) => { drag.current = null; setDragged(null); - if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + if (typeof event.currentTarget.hasPointerCapture === 'function' && 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); }; + const add = (type: string) => { const item = newWidget(type, 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 width = Math.min(spatialColumns[activeViewport], Math.max(1, Math.round(next))); + mutateWidgetPlacement(id, (current, others) => resolvePlacement({ ...current, w: width }, others, activeViewport)); + }; + const setWidgetLayoutField = (id: string, field: 'x' | 'y' | 'w' | 'h', value: number) => { + if (!Number.isFinite(value)) return; + mutateWidgetPlacement(id, (current, others) => resolvePlacement({ ...current, [field]: Math.round(value) }, others, activeViewport)); }; const onResizeMove = (event: ReactPointerEvent) => { const active = resize.current; if (!active) return; - setWidgetWidth(active.id, active.w + Math.round((event.clientX - active.x) / 48)); + const dw = Math.round((event.clientX - active.x) / 48); + const dh = Math.round((event.clientY - active.y) / 48); + mutateWidgetPlacement(active.id, (_current, others) => resizePlacement(active.placement, dw, dh, others, activeViewport)); }; // 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; + const max = spatialColumns[activeViewport]; + const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0; if (step === 0 && event.key !== 'Home' && event.key !== 'End') return; event.preventDefault(); event.stopPropagation(); @@ -193,7 +258,7 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav 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))); + setDraft(normalizeWidgets(importedWidgets)); setVariables(importedVariables); setHistory([]); setFuture([]); setMessage(messages.importLoaded); }; @@ -204,7 +269,7 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav 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 next = normalizeWidgets(serverWidgets); 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); } @@ -222,6 +287,7 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav 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 (!validateDashboardLayouts(draft)) { setMessage(messages.saveBlockedLayouts); return; } if (Object.keys(validateVariables(variables)).length > 0) { setMessage(messages.saveBlockedVariables); return; } setSaving(true); setMessage(''); const body = { ...document, widgets: draft, variables }; @@ -236,9 +302,9 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav return

    {shell.eyebrow}

    {shell.title}

    {shell.intro}

    {dirty &&

    {shell.dirty}

    }
    - {conflict &&
    {shell.conflictTitle}{shell.conflictDetail}
    }{confirmExit &&
    {shell.confirmExitTitle}{shell.confirmExitDetail}
    }
    {shell.advanced}
    {message &&

    {message}

    }
    + {conflict &&
    {shell.conflictTitle}{shell.conflictDetail}
    }{confirmExit &&
    {shell.confirmExitTitle}{shell.confirmExitDetail}
    }
    {shell.advanced}
    {shell.addWidget}
    {widgetGroups.map((group) =>

    {group.label}

    {group.types.map((type) => )}
    )}
    {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}

    {cardCopy.resizeHint}

    {Object.keys(errors).length > 0 &&

    {cardCopy.incompleteConfig}

    }

    {locked ? cardCopy.lockedHint : cardCopy.dragHint}

    ; })} -
    {selectedWidget ? setWidgetWidth(selectedWidget.id, value)} onPreviewStateChange={setPreviewState} onPreview={previewSelected} /> : }
    + {draft.map((widget) => { const activeLayout = layout(widget, activeViewport); const locked = read(widget.behavior, 'locked') === true; const viewportHidden = 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}
    {activeViewport !== 'mobile' && <>}

    {cardCopy.resizeHint}

    {Object.keys(errors).length > 0 &&

    {cardCopy.incompleteConfig}

    }

    {locked ? cardCopy.lockedHint : cardCopy.dragHint}

    ; })} +
    {selectedWidget ? (selectedWidget.behavior, 'locked') === true} onChange={updateSelected} onPlacementChange={(field, value) => setWidgetLayoutField(selectedWidget.id, field, value)} onPreviewStateChange={setPreviewState} onPreview={previewSelected} /> : }
    ; } diff --git a/apps/web/src/DashboardManagerPage.tsx b/apps/web/src/DashboardManagerPage.tsx new file mode 100644 index 0000000..8e7fd8f --- /dev/null +++ b/apps/web/src/DashboardManagerPage.tsx @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { formatDateTime } from './locale'; +import { readSessionCapabilities, type SessionCapabilities } from './sessionClient'; +import { archiveDashboard, cloneDashboard, createDashboard, DashboardApiError, listDashboards, listDashboardVersions, restoreDashboardVersion, updateDashboardMetadata, type DashboardSummary, type DashboardVersion } from './dashboardClient'; +import { buildDashboardDocument, dashboardSlug, type DashboardTemplateId } from './dashboardTemplates'; +import { dashboardManagerCopy as t } from './copy'; +import './dashboard-manager.css'; + +type LoadState = 'loading' | 'ready' | 'unauthorized' | 'error'; +function navigate(path: string) { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')); } + +export function DashboardManagerPage() { + const [state, setState] = useState('loading'); + const [items, setItems] = useState([]); + const [session, setSession] = useState(null); + const [showArchived, setShowArchived] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [scope, setScope] = useState<'personal' | 'shared'>('personal'); + const [template, setTemplate] = useState('operations'); + const [busy, setBusy] = useState(''); + const [message, setMessage] = useState(''); + const [manageId, setManageId] = useState(null); + const [editName, setEditName] = useState(''); + const [editDescription, setEditDescription] = useState(''); + const [confirmArchive, setConfirmArchive] = useState(null); + const [versionDashboard, setVersionDashboard] = useState(null); + const [versions, setVersions] = useState([]); + const [versionsLoading, setVersionsLoading] = useState(false); + const [reload, setReload] = useState(0); + + const load = useCallback((signal?: AbortSignal) => { + setState('loading'); + Promise.all([listDashboards(signal), readSessionCapabilities(signal)]).then(([dashboards, capabilities]) => { + setItems(dashboards); setSession(capabilities); setState('ready'); + }).catch((error: unknown) => { + if (error instanceof DOMException && error.name === 'AbortError') return; + setState(error && typeof error === 'object' && 'status' in error && (error as { status?: number }).status === 401 ? 'unauthorized' : 'error'); + }); + }, []); + + useEffect(() => { const controller = new AbortController(); load(controller.signal); return () => controller.abort(); }, [load, reload]); + const canEdit = session?.permissions.edit === true; + const active = useMemo(() => items.filter((item) => !item.archivedAt).sort((a, b) => a.name.localeCompare(b.name, 'nl-BE')), [items]); + const archived = useMemo(() => items.filter((item) => Boolean(item.archivedAt)).sort((a, b) => a.name.localeCompare(b.name, 'nl-BE')), [items]); + + const mutate = async (key: string, action: () => Promise) => { + setBusy(key); setMessage(''); + try { await action(); } + catch (error: unknown) { + if (error instanceof DashboardApiError && error.conflict) { setMessage(t.conflict); setReload((value) => value + 1); } + else setMessage(error instanceof Error && error.message ? error.message : t.failed); + } finally { setBusy(''); } + }; + + const create = () => void mutate('create', async () => { + if (!name.trim()) { setMessage(t.nameRequired); return; } + const created = await createDashboard(buildDashboardDocument({ name, description, scope, template })); + setItems((current) => [...current, created]); setName(''); setDescription(''); setCreateOpen(false); setMessage(t.created); + }); + const beginManage = (item: DashboardSummary) => { setManageId((current) => current === item.id ? null : item.id); setEditName(item.name); setEditDescription(item.description); setConfirmArchive(null); setVersionDashboard(null); setVersions([]); }; + const saveMetadata = (item: DashboardSummary) => void mutate('metadata:' + item.id, async () => { if (!editName.trim()) { setMessage(t.nameRequired); return; } const updated = await updateDashboardMetadata(item, editName.trim(), editDescription.trim()); setItems((current) => current.map((value) => value.id === item.id ? updated : value)); setManageId(null); setMessage(t.updated); }); + const clone = (item: DashboardSummary) => void mutate('clone:' + item.id, async () => { const cloneName = `${item.name} (kopie)`; const created = await cloneDashboard(item, dashboardSlug(cloneName), cloneName); setItems((current) => [...current, created]); setMessage(t.cloned); }); + const archive = (item: DashboardSummary) => void mutate('archive:' + item.id, async () => { const updated = await archiveDashboard(item); setItems((current) => current.map((value) => value.id === item.id ? updated : value)); setConfirmArchive(null); setManageId(null); setMessage(t.archivedDone); }); + const openVersions = async (item: DashboardSummary) => { if (versionDashboard === item.id) { setVersionDashboard(null); setVersions([]); return; } setVersionDashboard(item.id); setVersionsLoading(true); setVersions([]); setMessage(''); try { setVersions(await listDashboardVersions(item)); } catch (error: unknown) { setMessage(error instanceof Error ? error.message : t.failed); } finally { setVersionsLoading(false); } }; + const restore = (item: DashboardSummary, version: number) => void mutate(`restore:${item.id}:${version}`, async () => { const updated = await restoreDashboardVersion(item, version); setItems((current) => current.map((value) => value.id === item.id ? updated : value)); setVersionDashboard(null); setVersions([]); setMessage(t.restored); }); + + if (state === 'loading') return
    ; + if (state === 'unauthorized') return

    {t.unauthorized}

    ; + if (state === 'error') return

    {t.error}

    ; + + const renderDashboard = (item: DashboardSummary) =>
  • {canEdit && !item.archivedAt && }
    {canEdit && manageId === item.id && !item.archivedAt &&