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.
+
+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
+
-```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}
+
Zoek in meldingen { const value = event.target.value; setQuery(value); const params = new URLSearchParams(window.location.search); if (value.trim()) params.set('q', value.trim()); else params.delete('q'); window.history.replaceState(window.history.state, '', window.location.pathname + (params.toString() ? '?' + params.toString() : '')); }} />
{state === 'loading' ?
{copy.alerts.operationsLoading}
: state === 'error' ?
{copy.alerts.operationsError}
: visible.length === 0 ?
{copy.alerts.noActiveAlerts}
:
{visible.map((item) =>
{item.severity === 'critical' ? '!' : '•'} {presentStatus(item.severity)}
void choose(item)}>{item.ruleName || copy.alerts.unnamed} {presentStatus(item.state)}{item.entityName ? ' · ' + item.entityName : ''}
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.
applyTemplate('cpu')}>CPU hoog > 85% gedurende 5 min applyTemplate('storage')}>Storage bijna vol > 90% gedurende 10 min applyTemplate('service')}>Service down Down gedurende 1 min applyTemplate('source')}>Bron stale Telemetrie 2 min verouderd applyTemplate('events')}>Event burst 3+ events in 15 min
}
+
1 Signaal 2 Voorwaarde 3 Timing 4 Veiligheid 5 Preview
{copy.alerts.name} updateField('name', event.target.value)} maxLength={160} />
{copy.alerts.severity} updateField('severity', event.target.value)}>{copy.presentation.status.attention} {copy.presentation.status.degraded} {copy.presentation.status.critical}
@@ -226,7 +259,7 @@ export function AlertRulesPage() {
void save()}>{copy.alerts.save} {selected && void toggle(!selected.enabled)}>{selected.enabled ? copy.alerts.disable : copy.alerts.enable} }
{message &&
{message}
}
-
{copy.alerts.preview}
{copy.alerts.previewTitle} {copy.alerts.previewDetail}
{copy.alerts.sampleValue} setPreviewValue(event.target.value)} /> void testPreview()}>{copy.alerts.runPreview} {preview &&
{presentStatus(preview.state)}: {presentReason(preview.reason)}
}
+
{copy.alerts.preview}
{copy.alerts.previewTitle} {copy.alerts.previewDetail}
{copy.alerts.sampleValue} setPreviewValue(event.target.value)} /> void testPreview()}>{copy.alerts.runPreview} {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); }}>{item.icon} {label} ;
-}
-function DesktopNavigation({ route }: { route: RoutePath }) {
- const activeGroup = navigation.find((item) => route === item.path || (item.path !== '/' && route.startsWith(item.path + '/')))?.group ?? navigationGroups[0];
- const [openGroups, setOpenGroups] = useState>(() => new Set([navigationGroups[0], activeGroup]));
- useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]);
- return {navigationGroups.map((group) => {
- const items = navigation.filter((item) => item.group === group);
- return { const open = event.currentTarget.open; setOpenGroups((current) => { if (current.has(group) === open) return current; const next = new Set(current); if (open) next.add(group); else next.delete(group); return next; }); }}>{group} ⌄ ;
- })} ;
-}
-function navigate(path: string) {
- window.history.pushState({}, '', path);
- window.dispatchEvent(new PopStateEvent('popstate'));
-}
-
function StatusBadge({ label, tone = 'unknown' }: { label: string; tone?: 'unknown' | 'ready' }) {
return {tone === 'ready' ? '✓' : '?'} {label} ;
}
@@ -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) => {index === 0 ? '!' : '?'} {problem.label} {problem.reason} )} : {resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}
}{overviewNeedsAuthentication && } { refreshSystemStatus(); refreshOverview(); }}>{copy.overview.retry} navigate('/status')}>{copy.overview.openStatus}
+ {copy.overview.actionQueue}
{copy.overview.problems} {problems.length} {problems.length ? {problems.map((problem, index) => navigate(problem.route)}>{index === 0 ? '!' : '?'} {problem.label} {problem.reason} → )} : {resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}
}{overviewNeedsAuthentication && } { refreshSystemStatus(); refreshOverview(); }}>{copy.overview.retry} navigate('/status')}>{copy.overview.openStatus}
- {copy.overview.storagePools}
{copy.overview.capacity} navigate('/pools')}>{copy.overview.viewAll} {overview.resources.pools !== 'ready' ? {resourceStateDetail(overview.resources.pools)}
: poolSourceTone !== 'healthy' ? {copy.overview.resourceStaleDetail}
: overview.pools.length ? {overview.pools.slice(0, 5).map((pool) => { const state = operationalStorageState(pool.state, pool.capacitySeverity); return {pool.name} {presentStatus(state)} · device-health {presentStatus(pool.state).toLowerCase()} {metric(pool.utilizationPercent)} ; })} : {copy.overview.noPools}
}
+ {copy.overview.storagePools}
{copy.overview.capacity} navigate('/pools')}>{copy.overview.viewAll} {overview.resources.pools !== 'ready' ? {resourceStateDetail(overview.resources.pools)}
: poolSourceTone !== 'healthy' ? {copy.overview.resourceStaleDetail}
: overview.pools.length ? {overview.pools.slice(0, 5).map((pool) => { const state = operationalStorageState(pool.state, pool.capacitySeverity); return navigate('/pools/' + encodeURIComponent(pool.id))}>{pool.name} {presentStatus(state)} · device-health {presentStatus(pool.state).toLowerCase()} {metric(pool.utilizationPercent)} → ; })} : {copy.overview.noPools}
}
Nu
{copy.navigation.containers} navigate('/containers')}>{copy.overview.viewAll} {containersUsable ? `${overview.containersPartial ? '≥' : ''}${runningContainers}` : '—'} {copy.overview.running} {containersUsable && !overview.containersPartial ? Math.max(0, overview.containerTotal - runningContainers) : '—'} {copy.overview.other}
{overview.resources.containers !== 'ready' ? resourceStateDetail(overview.resources.containers) : containerSourceTone !== 'healthy' ? copy.overview.resourceStaleDetail : overview.containersPartial ? copy.overview.resourcePartialDetail : overview.containerTotal > 0 ? `${runningContainers} van ${overview.containerTotal} ${copy.overview.containersRunning}.` : copy.overview.signalPathNoWorkloads}
{copy.overview.cpu} {hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'}
{copy.overview.memory} {hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'}
Bron {hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}
- {copy.overview.recentIncidents}
{copy.navigation.incidents} navigate('/incidents')}>{copy.overview.viewAll} {overview.resources.incidents !== 'ready' ? {resourceStateDetail(overview.resources.incidents)}
: orderedIncidents.length ? {orderedIncidents.slice(0, 5).map((incident) => {incident.title} {formatDateTime(incident.startedAt)} )} : {copy.overview.noIncidents}
}
+ {copy.overview.recentIncidents}
{copy.navigation.incidents} navigate('/incidents')}>{copy.overview.viewAll} {overview.resources.incidents !== 'ready' ? {resourceStateDetail(overview.resources.incidents)}
: orderedIncidents.length ? {orderedIncidents.slice(0, 5).map((incident) => navigate('/incidents/' + encodeURIComponent(incident.id))}>{incident.title} {formatDateTime(incident.startedAt)} → )} : {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 && navigate('/dashboards')}>← {copy.dashboards.back} }{copy.dashboards.viewMode}
{wallboard ?
{summary.name} :
{summary.name} }
{summary.description || copy.dashboards.noDescription}
{wallboard && {copy.wallboard.readOnly} }{copy.dashboards.version} {summary.currentVersion} {!wallboard && setEditing(true)}>{copy.dashboards.edit} }
{!wallboard && {copy.dashboards.timeRange} setTimeRange(event.target.value)}>{copy.dashboards.live} 15 {copy.dashboards.minutes} 1 {copy.dashboards.hour} 6 {copy.dashboards.hours} 24 {copy.dashboards.hours} 7 {copy.dashboards.days} {copy.dashboards.filter} setFilter(event.target.value)} placeholder={copy.dashboards.filterPlaceholder} /> {crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView} {usableCount} van {shown.length} {copy.dashboards.widgetsWithData} {crossFilter && {copy.dashboards.clearFilter} }
}{shown.length === 0 ? {copy.dashboards.noMatchingWidgets} {copy.dashboards.clearFilterHint}
: <>{shown.map((widget) => {['semantic-metric', 'inventory', 'events'].includes(String(field(widget.data, 'sourceType') ?? '')) ? applyCrossFilter(widget)} /> : } )}
>} ;
+ const usableCount = Object.values(runtimeStates).filter((value) => value === 'usable' || value === 'partial' || value === 'stale').length;
+ return {!wallboard && navigate('/dashboards')}>← {copy.dashboards.back} }{copy.dashboards.viewMode}
{wallboard ?
{summary.name} :
{summary.name} }
{summary.description || copy.dashboards.noDescription}
{wallboard && {copy.wallboard.readOnly} }{copy.dashboards.version} {summary.currentVersion} {!wallboard && canEdit && setEditing(true)}>{copy.dashboards.edit} }
{!wallboard && {copy.dashboards.timeRange} setTimeRange(event.target.value)}>{copy.dashboards.live} 15 {copy.dashboards.minutes} 1 {copy.dashboards.hour} 6 {copy.dashboards.hours} 24 {copy.dashboards.hours} 7 {copy.dashboards.days} {copy.dashboards.filter} setFilter(event.target.value)} placeholder={copy.dashboards.filterPlaceholder} /> {crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView} {usableCount} van {shown.length} {copy.dashboards.widgetsWithData} {crossFilter && {copy.dashboards.clearFilter} }
}{shown.length === 0 ? {copy.dashboards.noMatchingWidgets} {copy.dashboards.clearFilterHint}
: <>{shown.map((widget) => {['semantic-metric', 'inventory', 'events', '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) => {copy.settings.management}
{group.title} {group.detail}
)}
- >;
-}
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 ;
+ 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); }}>{item.icon} {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 {navigationGroups.map((group) => {
+ const items = primaryNavigation.filter((item) => item.group === group);
+ return { const open = event.currentTarget.open; setOpenGroups((current) => { if (current.has(group) === open) return current; const next = new Set(current); if (open) next.add(group); else next.delete(group); return next; }); }}>{group} ⌄ ;
+ })} ;
+}
+
+function ShellStatusBadge({ label, tone }: { label: string; tone: 'unknown' | 'ready' }) {
+ return {tone === 'ready' ? '✓' : '?'} {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 &&
navigate(backTarget)} aria-label="Terug naar vorige context">← Terug }
+
+ { event.preventDefault(); navigate('/'); }}>Pulse /
+ navigate(sectionItems.find((entry) => entry.primary)?.path ?? '/')}>{sectionLabels[item.section]} /
+ {pageLabel}
+
+
+
+ toggleFavorite(pageLabel, currentPath)} title={favorite ? 'Verwijder uit favorieten' : 'Voeg toe aan favorieten'}>☆ {favorite ? 'Favoriet' : 'Favoriet'}
+ saved ? removeSavedView(currentPath) : saveCurrentView(pageLabel, currentPath)} title={saved ? 'Verwijder opgeslagen view' : 'Bewaar huidige filters en view'}>▣ {saved ? 'View bewaard' : 'Bewaar view'}
+
+
{sectionItems.map((entry) => {
+ const active = matchesRoute(route, entry.path);
+ return { event.preventDefault(); navigate(entry.path); }}>{entry.label} ;
+ })}
+
;
+}
+
+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 ;
+}
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) => navigate(componentRoute(item))}>{item.name} {item.kind} · {item.critical ? copy.applications.critical : copy.applications.optional} Container: {item.containerState || '—'} · service: {item.serviceState || '—'} {item.reason || item.id} → )} }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.source}
{detail.source?.id || copy.applications.unknown} {app.reasons?.map((reason) => {presentReason(reason.code)}
)} >;
+ return <>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.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
- {conflict && {shell.conflictTitle} {shell.conflictDetail} {shell.reloadServer}
}{confirmExit && {shell.confirmExitTitle} {shell.confirmExitDetail} setConfirmExit(false)}>{shell.keepEditing} {shell.leaveWithoutSaving}
}{shell.advanced} {shell.layout} setActiveViewport(event.target.value as Viewport)}>{viewports.map((viewport) => {viewportLabels[viewport]} )} {shell.addWidget} setNewType(event.target.value)}>{types.map((type) => {labels[type]} )} {shell.addWidget} {message &&
{message}
}
+ {conflict && {shell.conflictTitle} {shell.conflictDetail} {shell.reloadServer}
}{confirmExit && {shell.confirmExitTitle} {shell.confirmExitDetail} setConfirmExit(false)}>{shell.keepEditing} {shell.leaveWithoutSaving}
}{shell.advanced} {shell.layout} setActiveViewport(event.target.value as Viewport)}>{viewports.map((viewport) => {viewportLabels[viewport]} )} {shell.addWidget} {widgetGroups.map((group) =>
{group.label}
{group.types.map((type) => { add(type); const details = event.currentTarget.closest('details'); if (details) details.open = false; }}>{widgetGlyphs[type] ?? '□'} {labels[type]} {type === 'timeseries' ? 'Trend en historische vergelijking' : type === 'stat' ? 'Eén dominante waarde' : type === 'gauge' ? 'Waarde tegen bereik en drempels' : type === 'status-grid' ? 'Veel statussen in één scan' : type === 'ranked-list' ? 'Gesorteerde operationele lijst' : type === 'alert-summary' ? 'Actieve meldingen op prioriteit' : type === 'event-timeline' ? 'Gebeurtenissen in tijd' : type === 'storage-map' ? 'Array, pools en disks' : type === 'service-matrix' ? 'Servicebeschikbaarheid' : type === 'topology' ? 'Dependencyrelaties' : type === 'text' ? 'Context en toelichting' : 'Geavanceerde visualisatie'} )}
)}
{message &&
{message}
}
{shell.canvas}
- {draft.map((widget) => { const activeLayout = layout(widget, activeViewport); const locked = read
(widget.behavior, 'locked') === true; const viewportHidden = read(activeLayout, 'visible') === false; const hidden = read(widget.behavior, 'hidden') === true || viewportHidden; const errors = validateWidget(widget); const width = layoutWidth(widget, activeViewport); return onWidgetPointerDown(event, widget.id, locked)} onPointerMove={(event) => onWidgetPointerMove(event, widget.id)} onPointerUp={onWidgetPointerUp} onPointerCancel={onWidgetPointerUp} onClick={() => { setSelected(widget.id); setPreview(null); setPreviewFor(null); }}>{labels[widget.type] ?? widgetTypeCopy.fallback}
{widget.title || widgetTypeCopy.untitled} {locked ? cardCopy.locked : cardCopy.movable} { event.stopPropagation(); move(widget.id, -1); }}>{cardCopy.moveUpVisible} { event.stopPropagation(); move(widget.id, 1); }}>{cardCopy.moveDownVisible} { event.stopPropagation(); update(widget.id, (item) => ({ ...item, behavior: { ...(item.behavior ?? {}), locked: !locked } })); }}>{locked ? cardCopy.unlock : cardCopy.lock} { event.stopPropagation(); update(widget.id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), visible: viewportHidden })); }}>{viewportHidden ? cardCopy.show : cardCopy.hide} { event.stopPropagation(); duplicate(widget.id); }}>{cardCopy.duplicate} { event.stopPropagation(); remove(widget.id); }}>{cardCopy.remove} onResizeKeyDown(event, widget.id, width)} onPointerDown={(event) => { event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); resize.current = { id: widget.id, x: event.clientX, w: width }; }} onPointerMove={onResizeMove} onPointerUp={() => { resize.current = null; }}>{cardCopy.resizeVisible}
{cardCopy.resizeHint}
{Object.keys(errors).length > 0 && {cardCopy.incompleteConfig}
}{locked ? cardCopy.lockedHint : cardCopy.dragHint}
; })}
- {selectedWidget ?
setWidgetWidth(selectedWidget.id, value)} onPreviewStateChange={setPreviewState} onPreview={previewSelected} /> : }
+ {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} { event.stopPropagation(); if (activeViewport === 'mobile') move(widget.id, -1); else mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 0, -1, others, activeViewport)); }}>{cardCopy.moveUpVisible} { event.stopPropagation(); if (activeViewport === 'mobile') move(widget.id, 1); else mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 0, 1, others, activeViewport)); }}>{cardCopy.moveDownVisible} {activeViewport !== 'mobile' && <> { event.stopPropagation(); mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, -1, 0, others, activeViewport)); }}>{cardCopy.moveLeftVisible} { event.stopPropagation(); mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 1, 0, others, activeViewport)); }}>{cardCopy.moveRightVisible} >} { event.stopPropagation(); update(widget.id, (item) => ({ ...item, behavior: { ...(item.behavior ?? {}), locked: !locked } })); }}>{locked ? cardCopy.unlock : cardCopy.lock} { event.stopPropagation(); update(widget.id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), visible: viewportHidden })); }}>{viewportHidden ? cardCopy.show : cardCopy.hide} { event.stopPropagation(); duplicate(widget.id); }}>{cardCopy.duplicate} { event.stopPropagation(); remove(widget.id); }}>{cardCopy.remove} onResizeKeyDown(event, widget.id, width)} onPointerDown={(event) => { event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); resize.current = { id: widget.id, x: event.clientX, y: event.clientY, placement: activeLayout }; }} onPointerMove={onResizeMove} onPointerUp={() => { resize.current = null; }}>{cardCopy.resizeVisible}
{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 ;
+ if (state === 'error') return {t.error} setReload((value) => value + 1)}>{t.retry} ;
+
+ const renderDashboard = (item: DashboardSummary) => navigate('/dashboards/' + encodeURIComponent(item.id))}>{item.name} {item.description || item.slug} {item.scope === 'personal' ? t.personal : item.scope === 'shared' ? t.shared : t.system} v{item.currentVersion} · r{item.revision} {item.archivedAt && {t.archivedBadge} } {canEdit && !item.archivedAt && beginManage(item)}>{manageId === item.id ? t.closeManage : t.manage} }
{canEdit && manageId === item.id && !item.archivedAt && clone(item)}>{t.clone} void openVersions(item)}>{t.loadVersions} {item.scope === 'system' ? {t.systemProtected}
: setConfirmArchive(item.id)}>{t.archive} }{confirmArchive === item.id &&
{t.archiveConfirm} {t.archiveConfirmDetail}
setConfirmArchive(null)}>{t.cancel} archive(item)}>{t.confirmArchive} }{versionDashboard === item.id &&
{versionsLoading ? {t.loading}
: versions.length === 0 ? {t.noVersions}
: {versions.map((version) => v{version.number}{version.number === item.currentVersion ? ` · ${t.currentVersion}` : ''} {version.createdAt ? formatDateTime(version.createdAt) : ''} · {version.changeSummary || '—'} · {t.changedBy} {version.createdBy || t.unknownActor} {version.number !== item.currentVersion && restore(item, version.number)}>{t.restore} } )} } }
} ;
+
+ return {t.eyebrow}
{t.title} {t.intro}
{canEdit && setCreateOpen((value) => !value)}>{createOpen ? t.cancel : t.create} } {!canEdit && {t.viewerTitle} {t.viewerDetail}
}{canEdit && createOpen && }{message && {message}
}{t.active}
{active.length} {t.active.toLowerCase()} {archived.length > 0 &&
setShowArchived((value) => !value)}>{showArchived ? t.hideArchived : `${t.showArchived} (${archived.length})`} }
{active.length ? {active.map(renderDashboard)} : {t.noActive}
} {showArchived && {t.archived}
{archived.length} {t.archived.toLowerCase()} {archived.length ? {archived.map(renderDashboard)} : {t.noArchived}
} } ;
+}
diff --git a/apps/web/src/DashboardRuntimeWidget.tsx b/apps/web/src/DashboardRuntimeWidget.tsx
index 571951c..74d03b8 100644
--- a/apps/web/src/DashboardRuntimeWidget.tsx
+++ b/apps/web/src/DashboardRuntimeWidget.tsx
@@ -1,9 +1,9 @@
-import { useEffect, useMemo, useState, type CSSProperties } from 'react';
+import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
import { resolveDashboardScope } from './dashboardScope';
import { LiveChartAdapter, historicalSamplesFromData, type LiveFreshness } from './liveBuffer';
import { LiveClient } from './liveClient';
-import { MetricClient, rangeForPreset, type MetricQueryRequest, type MetricRangePreset } from './metricClient';
-import { MetricWidget, StatusGridWidget, type MetricWidgetProps, type StatusGridItem } from './MetricWidgets';
+import { MetricClient, rangeForSelection, type MetricQueryRequest } from './metricClient';
+import { MetricWidget, RankedListWidget, StatusGridWidget, type MetricWidgetProps, type RankedListItem, type StatusGridItem } from './MetricWidgets';
import { buildStorageNodes, type StorageData } from './StoragePage';
import { StorageMapWidget } from './StorageVisuals';
import { aggregateStatus, useSystemStatus } from './systemStatus';
@@ -12,75 +12,115 @@ import { useMetricQuery } from './useMetricQuery';
import { formatDateTime } from './locale';
import { copy } from './copy';
import { presentEventSummary, presentEventType, presentReason, presentStatus } from './presentation';
-import { wallboardColumns, wallboardPlacement } from './wallboardLayout';
+import { useOperatorContext } from './operatorContext';
+import { wallboardPlacement } from './wallboardLayout';
+import { spatialStyle } from './dashboardSpatial';
+import { isWidgetType, widgetLabel } from './widgetCatalog';
type RecordValue = Record;
export type RuntimeWidget = { id: string; type: string; title: string; description?: string; data?: RecordValue; visualization?: RecordValue; behavior?: RecordValue; layouts?: RecordValue };
-export type RuntimeState = 'loading' | 'usable' | 'empty' | 'error';
+export type RuntimeState = 'loading' | 'usable' | 'empty' | 'stale' | 'partial' | 'error' | 'capability-disabled';
+type RuntimeRow = { id: string; label: string; status: string; value?: string; detail?: string; occurredAt?: string; severity?: string; acknowledged?: boolean };
+type EventItem = { id: string; type: string; severity: string; summary: string; occurredAt: string; sourceId?: string };
+type Resource = { state: RuntimeState; rows?: RuntimeRow[]; storage?: StorageData; events?: EventItem[]; text?: string; error?: string };
const metricClient = new MetricClient();
const liveClient = new LiveClient();
+const metricTypes = new Set(['stat', 'timeseries', 'gauge', 'query-inspector']);
function field(record: RecordValue | undefined, name: string): T | undefined {
if (!record) return undefined;
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
}
+function safeLimit(widget: RuntimeWidget): number { return Math.min(100, Math.max(1, Math.trunc(Number(field(widget.data, 'limit') ?? 100) || 100))); }
+function safeInterval(widget: RuntimeWidget): number { return Math.min(300, Math.max(5, Math.trunc(Number(field(widget.behavior, 'liveIntervalSeconds') ?? 30) || 30))) * 1000; }
+function stringValue(value: unknown, fallback = ''): string { return typeof value === 'string' ? value.slice(0, 500) : fallback; }
function Badge({ state }: { state: RuntimeState }) {
- const usable = state === 'usable';
- const label = usable ? copy.dashboards.runtime.current : state === 'loading' ? copy.dashboards.runtime.loading : state === 'empty' ? copy.dashboards.runtime.empty : copy.dashboards.runtime.error;
- return {usable ? '✓' : '?'} {label} ;
+ const labels: Record = {
+ usable: copy.dashboards.runtime.current, loading: copy.dashboards.runtime.loading, empty: copy.dashboards.runtime.empty,
+ stale: copy.dashboards.runtime.stale, partial: copy.dashboards.runtime.partial, error: copy.dashboards.runtime.error,
+ 'capability-disabled': copy.dashboards.runtime.capabilityDisabled,
+ };
+ const ready = state === 'usable';
+ return {ready ? '✓' : '?'} {labels[state]} ;
}
-type Resource = { state: RuntimeState; items?: StatusGridItem[]; storage?: StorageData; events?: EventItem[]; error?: string };
-type EventItem = { id: string; type: string; severity: string; summary: string; occurredAt: string; sourceId?: string };
+async function readJSON(url: string, signal: AbortSignal): Promise {
+ const response = await fetch(url, { signal, cache: 'no-store' });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json() as Promise;
+}
-function useInventoryResource(widget: RuntimeWidget): Resource {
- const [resource, setResource] = useState({ state: 'loading' });
+function useBoundedResource(widget: RuntimeWidget): Resource {
const sourceType = String(field(widget.data, 'sourceType') ?? '');
const scope = field(widget.data, 'scope') ?? {};
const entityType = String(field(scope, 'entityType') ?? '');
- const isApplications = sourceType === 'inventory' && entityType === 'application';
- const isStorage = sourceType === 'inventory' && Array.isArray(field(scope, 'entityTypes'));
- const isEvents = sourceType === 'events';
- const interval = Math.min(300, Math.max(5, Number(field(widget.behavior, 'liveIntervalSeconds') ?? 15))) * 1000;
+ const entityTypes = field(scope, 'entityTypes');
+ const limit = safeLimit(widget);
+ const interval = safeInterval(widget);
+ const rawText = field(widget.data, 'text');
+ const text = typeof rawText === 'string' ? rawText.slice(0, 5000) : '';
+ const key = JSON.stringify({ sourceType, entityType, entityTypes, limit, interval, type: widget.type, text });
+ const [resource, setResource] = useState(() => sourceType === 'text' ? { state: text.trim() ? 'usable' : 'empty', text } : { state: 'loading' });
useEffect(() => {
- if (!isApplications && !isStorage && !isEvents) {
- setResource({ state: 'empty' });
- return undefined;
- }
+ if (sourceType === 'semantic-metric' || (sourceType === 'inventory' && entityType === 'server')) return undefined;
+ if (sourceType === 'text') { setResource({ state: text.trim() ? 'usable' : 'empty', text }); return undefined; }
+ if (!['inventory', 'events', 'alerts', 'incidents'].includes(sourceType)) { setResource({ state: 'capability-disabled' }); return undefined; }
let active = true;
let controller: AbortController | null = null;
- const read = async (url: string, signal: AbortSignal): Promise => {
- const response = await fetch(url, { signal, cache: 'no-store' });
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
- return response.json() as Promise;
- };
const load = async () => {
controller?.abort();
const current = new AbortController();
controller = current;
try {
- if (isApplications) {
- const value = await read<{ applications?: Array<{ id: string; name: string; status: string; reasons?: Array<{ message?: string }> }> }>('/api/v1/applications', current.signal);
- const items = (value.applications ?? []).slice(0, 50).map((item) => ({ id: item.id, label: item.name, status: presentStatus(item.status), reason: presentReason(item.reasons?.[0]?.message) }));
- if (active) setResource({ state: items.length ? 'usable' : 'empty', items });
- } else if (isStorage) {
+ if (sourceType === 'inventory' && Array.isArray(entityTypes)) {
const [array, disks, pools] = await Promise.all([
- read('/api/v1/array', current.signal),
- read('/api/v1/disks?limit=100', current.signal),
- read('/api/v1/pools?limit=100', current.signal),
+ readJSON('/api/v1/array', current.signal),
+ readJSON('/api/v1/disks?limit=' + limit, current.signal),
+ readJSON('/api/v1/pools?limit=' + limit, current.signal),
]);
const storage = { array, disks, pools };
if (active) setResource({ state: buildStorageNodes(storage).length ? 'usable' : 'empty', storage });
- } else {
- const limit = Math.min(100, Math.max(1, Number(field(widget.data, 'limit') ?? 100)));
- const value = await read<{ items?: EventItem[] }>('/api/v1/events?limit=' + limit, current.signal);
+ return;
+ }
+ if (sourceType === 'inventory' && entityType === 'application') {
+ const value = await readJSON<{ applications?: Array }>('/api/v1/applications', current.signal);
+ const rows = (value.applications ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.name, stringValue(item.id)), status: stringValue(item.status, 'unknown'), detail: presentReason(stringValue(field>(item, 'reasons')?.[0]?.message)) }));
+ if (active) setResource({ state: rows.length ? 'usable' : 'empty', rows });
+ return;
+ }
+ if (sourceType === 'inventory' && (entityType === 'service' || widget.type === 'service-matrix')) {
+ const value = await readJSON<{ services?: Array; total?: number }>('/api/v1/services?limit=' + limit, current.signal);
+ const rows = (value.services ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.name, stringValue(item.id)), status: stringValue(item.state, 'unknown'), value: field(item, 'responseTimeMs') == null ? '—' : `${field(item, 'responseTimeMs')} ms`, detail: field(item, 'availabilityPercent') == null ? stringValue(item.reason) : `${field(item, 'availabilityPercent')?.toFixed(2)}%` }));
+ if (active) setResource({ state: (value.total ?? rows.length) > rows.length ? 'partial' : rows.length ? 'usable' : 'empty', rows });
+ return;
+ }
+ if (sourceType === 'inventory') {
+ const query = new URLSearchParams({ limit: String(limit) });
+ if (entityType) query.set('type', entityType);
+ const value = await readJSON<{ items?: Array; hasMore?: boolean }>('/api/v1/entities?' + query, current.signal);
+ const rows = (value.items ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.displayName, stringValue(item.canonicalName, stringValue(item.id))), status: stringValue(item.status, 'unknown'), value: String(field(item, 'factCount') ?? ''), detail: stringValue(item.entityType) }));
+ if (active) setResource({ state: value.hasMore ? 'partial' : rows.length ? 'usable' : 'empty', rows });
+ return;
+ }
+ if (sourceType === 'events') {
+ const value = await readJSON<{ items?: EventItem[] }>('/api/v1/events?limit=' + limit, current.signal);
const events = (value.items ?? []).slice(0, limit);
if (active) setResource({ state: events.length ? 'usable' : 'empty', events });
+ return;
}
+ if (sourceType === 'alerts') {
+ const value = await readJSON<{ items?: Array }>('/api/v1/alerts?limit=' + limit, current.signal);
+ const rows = (value.items ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.ruleName, stringValue(item.id)), status: stringValue(item.state, 'unknown'), severity: stringValue(item.severity, 'unknown'), value: stringValue(item.entityName, '—'), detail: stringValue(item.reason), occurredAt: stringValue(item.updatedAt), acknowledged: Boolean(item.acknowledgedBy) }));
+ if (active) setResource({ state: rows.length ? 'usable' : 'empty', rows });
+ return;
+ }
+ const value = await readJSON<{ items?: Array }>('/api/v1/incidents?limit=' + limit, current.signal);
+ const rows = (value.items ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.title, stringValue(item.id)), status: stringValue(item.status, 'unknown'), severity: stringValue(item.severity, 'unknown'), value: stringValue(item.ownerUserId, '—'), detail: stringValue(item.summary), occurredAt: stringValue(item.updatedAt) }));
+ if (active) setResource({ state: rows.length ? 'usable' : 'empty', rows });
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (active) setResource({ state: 'error', error: error instanceof Error ? error.message : 'bron niet beschikbaar' });
@@ -89,23 +129,55 @@ function useInventoryResource(widget: RuntimeWidget): Resource {
void load();
const timer = window.setInterval(load, interval);
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
- }, [isApplications, isStorage, isEvents, interval, widget.data]);
+ }, [key, sourceType, entityType, entityTypes, limit, interval, text, widget.type]);
return resource;
}
+function RuntimeNotice({ state }: { state: RuntimeState }) {
+ const title = state === 'error' ? copy.dashboards.runtime.sourceUnavailable : state === 'loading' ? copy.dashboards.runtime.telemetryLoading : state === 'capability-disabled' ? copy.dashboards.runtime.capabilityDisabled : copy.dashboards.runtime.noCurrentData;
+ const detail = state === 'error' ? copy.dashboards.runtime.unavailableDetail : state === 'capability-disabled' ? 'Kies een passend widgettype of een andere datasource.' : copy.dashboards.runtime.retryDetail;
+ return {title} {detail}
;
+}
+function RuntimeTable({ rows, service = false }: { rows: RuntimeRow[]; service?: boolean }) {
+ return {rows.length} {copy.dashboards.runtime.rows} Naam Status {service ? 'Respons / beschikbaarheid' : 'Waarde'} {rows.slice(0, 100).map((row) => {row.label}{row.detail} {presentStatus(row.status)} {row.value || '—'} )}
;
+}
+function AlertSummary({ rows }: { rows: RuntimeRow[] }) {
+ const count = (severity: string) => rows.filter((row) => row.severity === severity && row.status !== 'resolved').length;
+ return
Kritiek {count('critical')}
Waarschuwing {count('warning')}
Actief {rows.filter((row) => row.status !== 'resolved').length}
Bevestigd {rows.filter((row) => row.acknowledged).length} ;
+}
+function safeInline(value: string): ReactNode[] {
+ const parts: ReactNode[] = [];
+ const pattern = /\[([^\]]{1,120})\]\((\/[^)\s]*|https:\/\/[^)\s]+)\)/g;
+ let cursor = 0; let match: RegExpExecArray | null;
+ while ((match = pattern.exec(value)) !== null) { if (match.index > cursor) parts.push(value.slice(cursor, match.index)); parts.push({match[1]} ); cursor = match.index + match[0].length; }
+ if (cursor < value.length) parts.push(value.slice(cursor));
+ return parts;
+}
+function TextWidget({ text }: { text: string }) { return {text.slice(0, 5000).split(/\r?\n/).slice(0, 100).filter(Boolean).map((line, index) =>
{safeInline(line.replace(/^#{1,3}\s+/, '').replace(/^[-*]\s+/, '• '))}
)}
; }
+
+function sourceLabel(sourceType: string): string {
+ if (sourceType === 'semantic-metric') return copy.dashboards.runtime.semanticMetric;
+ if (sourceType === 'events') return copy.dashboards.runtime.events;
+ if (sourceType === 'alerts') return copy.dashboards.runtime.alerts;
+ if (sourceType === 'incidents') return copy.dashboards.runtime.incidents;
+ if (sourceType === 'text') return copy.dashboards.runtime.text;
+ return copy.dashboards.runtime.inventory;
+}
+
export function DashboardRuntimeWidget({ widget, viewport, document, timeRange, onState, onFilter }: { widget: RuntimeWidget; viewport: 'desktop' | 'tablet' | 'mobile' | 'wallboard'; document: RecordValue; timeRange: string; onState: (id: string, state: RuntimeState) => void; onFilter: () => void }) {
const sourceType = String(field(widget.data, 'sourceType') ?? '');
const semantic = sourceType === 'semantic-metric';
const system = useSystemStatus();
- const inventory = useInventoryResource(widget);
- const range = useMemo(() => rangeForPreset(timeRange as MetricRangePreset), [timeRange]);
+ const resource = useBoundedResource(widget);
+ const operator = useOperatorContext();
+ if (operator.paused && viewport !== 'wallboard' && timeRange === 'live') timeRange = 'paused';
+ const range = useMemo(() => rangeForSelection(timeRange), [timeRange, operator.refreshVersion]);
const request = useMemo(() => {
- if (!semantic) return null;
+ if (!semantic || !metricTypes.has(widget.type)) return null;
const metric = String(field(widget.data, 'metric') ?? '');
if (!metric) return null;
- const scope = resolveDashboardScope(field(widget.data, 'scope') ?? {}, field(document, 'variables') ?? []);
- return { metric, scope, range, aggregation: String(field(widget.data, 'aggregation') ?? 'avg') };
- }, [semantic, widget.data, document, range]);
+ return { metric, scope: resolveDashboardScope(field(widget.data, 'scope') ?? {}, field(document, 'variables') ?? []), range, aggregation: String(field(widget.data, 'aggregation') ?? 'avg') };
+ }, [semantic, widget.type, widget.data, document, range]);
const metricState = useMetricQuery(metricClient, request);
const historicalSamples = useMemo(() => metricState.status === 'success' ? historicalSamplesFromData(metricState.response?.data) : [], [metricState.status, metricState.response?.data]);
const freshness: LiveFreshness = metricState.response?.freshness ?? 'unavailable';
@@ -113,29 +185,36 @@ export function DashboardRuntimeWidget({ widget, viewport, document, timeRange,
const live = useLiveMetric(liveClient, semantic && timeRange === 'live' ? request : null, historicalSamples);
const systemWidget = sourceType === 'inventory' && String(field(field(widget.data, 'scope'), 'entityType') ?? '') === 'server';
const systemStatus = aggregateStatus(system);
- const metricRuntime: RuntimeState = metricState.status === 'error' || (timeRange === 'live' && live.state === 'error') ? 'error' : (timeRange === 'live' ? live.series.length : historicalSeries.length) > 0 ? 'usable' : metricState.status === 'loading' || (timeRange === 'live' && live.state === 'connecting') ? 'loading' : 'empty';
- const runtime = semantic ? metricRuntime : systemWidget ? (system.state === 'loading' ? 'loading' : system.state === 'ready' ? 'usable' : 'error') : inventory.state;
+ const metricRuntime: RuntimeState = !metricTypes.has(widget.type) ? 'capability-disabled' : metricState.status === 'error' || (timeRange === 'live' && live.state === 'error') ? 'error' : (timeRange === 'live' ? live.series.length : historicalSeries.length) > 0 ? (freshness === 'stale' ? 'stale' : 'usable') : metricState.status === 'loading' || (timeRange === 'live' && live.state === 'connecting') ? 'loading' : 'empty';
+ let runtime: RuntimeState = semantic ? metricRuntime : systemWidget ? (system.state === 'loading' ? 'loading' : system.state === 'ready' ? 'usable' : 'error') : resource.state;
+ if (!isWidgetType(widget.type)) runtime = 'error';
+ const rows = resource.rows ?? [];
+ const compatible = semantic ? metricTypes.has(widget.type)
+ : systemWidget ? ['stat', 'status-grid'].includes(widget.type)
+ : sourceType === 'text' ? widget.type === 'text'
+ : resource.storage ? widget.type === 'storage-map'
+ : sourceType === 'events' ? widget.type === 'event-timeline'
+ : ['stat', 'ranked-list', 'status-grid', 'table', 'event-timeline', 'service-matrix', 'alert-summary'].includes(widget.type);
+ if (isWidgetType(widget.type) && !compatible && runtime !== 'error') runtime = 'capability-disabled';
useEffect(() => { onState(widget.id, runtime); }, [onState, runtime, widget.id]);
const activeLayout = field(widget.layouts, viewport) ?? field(widget.layouts, 'desktop') ?? {};
- const columns = viewport === 'wallboard' ? wallboardColumns : viewport === 'tablet' ? 8 : viewport === 'mobile' ? 1 : 18;
- const width = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, Number(field(activeLayout, 'w') ?? 6)));
const placement = wallboardPlacement(activeLayout);
- const layoutStyle = viewport === 'wallboard' ? { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties : { '--widget-span': String(width) } as CSSProperties;
- let content;
- if (semantic) {
- const metric: MetricWidgetProps = { kind: widget.type as MetricWidgetProps['kind'], series: timeRange === 'live' ? live.series : historicalSeries, freshness, expectedStepSeconds: request?.range.stepSeconds ?? 15, availability: timeRange === 'live' ? live.state : metricState.status, error: timeRange === 'live' ? live.error : metricState.error?.actionable ?? null, visualization: widget.visualization, metricName: request?.metric, sourceObservedAt: metricState.response?.sourceObservedAt, receivedAt: metricState.response?.receivedAt, warnings: metricState.response?.warnings, inspector: metricState.response?.inspector };
- content = ;
- } else if (systemWidget && system.state === 'ready') {
- content = {systemStatus.label} {systemStatus.detail} {system.status?.components.length ?? 0} {copy.dashboards.runtime.checkedComponents}
;
- } else if (inventory.items?.length) {
- content = ;
- } else if (inventory.storage) {
- content = ;
- } else if (inventory.events?.length) {
- content = {inventory.events.slice(0, viewport === 'mobile' ? 6 : 12).map((event) => {presentEventSummary(event.type, event.summary)} {presentEventType(event.type)} · {formatDateTime(event.occurredAt)} )} ;
- } else {
- content = {runtime === 'error' ? copy.dashboards.runtime.sourceUnavailable : runtime === 'loading' ? copy.dashboards.runtime.telemetryLoading : copy.dashboards.runtime.noCurrentData} {runtime === 'error' ? copy.dashboards.runtime.unavailableDetail : copy.dashboards.runtime.retryDetail}
;
- }
- return {sourceType === 'semantic-metric' ? copy.dashboards.runtime.semanticMetric : sourceType === 'events' ? copy.dashboards.runtime.events : copy.dashboards.runtime.inventory}
{widget.description && {widget.description}
}{content} ;
+ const layoutStyle = viewport === 'wallboard' ? { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties : spatialStyle(activeLayout, viewport) as CSSProperties;
+ let content: ReactNode;
+ if (!isWidgetType(widget.type)) content = {copy.dashboards.runtime.unknownType} {widget.type.slice(0, 80)}
;
+ else if (semantic && metricTypes.has(widget.type)) content = ;
+ else if (systemWidget && system.state === 'ready') content = {systemStatus.label} {systemStatus.detail} {system.status?.components.length ?? 0} {copy.dashboards.runtime.checkedComponents}
;
+ else if (resource.storage && widget.type === 'storage-map') content = ;
+ else if (widget.type === 'text' && resource.text?.trim()) content = ;
+ else if (widget.type === 'alert-summary' && rows.length) content = ;
+ else if (widget.type === 'service-matrix' && rows.length) content = ;
+ else if (widget.type === 'table' && rows.length) content = ;
+ else if (widget.type === 'ranked-list' && rows.length) content = ({ id: row.id, label: row.label, value: row.value || presentStatus(row.status), detail: row.detail }))} onSelect={onFilter} />;
+ else if (widget.type === 'status-grid' && rows.length) content = ({ id: row.id, label: row.label, status: row.status, reason: row.detail }))} onSelect={onFilter} />;
+ else if (widget.type === 'stat' && rows.length) content = {rows.length} {sourceLabel(sourceType)} {rows.filter((row) => !['healthy', 'up', 'operational', 'resolved'].includes(row.status)).length} vragen aandacht
;
+ else if (widget.type === 'event-timeline' && (resource.events?.length || rows.length)) { const events = resource.events ?? rows.map((row) => ({ id: row.id, type: sourceType, severity: row.severity ?? row.status, summary: row.label, occurredAt: row.occurredAt ?? '', sourceId: row.id })); content = {events.slice(0, viewport === 'mobile' ? 6 : 12).map((event) => {sourceType === 'events' ? presentEventSummary(event.type, event.summary) : event.summary} {sourceType === 'events' ? presentEventType(event.type) : sourceLabel(sourceType)}{event.occurredAt ? ' · ' + formatDateTime(event.occurredAt) : ''} )} ; }
+ else content = ;
+
+ return {sourceLabel(sourceType)} · {widgetLabel(widget.type)}
{widget.description && {widget.description}
}{content} ;
}
diff --git a/apps/web/src/EventsPage.tsx b/apps/web/src/EventsPage.tsx
index 01d1e47..ad5079b 100644
--- a/apps/web/src/EventsPage.tsx
+++ b/apps/web/src/EventsPage.tsx
@@ -77,7 +77,7 @@ export function EventsPage() {
const resetPage = () => setPage(0);
const movePage = (next: number) => {
setPage(next);
- requestAnimationFrame(() => pageStatus.current?.focus());
+ pageStatus.current?.focus();
};
const clearFilters = () => { setSearch(''); setSeverity(''); setType(''); setEntity(''); setPage(0); };
diff --git a/apps/web/src/ExploreMetricsPage.tsx b/apps/web/src/ExploreMetricsPage.tsx
new file mode 100644
index 0000000..f8bc3eb
--- /dev/null
+++ b/apps/web/src/ExploreMetricsPage.tsx
@@ -0,0 +1,130 @@
+import { useEffect, useMemo, useState } from 'react';
+import { LiveChartAdapter, historicalSamplesFromData, type ChartSeries } from './liveBuffer';
+import { MetricApiError, MetricClient, rangeForSelection, type MetricFreshness, type MetricQueryResponse } from './metricClient';
+import { MetricWidget } from './MetricWidgets';
+import { copy } from './copy';
+import { operatorCustomRangeValid, operatorRangeSelection, setOperatorCustomRange, setOperatorRangePreset, useOperatorContext, type OperatorRangePreset } from './operatorContext';
+
+type Limits = { maxRangeSeconds: number; maxSeries: number; maxPoints: number; timeoutSeconds: number };
+type MetricDefinition = { semanticName: string; description: string; unit: string; valueKind: string; defaultAggregation?: string; allowedVisualizations: string[]; limits: Limits };
+type CatalogResponse = { version: string; metrics: MetricDefinition[]; bindings: Array<{ semanticName: string; state: string }> };
+type Result = { metric: MetricDefinition; status: 'loading' | 'success' | 'error'; series: ChartSeries[]; freshness: MetricFreshness; response?: MetricQueryResponse; error?: string };
+type Annotation = { id: string; label: string; timestamp: number; tone: 'warning' | 'critical' | 'info' };
+
+const client = new MetricClient();
+const ranges: Array<{ value: OperatorRangePreset; label: string }> = [
+ { value: '15m', label: '15 min' }, { value: '1h', label: '1 uur' }, { value: '6h', label: '6 uur' },
+ { value: '24h', label: '24 uur' }, { value: '7d', label: '7 dagen' }, { value: '30d', label: '30 dagen' }, { value: 'custom', label: 'Aangepast' },
+];
+
+function boundedCatalog(value: CatalogResponse): CatalogResponse {
+ const metrics = Array.isArray(value.metrics) ? value.metrics.filter((metric) => typeof metric?.semanticName === 'string' && metric.semanticName.length <= 160 && typeof metric.description === 'string' && metric.description.length <= 1000 && metric.limits && Number.isFinite(metric.limits.maxRangeSeconds) && Number.isFinite(metric.limits.maxSeries) && Number.isFinite(metric.limits.maxPoints)).slice(0, 500).map((metric) => ({ ...metric, description: metric.description.slice(0, 1000), unit: String(metric.unit ?? '').slice(0, 40), allowedVisualizations: Array.isArray(metric.allowedVisualizations) ? metric.allowedVisualizations.filter((item) => typeof item === 'string').slice(0, 30) : [], limits: { maxRangeSeconds: Math.max(60, Math.min(31_536_000, Math.trunc(metric.limits.maxRangeSeconds))), maxSeries: Math.max(1, Math.min(10_000, Math.trunc(metric.limits.maxSeries))), maxPoints: Math.max(10, Math.min(1_000_000, Math.trunc(metric.limits.maxPoints))), timeoutSeconds: Math.max(1, Math.min(120, Math.trunc(metric.limits.timeoutSeconds || 5))) } })) : [];
+ const bindings = Array.isArray(value.bindings) ? value.bindings.filter((binding) => typeof binding?.semanticName === 'string' && typeof binding.state === 'string').slice(0, 500) : [];
+ return { version: String(value.version ?? '').slice(0, 128), metrics, bindings };
+}
+function seriesFrom(response: MetricQueryResponse): ChartSeries[] {
+ const samples = historicalSamplesFromData(response.data);
+ const adapter = new LiveChartAdapter(4000);
+ adapter.append(samples.map((sample) => ({ ...sample, freshness: response.freshness })));
+ return adapter.snapshot();
+}
+function rangeSeconds(from: string, to: string): number { return Math.max(0, (Date.parse(to) - Date.parse(from)) / 1000); }
+
+function initialExploreMode(): 'explore' | 'compare' | 'history' {
+ const value = new URLSearchParams(window.location.search).get('mode');
+ return value === 'compare' || value === 'history' ? value : 'explore';
+}
+function initialExploreMetrics(): string[] {
+ const params = new URLSearchParams(window.location.search);
+ const values = [
+ ...(params.get('metrics') ?? '').split(','),
+ params.get('metric') ?? '',
+ ].map((value) => value.trim()).filter(Boolean);
+ return [...new Set(values)].slice(0, 4);
+}
+
+export function ExploreMetricsPage() {
+ const [catalog, setCatalog] = useState(null);
+ const [catalogState, setCatalogState] = useState<'loading' | 'ready' | 'error'>('loading');
+ const [search, setSearch] = useState('');
+ const [mode, setMode] = useState<'explore' | 'compare' | 'history'>(() => initialExploreMode());
+ const [selected, setSelected] = useState(() => initialExploreMetrics());
+ const [results, setResults] = useState([]);
+ const [annotations, setAnnotations] = useState([]);
+ const operator = useOperatorContext();
+ const rangeSelection = operatorRangeSelection(operator);
+ const range = useMemo(() => rangeForSelection(rangeSelection), [rangeSelection, operator.refreshVersion]);
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ params.set('mode', mode);
+ params.delete('metric');
+ params.delete('metrics');
+ if (selected.length === 1) params.set('metric', selected[0]);
+ else if (selected.length > 1) params.set('metrics', selected.join(','));
+ const query = params.toString();
+ const next = window.location.pathname + (query ? '?' + query : '');
+ if (next !== window.location.pathname + window.location.search) window.history.replaceState(window.history.state, '', next);
+ }, [mode, selected.join('|')]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setCatalogState('loading');
+ fetch('/api/v1/metrics/catalog', { signal: controller.signal, cache: 'no-store' }).then(async (response) => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return boundedCatalog(await response.json() as CatalogResponse);
+ }).then((value) => { setCatalog(value); setCatalogState('ready'); }).catch((error: unknown) => {
+ if (!(error instanceof DOMException && error.name === 'AbortError')) setCatalogState('error');
+ });
+ Promise.all([
+ fetch('/api/v1/incidents?limit=20', { signal: controller.signal, cache: 'no-store' }).then((response) => response.ok ? response.json() : { items: [] }),
+ fetch('/api/v1/alerts?limit=20', { signal: controller.signal, cache: 'no-store' }).then((response) => response.ok ? response.json() : { items: [] }),
+ ]).then(([incidentData, alertData]: Array<{ items?: Array> }>) => {
+ const mapped = [...(incidentData.items ?? []).map((item) => ({ id: String(item.id), label: String(item.title ?? 'Incident'), timestamp: Date.parse(String(item.updatedAt ?? item.startedAt ?? '')), tone: item.severity === 'critical' ? 'critical' as const : 'warning' as const })), ...(alertData.items ?? []).map((item) => ({ id: String(item.id), label: String(item.ruleName ?? 'Melding'), timestamp: Date.parse(String(item.updatedAt ?? '')), tone: item.severity === 'critical' ? 'critical' as const : 'warning' as const }))].filter((item) => Number.isFinite(item.timestamp)).slice(0, 40);
+ setAnnotations(mapped);
+ }).catch(() => setAnnotations([]));
+ return () => controller.abort();
+ }, []);
+
+ const bindings = useMemo(() => new Map((catalog?.bindings ?? []).map((binding) => [binding.semanticName, binding.state])), [catalog]);
+ const normalized = search.trim().toLocaleLowerCase('nl-BE');
+ const visible = (catalog?.metrics ?? []).filter((metric) => !normalized || `${metric.semanticName} ${metric.description} ${metric.unit}`.toLocaleLowerCase('nl-BE').includes(normalized)).slice(0, 100);
+ const selectedMetrics = selected.flatMap((name) => catalog?.metrics.find((metric) => metric.semanticName === name) ?? []);
+ const invalidCustom = operator.range === 'custom' && !operatorCustomRangeValid(operator);
+ const overBudget = selectedMetrics.find((metric) => rangeSeconds(range.from, range.to) > metric.limits.maxRangeSeconds);
+
+ useEffect(() => {
+ if (selectedMetrics.length === 0 || invalidCustom || overBudget) { setResults([]); return undefined; }
+ const controller = new AbortController();
+ setResults(selectedMetrics.map((metric) => ({ metric, status: 'loading', series: [], freshness: 'unavailable' })));
+ selectedMetrics.forEach((metric) => {
+ client.queryRange({ metric: metric.semanticName, range, aggregation: metric.defaultAggregation || 'avg', maxSeries: Math.min(20, metric.limits.maxSeries), maxPoints: Math.min(4000, metric.limits.maxPoints) }, controller.signal).then((response) => {
+ setResults((current) => current.map((item) => item.metric.semanticName === metric.semanticName ? { metric, status: 'success', series: seriesFrom(response), freshness: response.freshness, response } : item));
+ }).catch((error: unknown) => {
+ if (controller.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) return;
+ const message = error instanceof MetricApiError ? error.actionable : copy.explore.queryError;
+ setResults((current) => current.map((item) => item.metric.semanticName === metric.semanticName ? { metric, status: 'error', series: [], freshness: 'unavailable', error: message } : item));
+ });
+ });
+ return () => controller.abort();
+ }, [selected.join('|'), range.from, range.to, range.stepSeconds, invalidCustom, overBudget?.semanticName]);
+
+ const choose = (metric: MetricDefinition) => {
+ if (mode !== 'compare') { setSelected([metric.semanticName]); return; }
+ setSelected((current) => current.includes(metric.semanticName) ? current.filter((name) => name !== metric.semanticName) : current.length >= 4 ? current : [...current, metric.semanticName]);
+ };
+ const changeMode = (next: typeof mode) => { setMode(next); if (next !== 'compare') setSelected((current) => current.slice(0, 1)); };
+
+ return
+ {copy.explore.eyebrow}
{copy.explore.title} {copy.explore.intro}
+ {(['explore', 'compare', 'history'] as const).map((item) => changeMode(item)}>{copy.explore[item]} )}
+
+
+
+ {invalidCustom && {copy.explore.invalidRange} {copy.operator.customInvalid}
}
+ {overBudget && {copy.explore.budgetExceeded} {copy.explore.budgetDetail.replace('{metric}', overBudget.semanticName).replace('{days}', Math.floor(overBudget.limits.maxRangeSeconds / 86400).toString())}
}
+ {mode === 'compare' && selected.length >= 4 && {copy.explore.compareLimit}
}
+ {selected.length === 0 ? {copy.explore.selectTitle} {copy.explore.selectDetail}
: {results.map((result) =>
{result.metric.unit || result.metric.valueKind}
{result.metric.semanticName} setSelected((current) => current.filter((name) => name !== result.metric.semanticName))}>{copy.explore.closeMetric} {result.metric.description}
)}
}
+
+ ;
+}
diff --git a/apps/web/src/IncidentPage.tsx b/apps/web/src/IncidentPage.tsx
index 84e66e7..831f8b6 100644
--- a/apps/web/src/IncidentPage.tsx
+++ b/apps/web/src/IncidentPage.tsx
@@ -1,13 +1,29 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { formatDateTime } from './locale';
+import { presentStatus } from './presentation';
+import { readSessionCapabilities } from './sessionClient';
type Association = { alertId: string; rationale: string; confidence: number; correlationMethod: string; manual: boolean; addedBy?: string; createdAt: string };
type Note = { id: string; incidentId: string; author: string; body: string; createdAt: string };
type Incident = { id: string; correlationKey: string; title: string; summary: string; severity: string; status: string; startedAt: string; resolvedAt?: string; ownerUserId?: string; correlationMethod: string; confidence: number; revision: number; updatedAt: string; alerts?: Association[]; notes?: Note[] };
+type AlertDetail = { id: string; state: string; retainedState: string; ruleName: string; severity: string; entityName?: string; reason: string; revision: number; acknowledgedBy?: string; updatedAt?: string };
+
+async function readAssociatedAlerts(associations: Association[], signal: AbortSignal): Promise {
+ const alerts: AlertDetail[] = [];
+ for (let offset = 0; offset < associations.length; offset += 4) {
+ const batch = await Promise.all(associations.slice(offset, offset + 4).map(async (association) => {
+ const response = await fetch('/api/v1/alerts/' + encodeURIComponent(association.alertId) + '?occurrenceLimit=1', { signal, cache: 'no-store' });
+ if (!response.ok) return null;
+ const value = await response.json() as { alert?: AlertDetail };
+ return value.alert ?? null;
+ }));
+ alerts.push(...batch.filter((value): value is AlertDetail => Boolean(value)));
+ }
+ return alerts;
+}
function navigate(path: string) { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')); }
-function formatTime(value: string) { return formatDateTime(value); }
function severityLabel(value: string) { return value === 'critical' ? 'Kritiek' : value === 'degraded' ? 'Aandacht' : 'Opmerking'; }
function statusLabel(value: string) { return value === 'resolved' ? 'Opgelost' : value === 'acknowledged' ? 'Erkend' : 'Open'; }
@@ -15,32 +31,96 @@ export function IncidentPage({ id }: { id?: string }) {
const [state, setState] = useState<'loading' | 'ready' | 'empty' | 'error' | 'unauthorized'>('loading');
const [items, setItems] = useState([]);
const [incident, setIncident] = useState(null);
+ const [alerts, setAlerts] = useState>({});
+ const [canOperate, setCanOperate] = useState(false);
+ const [sessionSubject, setSessionSubject] = useState('');
const [note, setNote] = useState('');
const [owner, setOwner] = useState('');
const [message, setMessage] = useState('');
+ const [actingAlert, setActingAlert] = useState('');
const [reload, setReload] = useState(0);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ void readSessionCapabilities(controller.signal).then((session) => { setCanOperate(session.permissions.operate); setSessionSubject(session.subject ?? ''); }).catch(() => { setCanOperate(false); setSessionSubject(''); });
+ return () => controller.abort();
+ }, []);
useEffect(() => {
const controller = new AbortController();
setState('loading');
const url = id ? '/api/v1/incidents/' + encodeURIComponent(id) : '/api/v1/incidents?limit=100&status=open';
- fetch(url, { signal: controller.signal }).then((response) => { if (response.status === 401) throw new Error('unauthorized'); if (!response.ok) throw new Error('error'); return response.json() as Promise<{ incident?: Incident; items?: Incident[] }>; }).then((data) => {
- if (id) { const next = data.incident ?? null; setIncident(next); setOwner(next?.ownerUserId ?? ''); setState(next ? 'ready' : 'empty'); } else { const next = data.items ?? []; setItems(next); setState(next.length ? 'ready' : 'empty'); }
+ fetch(url, { signal: controller.signal, cache: 'no-store' }).then((response) => {
+ if (response.status === 401) throw new Error('unauthorized');
+ if (!response.ok) throw new Error('error');
+ return response.json() as Promise<{ incident?: Incident; items?: Incident[] }>;
+ }).then((data) => {
+ if (id) { const next = data.incident ?? null; setIncident(next); setOwner(next?.ownerUserId ?? ''); setState(next ? 'ready' : 'empty'); }
+ else { const next = (data.items ?? []).slice(0, 100); setItems(next); setState(next.length ? 'ready' : 'empty'); }
}).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState(error instanceof Error && error.message === 'unauthorized' ? 'unauthorized' : 'error'); });
return () => controller.abort();
}, [id, reload]);
+ useEffect(() => {
+ const associations = (incident?.alerts ?? []).slice(0, 100);
+ if (!associations.length) { setAlerts({}); return undefined; }
+ const controller = new AbortController();
+ readAssociatedAlerts(associations, controller.signal).then((values) => setAlerts(Object.fromEntries(values.map((value) => [value.id, value])))).catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setAlerts({}); });
+ return () => controller.abort();
+ }, [incident]);
+
const timeline = useMemo(() => {
if (!incident) return [] as Array<{ id: string; label: string; detail: string; at: string }>;
- const entries = (incident.alerts ?? []).map((alert) => ({ id: 'alert-' + alert.alertId, label: alert.manual ? 'Handmatig gekoppeld alert' : 'Gecorreleerd alert', detail: alert.rationale + ' · confidence ' + Math.round(alert.confidence * 100) + '%', at: alert.createdAt }));
- entries.push(...(incident.notes ?? []).map((item) => ({ id: item.id, label: 'Notitie van ' + item.author, detail: item.body, at: item.createdAt })));
+ const entries = (incident.alerts ?? []).slice(0, 100).map((alert) => ({ id: 'alert-' + alert.alertId, label: alert.manual ? 'Handmatig gekoppelde melding' : 'Gecorreleerde melding', detail: alert.rationale + ' · betrouwbaarheid ' + Math.round(alert.confidence * 100) + '%', at: alert.createdAt }));
+ entries.push({ id: 'incident-start', label: 'Incident gestart', detail: incident.summary, at: incident.startedAt });
+ if (incident.resolvedAt) entries.push({ id: 'incident-resolved', label: 'Incident opgelost', detail: 'Het incident werd als opgelost geregistreerd.', at: incident.resolvedAt });
+ entries.push(...(incident.notes ?? []).slice(0, 100).map((item) => ({ id: item.id, label: 'Notitie van ' + item.author, detail: item.body, at: item.createdAt })));
return entries.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
}, [incident]);
- const submitNote = async () => { if (!id || !note.trim()) return; setMessage('Notitie wordt opgeslagen…'); const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id) + '/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: note }) }); if (!response.ok) { setMessage('De notitie kon niet worden opgeslagen.'); return; } setNote(''); setMessage('Notitie opgeslagen.'); setReload((value) => value + 1); };
- const saveOwner = async () => { if (!id || !incident) return; setMessage('Eigenaar wordt opgeslagen…'); const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ownerUserId: owner.trim(), revision: incident.revision }) }); if (!response.ok) { setMessage('De eigenaar kon niet worden opgeslagen.'); return; } setMessage('Eigenaar opgeslagen.'); setReload((value) => value + 1); };
+
+ const submitNote = async () => { if (!id || !note.trim()) return; setMessage('Notitie wordt opgeslagen…'); try { const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id) + '/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: note }) }); if (!response.ok) { setMessage('De notitie kon niet worden opgeslagen.'); return; } setNote(''); setMessage('Notitie opgeslagen.'); setReload((value) => value + 1); } catch { setMessage('De notitie kon niet worden opgeslagen.'); } };
+ const saveOwner = async (nextOwner = owner.trim()) => { if (!id || !incident) return; setMessage('Eigenaar wordt opgeslagen…'); try { const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ownerUserId: nextOwner, revision: incident.revision }) }); if (!response.ok) { setMessage(response.status === 409 ? 'Het incident is intussen gewijzigd. De actuele versie wordt geladen.' : 'De eigenaar kon niet worden opgeslagen.'); if (response.status === 409) setReload((value) => value + 1); return; } setOwner(nextOwner); setMessage('Eigenaar opgeslagen.'); setReload((value) => value + 1); } catch { setMessage('De eigenaar kon niet worden opgeslagen.'); } };
+ const assignToMe = () => { if (sessionSubject && canOperate) void saveOwner(sessionSubject); };
+ const openHistory = () => {
+ if (!incident) return;
+ const start = new Date(Math.max(0, Date.parse(incident.startedAt) - 15 * 60 * 1000));
+ const end = incident.resolvedAt ? new Date(Date.parse(incident.resolvedAt) + 15 * 60 * 1000) : new Date();
+ const local = (value: Date) => new Date(value.getTime() - value.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
+ navigate('/explore?mode=history&range=custom&from=' + encodeURIComponent(local(start)) + '&to=' + encodeURIComponent(local(end)));
+ };
+ const acknowledge = async (alert: AlertDetail) => {
+ if (!id || !canOperate || actingAlert) return;
+ const evaluationKey = `incident-${id}-alert-${alert.id}-ack-${alert.revision}`.slice(0, 160);
+ setActingAlert(alert.id); setMessage('Melding wordt erkend…');
+ try {
+ const response = await fetch('/api/v1/alerts/' + encodeURIComponent(alert.id) + '/acknowledge', { method: 'POST', headers: { 'Content-Type': 'application/json', 'If-Match': String(alert.revision), 'Idempotency-Key': evaluationKey }, body: JSON.stringify({ evaluationKey }) });
+ if (!response.ok) { setMessage(response.status === 409 ? 'De melding is intussen gewijzigd. De actuele status wordt geladen.' : 'De melding kon niet worden erkend.'); setReload((value) => value + 1); return; }
+ setMessage('Melding erkend. Dit lost de infrastructuur niet automatisch op.'); setReload((value) => value + 1);
+ } catch { setMessage('De melding kon niet worden erkend.'); } finally { setActingAlert(''); }
+ };
+ const acknowledgeAll = async () => {
+ if (!id || !canOperate || actingAlert) return;
+ const actionable = Object.values(alerts).filter((alert) => !alert.acknowledgedBy && alert.state !== 'acknowledged' && alert.state !== 'resolved').slice(0, 20);
+ if (!actionable.length) { setMessage('Alle gekoppelde meldingen zijn al erkend of opgelost.'); return; }
+ setActingAlert('all'); setMessage(`${actionable.length} meldingen worden erkend…`);
+ let changed = 0;
+ try {
+ for (const alert of actionable) {
+ const evaluationKey = `incident-${id}-alert-${alert.id}-ack-${alert.revision}`.slice(0, 160);
+ const response = await fetch('/api/v1/alerts/' + encodeURIComponent(alert.id) + '/acknowledge', { method: 'POST', headers: { 'Content-Type': 'application/json', 'If-Match': String(alert.revision), 'Idempotency-Key': evaluationKey }, body: JSON.stringify({ evaluationKey }) });
+ if (response.ok) changed += 1;
+ }
+ setMessage(`${changed} van ${actionable.length} meldingen erkend. Dit wijzigt geen infrastructuur.`);
+ setReload((value) => value + 1);
+ } catch { setMessage('Niet alle meldingen konden worden erkend. De actuele status wordt opnieuw geladen.'); setReload((value) => value + 1); }
+ finally { setActingAlert(''); }
+ };
+
if (state === 'loading') return ;
if (state === 'unauthorized') return Geen toegang tot incidenten Je hebt geen rechten om incidentgegevens te bekijken.
;
if (state === 'error') return Incidenten niet beschikbaar De incidentgegevens konden niet veilig worden geladen.
setReload((value) => value + 1)}>Opnieuw laden ;
if (!id && state === 'empty') return <>✓ Geen open incidenten Er zijn momenteel geen open incidenten geregistreerd.
>;
if (!id) return <>{copy.incidents.openIncidents}
{copy.incidents.listTitle} ? {items.length} incidenten{items.map((item) => navigate('/incidents/' + encodeURIComponent(item.id))}>{item.title} {severityLabel(item.severity)} · {item.summary} {statusLabel(item.status)} {copy.incidents.confidence}: {Math.round(item.confidence * 100)}% )} >;
if (!incident) return null;
- return <> navigate('/incidents')}>← Terug naar incidenten Ernst {severityLabel(incident.severity)} Status {statusLabel(incident.status)} {copy.incidents.started} {formatTime(incident.startedAt)} {copy.incidents.confidence} {Math.round(incident.confidence * 100)}% {copy.incidents.statusAndConfidence}
{statusLabel(incident.status)} ? {severityLabel(incident.severity)}
{copy.incidents.started} {formatTime(incident.startedAt)}
{copy.incidents.correlationMethod} {incident.correlationMethod}
{copy.incidents.confidence} {Math.round(incident.confidence * 100)}%
{copy.incidents.revision} {incident.revision} Correlatie is een onderbouwde aanwijzing, geen bewezen causaliteit. Betrouwbaarheid beschrijft de correlatieregel, niet de zekerheid van de oorzaak.
{copy.incidents.ownership}
{copy.incidents.followUp} {copy.incidents.ownerLabel} setOwner(event.target.value)} placeholder={copy.incidents.ownerPlaceholder} aria-label={copy.incidents.ownerLabel} /> {copy.incidents.saveOwner} {copy.incidents.ownerNote}
{copy.incidents.timeline}
{copy.incidents.signalsAndNotes} {timeline.length} {copy.incidents.timelineItems} {timeline.length === 0 ? {copy.incidents.noTimeline}
: {timeline.map((entry) => {formatTime(entry.at)} {entry.label} {entry.detail}
)} }{copy.incidents.notes}
{copy.incidents.operatorContext} {copy.incidents.newNote} setNote(event.target.value)} maxLength={2000} rows={4} aria-describedby="incident-note-help" /> {copy.incidents.noteHelp}
{copy.incidents.addNote} {message && {message}
} >;
+ return <> navigate('/incidents')}>← Terug naar incidenten Ernst {severityLabel(incident.severity)} Status {statusLabel(incident.status)} {copy.incidents.started} {formatDateTime(incident.startedAt)} {copy.incidents.confidence} {Math.round(incident.confidence * 100)}% {canOperate && sessionSubject && incident.ownerUserId !== sessionSubject && Aan mij toewijzen }{canOperate && Object.values(alerts).some((alert) => !alert.acknowledgedBy && alert.state !== 'acknowledged' && alert.state !== 'resolved') && void acknowledgeAll()}>{actingAlert === 'all' ? 'Meldingen erkennen…' : 'Alle meldingen erkennen'} }Onderzoek in Explore
{copy.incidents.statusAndConfidence}
{statusLabel(incident.status)} ? {severityLabel(incident.severity)}
{copy.incidents.started} {formatDateTime(incident.startedAt)}
{copy.incidents.correlationMethod} {incident.correlationMethod}
{copy.incidents.confidence} {Math.round(incident.confidence * 100)}%
{copy.incidents.revision} {incident.revision} Correlatie is een onderbouwde aanwijzing, geen bewezen causaliteit. Betrouwbaarheid beschrijft de correlatieregel, niet de zekerheid van de oorzaak.
{copy.incidents.ownership}
{copy.incidents.followUp} {copy.incidents.ownerLabel} setOwner(event.target.value)} placeholder={copy.incidents.ownerPlaceholder} aria-label={copy.incidents.ownerLabel} /> void saveOwner()} disabled={!canOperate}>{copy.incidents.saveOwner} {canOperate ? copy.incidents.ownerNote : 'Je rol kan de opvolgcontext alleen bekijken.'}
+ Bijdragende signalen
Meldingen in dit incident {incident.alerts?.length ?? 0} meldingen {!incident.alerts?.length ? Geen gekoppelde meldingen.
: {incident.alerts.slice(0, 100).map((association) => { const alert = alerts[association.alertId]; const actionable = alert && !alert.acknowledgedBy && alert.state !== 'acknowledged' && alert.state !== 'resolved'; return {alert?.ruleName || association.alertId} {alert ? `${presentStatus(alert.state)} · ${alert.reason}` : association.rationale} Relatie: {association.rationale} · {Math.round(association.confidence * 100)}%
{alert && {presentStatus(alert.state)} {actionable && canOperate && void acknowledge(alert)}>{actingAlert === alert.id ? 'Erkennen…' : 'Melding erkennen'} }
} ; })} }Erkennen bevestigt alleen de operationele opvolging. Pulse start, stopt of repareert geen Unraidresource.
+ {copy.incidents.timeline}
{copy.incidents.signalsAndNotes} {timeline.length} {copy.incidents.timelineItems} {timeline.length === 0 ? {copy.incidents.noTimeline}
: {timeline.map((entry) => {formatDateTime(entry.at)} {entry.label} {entry.detail}
)} }{copy.incidents.notes}
{copy.incidents.operatorContext} {copy.incidents.newNote} setNote(event.target.value)} maxLength={2000} rows={4} aria-describedby="incident-note-help" /> {copy.incidents.noteHelp}
{copy.incidents.addNote} {message && {message}
} >;
}
diff --git a/apps/web/src/MetricWidgets.tsx b/apps/web/src/MetricWidgets.tsx
index cd2f0eb..31720b8 100644
--- a/apps/web/src/MetricWidgets.tsx
+++ b/apps/web/src/MetricWidgets.tsx
@@ -1,4 +1,4 @@
-import { useId, type CSSProperties, type ReactNode } from 'react';
+import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import type { BufferedPoint, ChartSeries, LiveFreshness } from './liveBuffer';
import type { MetricInspector } from './metricClient';
import { copy } from './copy';
@@ -19,6 +19,7 @@ export type MetricWidgetProps = {
receivedAt?: string;
warnings?: string[];
inspector?: MetricInspector;
+ annotations?: Array<{ id: string; label: string; timestamp: number; tone: 'warning' | 'critical' | 'info' }>;
};
type LimitedSeries = { key: string; points: BufferedPoint[] };
@@ -75,6 +76,17 @@ function formatValue(value: number, visualization: Record): str
const formatted = formatNumber(value, decimals);
return unit ? formatted + ' ' + unit : formatted;
}
+function openMetricInExplore(metricName: string): void {
+ const params = new URLSearchParams(window.location.search);
+ params.set('metric', metricName);
+ params.delete('metrics');
+ params.set('mode', 'explore');
+ const query = params.toString();
+ const path = '/explore' + (query ? '?' + query : '');
+ window.history.pushState({ pulseFrom: window.location.pathname + window.location.search }, '', path);
+ window.dispatchEvent(new PopStateEvent('popstate'));
+}
+
function validPoints(series: readonly LimitedSeries[]): NumericPoint[] { return series.flatMap((item) => item.points).filter((point): point is NumericPoint => point.value !== null && Number.isFinite(point.value)); }
function latestPoint(series: readonly LimitedSeries[]): BufferedPoint | null {
return validPoints(series).sort((a, b) => b.timestamp - a.timestamp || String(a.labels?.__name__ ?? '').localeCompare(String(b.labels?.__name__ ?? '')))[0] ?? null;
@@ -203,8 +215,11 @@ function chartPath(points: readonly BufferedPoint[], minTime: number, timeSpread
}
function exportCsv(series: readonly LimitedSeries[], visualization: Record, metricName?: string): void {
if (typeof document === 'undefined' || typeof URL === 'undefined') return;
- const rows = [['series', 'timestamp', 'value', 'freshness']];
- series.forEach((item) => item.points.forEach((point) => rows.push([item.key, new Date(point.timestamp).toISOString(), point.value === null ? '' : String(point.value), point.freshness])));
+ const timestamps = series.flatMap((item) => item.points.map((point) => point.timestamp));
+ const rangeFrom = timestamps.length ? new Date(Math.min(...timestamps)).toISOString() : '';
+ const rangeTo = timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : '';
+ const rows = [['metric', 'series', 'timestamp', 'value', 'freshness', 'range_from', 'range_to']];
+ series.forEach((item) => item.points.forEach((point) => rows.push([metricName || '', item.key, new Date(point.timestamp).toISOString(), point.value === null ? '' : String(point.value), point.freshness, rangeFrom, rangeTo])));
const csv = rows.map((row) => row.map((cell) => '"' + cell.replaceAll('"', '""') + '"').join(',')).join('\n');
const link = document.createElement('a');
const objectURL = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
@@ -248,9 +263,29 @@ export function metricStatus(props: MetricWidgetProps): { label: string; tone: '
export function MetricWidget(props: MetricWidgetProps) {
const reduced = reducedMetricLimits();
const series = reduced ? limitMetricSeries(props.series, reduced.maxSeries, reduced.maxPoints) : limitMetricSeries(props.series, 20, 4000);
- const gap = hasDataGap(series, props.expectedStepSeconds);
- const content = props.kind === 'stat' ? : props.kind === 'gauge' ? : props.kind === 'query-inspector' ? : ;
- return {props.warnings && props.warnings.slice(0, 10).map((warning) =>
{copy.metrics.sourceWarning}: {warning.slice(0, 240)}
)}{content}{gap && props.kind !== 'timeseries' &&
{copy.metrics.gapNotice} }{(props.freshness === 'stale' || props.freshness === 'unavailable') &&
{copy.metrics.sourceStatus}: {freshnessLabel(props.freshness)}. {copy.metrics.notHealthy} }
;
+ const [hidden, setHidden] = useState>(() => new Set());
+ const [zoomed, setZoomed] = useState(false);
+ const [cursorRatio, setCursorRatio] = useState(null);
+ const cursorFrame = useRef(null);
+ const pendingCursor = useRef(null);
+ const visible = series.filter((item) => !hidden.has(item.key));
+ const displaySeries = useMemo(() => {
+ if (!zoomed) return visible;
+ const timestamps = visible.flatMap((item) => item.points.map((point) => point.timestamp));
+ const minimum = Math.min(...timestamps); const maximum = Math.max(...timestamps); const cutoff = minimum + (maximum - minimum) / 2;
+ return visible.map((item) => ({ ...item, points: item.points.filter((point) => point.timestamp >= cutoff) }));
+ }, [visible, zoomed]);
+ const gap = hasDataGap(displaySeries, props.expectedStepSeconds);
+ const cursorPoints = useMemo(() => validPoints(displaySeries).sort((left, right) => left.timestamp - right.timestamp), [displaySeries]);
+ const cursor = cursorRatio == null || !cursorPoints.length ? null : cursorPoints[Math.min(cursorPoints.length - 1, Math.round(cursorRatio * (cursorPoints.length - 1)))];
+ useEffect(() => () => { if (cursorFrame.current != null) cancelAnimationFrame(cursorFrame.current); }, []);
+ const onPointerMove = (event: ReactPointerEvent) => {
+ const rect = event.currentTarget.getBoundingClientRect(); pendingCursor.current = Math.min(1, Math.max(0, (event.clientX - rect.left) / Math.max(1, rect.width)));
+ if (cursorFrame.current != null) return;
+ cursorFrame.current = requestAnimationFrame(() => { cursorFrame.current = null; setCursorRatio(pendingCursor.current); });
+ };
+ const content = props.kind === 'stat' ? : props.kind === 'gauge' ? : props.kind === 'query-inspector' ? : ;
+ return {props.warnings && props.warnings.slice(0, 10).map((warning) =>
{copy.metrics.sourceWarning}: {warning.slice(0, 240)}
)}{(props.metricName || (props.kind === 'timeseries' && series.length > 0)) &&
{props.kind === 'timeseries' && series.length > 0 &&
{series.map((item) => setHidden((current) => { const next = new Set(current); if (next.has(item.key)) next.delete(item.key); else if (next.size < Math.max(0, series.length - 1)) next.add(item.key); return next; })}>{seriesDisplayName(item, props.metricName)} )}
}
{props.kind === 'timeseries' && series.length > 0 && setZoomed((value) => !value)}>{zoomed ? copy.metrics.resetZoom : copy.metrics.zoomRecent} }{props.metricName && openMetricInExplore(props.metricName!)}>Open in Explore }
}
setCursorRatio(null)}>{content}{cursor && {new Date(cursor.timestamp).toLocaleString('nl-BE')} · {cursor.value == null ? '—' : cursor.value.toLocaleString('nl-BE')} }
{props.kind === 'timeseries' && props.annotations && props.annotations.length > 0 &&
{props.annotations.slice(0, 40).map((annotation) => {new Date(annotation.timestamp).toLocaleString('nl-BE')} {annotation.label.slice(0, 160)} )} }{gap && props.kind !== 'timeseries' &&
{copy.metrics.gapNotice} }{(props.freshness === 'stale' || props.freshness === 'unavailable') &&
{copy.metrics.sourceStatus}: {freshnessLabel(props.freshness)}. {copy.metrics.notHealthy} }
;
}
export type RankedListItem = { id: string; label: string; value: string; detail?: string };
export type StatusGridItem = { id: string; label: string; status: string; reason?: string };
diff --git a/apps/web/src/OperatorCommandBar.tsx b/apps/web/src/OperatorCommandBar.tsx
new file mode 100644
index 0000000..5d01a00
--- /dev/null
+++ b/apps/web/src/OperatorCommandBar.tsx
@@ -0,0 +1,229 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { copy } from './copy';
+import { formatDateTime } from './locale';
+import { SignInButton } from './SignIn';
+import {
+ operatorCustomRangeValid,
+ refreshOperatorData,
+ setOperatorCustomRange,
+ setOperatorPaused,
+ setOperatorRangePreset,
+ useOperatorContext,
+ type OperatorRangePreset,
+} from './operatorContext';
+import { readSessionCapabilities } from './sessionClient';
+import { aggregateStatus, refreshSystemStatus, useSystemStatus } from './systemStatus';
+import { WORKSPACE_EVENT, readWorkspaceState, type WorkspaceItem } from './operatorWorkspace';
+
+type NavigationEntry = { path: string; label: string };
+type HostSnapshot = { identity?: { name?: string } };
+type AlertSummary = { state?: string };
+type IncidentSummary = { id?: string; title?: string; summary?: string; severity?: string; status?: string };
+type AlertSearchItem = { id: string; ruleName?: string; entityName?: string; reason?: string; state?: string; severity?: string };
+type OperationalSearchItem = { id: string; label: string; detail: string; kind: 'incident' | 'alert'; path: string };
+type EntitySearchItem = { id: string; name?: string; displayName?: string; canonicalName?: string; entityType?: string; status?: string };
+type ShellData = { hostName?: string; subject?: string; role?: string; environment?: string; alerts: number; incidents: number; loading: boolean; unavailable: boolean; updatedAt?: string };
+
+const rangeOptions: Array<{ value: OperatorRangePreset; label: string }> = [
+ { value: 'live', label: copy.operator.rangeLive },
+ { value: '15m', label: copy.operator.range15m },
+ { value: '1h', label: copy.operator.range1h },
+ { value: '6h', label: copy.operator.range6h },
+ { value: '24h', label: copy.operator.range24h },
+ { value: '7d', label: copy.operator.range7d },
+ { value: '30d', label: copy.operator.range30d },
+ { value: 'custom', label: copy.operator.rangeCustom },
+];
+
+function boundedCount(value: number): string { return value >= 100 ? copy.operator.countCapped : String(value); }
+
+function entityRoute(item: EntitySearchItem): string {
+ const kind = (item.entityType ?? '').toLocaleLowerCase('en-US');
+ const id = encodeURIComponent(item.id);
+ if (kind.includes('container')) return '/containers/' + id;
+ if (kind.includes('application') || kind === 'app') return '/applications/' + id;
+ if (kind.includes('service')) return '/services/' + id;
+ if (kind.includes('disk')) return '/disks/' + id;
+ if (kind.includes('pool')) return '/pools/' + id;
+ if (kind.includes('share')) return '/shares/' + id;
+ return '/inventory/' + id;
+}
+
+function entityLabel(item: EntitySearchItem): string {
+ return item.displayName?.trim() || item.name?.trim() || item.canonicalName?.trim() || item.id;
+}
+
+function useWorkspaceItems(): { favorites: WorkspaceItem[]; views: WorkspaceItem[]; recent: WorkspaceItem[] } {
+ const [state, setState] = useState(() => readWorkspaceState());
+ useEffect(() => {
+ const refresh = () => setState(readWorkspaceState());
+ window.addEventListener(WORKSPACE_EVENT, refresh);
+ window.addEventListener('storage', refresh);
+ return () => { window.removeEventListener(WORKSPACE_EVENT, refresh); window.removeEventListener('storage', refresh); };
+ }, []);
+ return state;
+}
+
+function useShellData(refreshVersion: number): ShellData {
+ const [data, setData] = useState({ alerts: 0, incidents: 0, loading: true, unavailable: false });
+ useEffect(() => {
+ const controller = new AbortController();
+ setData((current) => ({ ...current, loading: true }));
+ const read = async (url: string): Promise => {
+ const response = await fetch(url, { signal: controller.signal, cache: 'no-store' });
+ if (!response.ok) throw new Error(String(response.status));
+ return response.json() as Promise;
+ };
+ Promise.all([
+ read('/api/v1/host'),
+ read<{ items?: AlertSummary[] }>('/api/v1/alerts?limit=100'),
+ read<{ items?: IncidentSummary[] }>('/api/v1/incidents?limit=100&status=open'),
+ readSessionCapabilities(controller.signal),
+ ]).then(([host, alerts, incidents, session]) => {
+ const activeAlerts = (alerts.items ?? []).filter((item) => !['resolved', 'silenced', 'suppressed'].includes((item.state ?? '').toLowerCase())).length;
+ setData({ hostName: host.identity?.name?.trim() || undefined, subject: session.subject || undefined, role: session.role || undefined, environment: session.environment || undefined, alerts: activeAlerts, incidents: (incidents.items ?? []).length, loading: false, unavailable: false, updatedAt: new Date().toISOString() });
+ }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setData((current) => ({ ...current, loading: false, unavailable: true }));
+ });
+ return () => controller.abort();
+ }, [refreshVersion]);
+ return data;
+}
+
+function useEntitySearch(query: string): { loading: boolean; items: EntitySearchItem[] } {
+ const [state, setState] = useState<{ loading: boolean; items: EntitySearchItem[] }>({ loading: false, items: [] });
+ useEffect(() => {
+ const term = query.trim();
+ if (term.length < 2) { setState({ loading: false, items: [] }); return undefined; }
+ const controller = new AbortController();
+ const timer = window.setTimeout(() => {
+ setState((current) => ({ ...current, loading: true }));
+ const params = new URLSearchParams({ q: term, limit: '8', order: 'asc' });
+ fetch('/api/v1/entities?' + params, { signal: controller.signal, cache: 'no-store' }).then(async (response) => {
+ if (!response.ok) throw new Error('search');
+ return response.json() as Promise<{ items?: EntitySearchItem[] }>;
+ }).then((value) => setState({ loading: false, items: (value.items ?? []).slice(0, 8) })).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState({ loading: false, items: [] });
+ });
+ }, 180);
+ return () => { window.clearTimeout(timer); controller.abort(); };
+ }, [query]);
+ return state;
+}
+
+function useOperationalSearch(query: string): { loading: boolean; items: OperationalSearchItem[] } {
+ const [state, setState] = useState<{ loading: boolean; items: OperationalSearchItem[] }>({ loading: false, items: [] });
+ useEffect(() => {
+ const term = query.trim();
+ if (term.length < 2) { setState({ loading: false, items: [] }); return undefined; }
+ const controller = new AbortController();
+ const timer = window.setTimeout(() => {
+ setState({ loading: true, items: [] });
+ Promise.all([
+ fetch('/api/v1/incidents?limit=100', { signal: controller.signal, cache: 'no-store' }).then((response) => response.ok ? response.json() as Promise<{ items?: IncidentSummary[] }> : { items: [] }),
+ fetch('/api/v1/alerts?limit=100', { signal: controller.signal, cache: 'no-store' }).then((response) => response.ok ? response.json() as Promise<{ items?: AlertSearchItem[] }> : { items: [] }),
+ ]).then(([incidents, alerts]) => {
+ const normalized = term.toLocaleLowerCase('nl-BE');
+ const incidentResults = (incidents.items ?? []).filter((item) => `${item.title ?? ''} ${item.summary ?? ''} ${item.severity ?? ''} ${item.status ?? ''}`.toLocaleLowerCase('nl-BE').includes(normalized)).slice(0, 4).map((item): OperationalSearchItem => ({ id: 'incident:' + item.id, label: item.title || item.id || 'Incident', detail: `${item.severity ?? 'unknown'} · ${item.status ?? 'open'}`, kind: 'incident', path: '/incidents/' + encodeURIComponent(item.id ?? '') }));
+ const alertResults = (alerts.items ?? []).filter((item) => `${item.ruleName ?? ''} ${item.entityName ?? ''} ${item.reason ?? ''} ${item.state ?? ''} ${item.severity ?? ''}`.toLocaleLowerCase('nl-BE').includes(normalized)).slice(0, 4).map((item): OperationalSearchItem => ({ id: 'alert:' + item.id, label: item.ruleName || item.entityName || item.id, detail: `${item.state ?? 'unknown'}${item.entityName ? ' · ' + item.entityName : ''}`, kind: 'alert', path: '/alerts?section=operations&alert=' + encodeURIComponent(item.id) + '&q=' + encodeURIComponent(term) }));
+ setState({ loading: false, items: [...incidentResults, ...alertResults] });
+ }).catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === 'AbortError') return;
+ setState({ loading: false, items: [] });
+ });
+ }, 180);
+ return () => { window.clearTimeout(timer); controller.abort(); };
+ }, [query]);
+ return state;
+}
+
+export function OperatorCommandBar({ routeLabel, navigation, onNavigate }: { routeLabel: string; navigation: readonly NavigationEntry[]; onNavigate: (path: string) => void }) {
+ const operator = useOperatorContext();
+ const snapshot = useSystemStatus();
+ const aggregate = aggregateStatus(snapshot);
+ const shell = useShellData(operator.refreshVersion);
+ const [query, setQuery] = useState('');
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [customOpen, setCustomOpen] = useState(false);
+ const searchInput = useRef(null);
+ const search = useEntitySearch(query);
+ const operationalSearch = useOperationalSearch(query);
+ const workspace = useWorkspaceItems();
+ const currentParams = new URLSearchParams(window.location.search);
+ const activeFilters = [...currentParams.keys()].filter((key) => !['range', 'from', 'to', 'interval', 'refresh'].includes(key)).length;
+ const sourceTotal = snapshot.status?.sourceLag.length ?? 0;
+ const healthySources = snapshot.status?.sourceLag.filter((source) => source.state === 'healthy').length ?? 0;
+ const transportConnected = snapshot.state === 'ready' && !aggregate.stale;
+ const transportReachable = snapshot.state === 'unauthorized' || snapshot.state === 'forbidden';
+ const transportLabel = aggregate.stale ? copy.operator.stale : transportConnected ? copy.operator.connected : transportReachable ? copy.operator.reachable : snapshot.state === 'loading' ? copy.operator.connecting : copy.operator.unavailable;
+ const sessionLabel = snapshot.state === 'ready' || snapshot.state === 'forbidden' ? copy.operator.sessionActive : snapshot.state === 'unauthorized' ? copy.operator.sessionRequired : copy.operator.sessionUnknown;
+ const serverName = shell.hostName || copy.operator.serverUnknown;
+ const staticResults = useMemo(() => {
+ const term = query.trim().toLocaleLowerCase('nl-BE');
+ if (!term) return [];
+ return navigation.filter((item) => item.label.toLocaleLowerCase('nl-BE').includes(term)).slice(0, 6);
+ }, [navigation, query]);
+
+ useEffect(() => {
+ const handleKey = (event: KeyboardEvent) => {
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
+ event.preventDefault();
+ setSearchOpen(true);
+ window.setTimeout(() => searchInput.current?.focus(), 0);
+ }
+ if (event.key === 'Escape') { setSearchOpen(false); setCustomOpen(false); }
+ };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, []);
+
+ const selectRange = (value: OperatorRangePreset) => {
+ if (value === 'custom') {
+ const now = new Date();
+ const to = operator.customTo || new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
+ const fromDate = new Date(now.getTime() - 60 * 60 * 1000);
+ const from = operator.customFrom || new Date(fromDate.getTime() - fromDate.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
+ setOperatorCustomRange(from, to);
+ setCustomOpen(true);
+ return;
+ }
+ setCustomOpen(false);
+ setOperatorRangePreset(value);
+ };
+ const refreshAll = () => { refreshOperatorData(); refreshSystemStatus(); };
+ const openResult = (path: string) => { setSearchOpen(false); setQuery(''); onNavigate(path); };
+ const release = snapshot.status?.release?.version || snapshot.status?.version;
+
+ return
+ P {copy.operator.server}{shell.environment ? ` · ${shell.environment}` : ''} {serverName} {copy.operator.currentPage} {routeLabel}
+ ● {copy.operator.transport} {transportLabel} ◉ {copy.operator.sources} {snapshot.state === 'ready' && sourceTotal > 0 ? `${healthySources}/${sourceTotal} ${copy.operator.sourcesCurrent}` : copy.operator.sourcesUnknown}
+
+
{copy.operator.range} selectRange(event.target.value as OperatorRangePreset)}>{rangeOptions.map((item) => {item.label} )}
+ {operator.range === 'custom' && customOpen &&
}
+
setOperatorPaused(!operator.paused)} disabled={operator.range !== 'live'} aria-label={operator.paused ? copy.operator.resume : copy.operator.pause} title={operator.paused ? copy.operator.resume : copy.operator.pause}>{operator.paused ? '▶' : 'Ⅱ'}
+
↻
+
{ setSearchOpen(true); window.setTimeout(() => searchInput.current?.focus(), 0); }} aria-label={copy.operator.search}>⌕ {copy.operator.search} {copy.operator.searchHint}
+
onNavigate('/alerts')} aria-label={`${copy.operator.alerts}: ${shell.unavailable ? copy.systemStatus.unknown : shell.alerts}`}>{copy.operator.alerts} {shell.unavailable ? '—' : boundedCount(shell.alerts)}
+
onNavigate('/incidents')} aria-label={`${copy.operator.incidents}: ${shell.unavailable ? copy.systemStatus.unknown : shell.incidents}`}>{copy.operator.incidents} {shell.unavailable ? '—' : boundedCount(shell.incidents)}
+
{copy.operator.filters} {activeFilters || '—'}
+
◎ {copy.operator.session} {sessionLabel} {shell.subject && {copy.operator.user}: {shell.subject} }{shell.role && {copy.operator.role}: {shell.role} }{shell.environment && {copy.operator.environment}: {shell.environment} }{release && {copy.operator.release} {release} }{shell.updatedAt && {copy.overview.generated}: {formatDateTime(shell.updatedAt)} } onNavigate('/settings')}>{copy.operator.openSettings} {snapshot.state === 'unauthorized' && }
+
{aggregate.label}
+
+ {searchOpen &&
+
⌕ setQuery(event.target.value)} placeholder={copy.operator.searchPlaceholder} aria-label={copy.operator.search} /> { setSearchOpen(false); setQuery(''); }} aria-label={copy.operator.close}>×
+
+ {!query.trim() && workspace.favorites.length > 0 && <>
Favorieten
{workspace.favorites.map((item) =>
openResult(item.path)}>Favoriet {item.label} → )}>}
+ {!query.trim() && workspace.views.length > 0 && <>
Opgeslagen views
{workspace.views.map((item) =>
openResult(item.path)}>View {item.label} → )}>}
+ {!query.trim() && workspace.recent.length > 0 && <>
Recent
{workspace.recent.slice(0, 8).map((item) =>
openResult(item.path)}>Recent {item.label} → )}>}
+ {staticResults.map((item) =>
openResult(item.path)}>{copy.operator.navigationResult} {item.label} → )}
+ {search.items.map((item) =>
openResult(entityRoute(item))}>{copy.operator.entityResult}{item.entityType ? ` · ${item.entityType}` : ''} {entityLabel(item)} → )}
+ {operationalSearch.items.map((item) =>
openResult(item.path)}>{item.kind === 'incident' ? 'Incident' : 'Melding'} · {item.detail} {item.label} → )}
+ {(search.loading || operationalSearch.loading) &&
{copy.operator.searchLoading}
}
+ {!search.loading && !operationalSearch.loading && query.trim().length >= 2 && staticResults.length === 0 && search.items.length === 0 && operationalSearch.items.length === 0 &&
{copy.operator.noSearchResults}
}
+ {!query.trim() && workspace.favorites.length === 0 && workspace.views.length === 0 && workspace.recent.length === 0 &&
Open een pagina om recente context op te bouwen, of bewaar een favoriete view vanuit de contextbalk.
}
+
+
}
+ ;
+}
diff --git a/apps/web/src/RouteErrorBoundary.tsx b/apps/web/src/RouteErrorBoundary.tsx
new file mode 100644
index 0000000..3ce176e
--- /dev/null
+++ b/apps/web/src/RouteErrorBoundary.tsx
@@ -0,0 +1,18 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import { ApiProblemError } from './apiClient';
+import { copy } from './copy';
+
+type Props = { children: ReactNode; resetKey: string };
+type State = { error: Error | null };
+
+export class RouteErrorBoundary extends Component {
+ state: State = { error: null };
+ static getDerivedStateFromError(error: Error): State { return { error }; }
+ componentDidCatch(_error: Error, _info: ErrorInfo): void {}
+ componentDidUpdate(previous: Props): void { if (previous.resetKey !== this.props.resetKey && this.state.error) this.setState({ error: null }); }
+ render() {
+ if (!this.state.error) return this.props.children;
+ const correlationId = this.state.error instanceof ApiProblemError ? this.state.error.correlationId : undefined;
+ return × {copy.states.boundaryTitle} {copy.states.boundaryDetail}
{correlationId && {copy.states.correlationReference}: {correlationId}
} window.location.reload()}>{copy.states.reloadPage} ;
+ }
+}
diff --git a/apps/web/src/SettingsCapabilityPanel.tsx b/apps/web/src/SettingsCapabilityPanel.tsx
new file mode 100644
index 0000000..8c9691c
--- /dev/null
+++ b/apps/web/src/SettingsCapabilityPanel.tsx
@@ -0,0 +1,32 @@
+import { useEffect, useState } from 'react';
+import { copy } from './copy';
+import { presentComponent, presentReason, presentStatus } from './presentation';
+import { backupPresentation, useSystemStatus } from './systemStatus';
+import { requestJSON } from './apiClient';
+
+type HostCapabilities = { hardware: 'available' | 'not-configured' | 'unsupported'; ups: 'available' | 'not-configured' | 'unsupported' };
+function capability(value: unknown): HostCapabilities[keyof HostCapabilities] {
+ if (!value || typeof value !== 'object') return 'not-configured';
+ const record = value as Record;
+ const state = String(record.state ?? (record.status as Record | undefined)?.state ?? (record.capability as Record | undefined)?.state ?? 'available');
+ if (state === 'unsupported') return 'unsupported';
+ if (state === 'not_configured' || state === 'disabled') return 'not-configured';
+ return 'available';
+}
+
+export function SettingsCapabilityPanel() {
+ const snapshot = useSystemStatus();
+ const [host, setHost] = useState({ hardware: 'not-configured', ups: 'not-configured' });
+ useEffect(() => {
+ const controller = new AbortController();
+ requestJSON>('/api/v1/host', { signal: controller.signal, retries: 1 }).then((value) => setHost({ hardware: capability(value.hardware), ups: capability(value.ups) })).catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setHost({ hardware: 'not-configured', ups: 'not-configured' }); });
+ return () => controller.abort();
+ }, []);
+ const backup = backupPresentation(snapshot.status?.backup);
+ const capabilityLabel = (value: HostCapabilities[keyof HostCapabilities]) => value === 'available' ? 'Beschikbaar' : value === 'unsupported' ? 'Niet ondersteund' : 'Niet geconfigureerd';
+ return
+ Databronnen
Bronstatus {snapshot.status?.sourceLag.length ? {snapshot.status.sourceLag.slice(0, 50).map((source) => {presentComponent(source.sourceId)} {presentStatus(source.state)} · {presentReason(source.reason)} )} : Geen bronnen geconfigureerd of bronstatus niet beschikbaar.
}
+ Back-ups
{presentStatus(backup.state)} {presentReason(backup.reason)}
Open systeemstatus en back-up
+ Hardware-capabilities
Host en UPS
Hardwaretelemetrie {capabilityLabel(host.hardware)}
UPS {capabilityLabel(host.ups)} Ontbrekende hardwaredata wordt niet als gezond geïnterpreteerd. Configuratie blijft in onboarding en de veilige collectorgrens.
+ ;
+}
diff --git a/apps/web/src/SettingsPage.tsx b/apps/web/src/SettingsPage.tsx
new file mode 100644
index 0000000..723ed0b
--- /dev/null
+++ b/apps/web/src/SettingsPage.tsx
@@ -0,0 +1,31 @@
+import { useEffect, useState } from 'react';
+import { navigate } from './AppShell';
+import { copy } from './copy';
+import { SettingsCapabilityPanel } from './SettingsCapabilityPanel';
+import { aggregateStatus, useSystemStatus } from './systemStatus';
+import { readSessionCapabilities, type SessionCapabilities } from './sessionClient';
+
+function StatusBadge({ label, tone }: { label: string; tone: 'unknown' | 'ready' }) { return {tone === 'ready' ? '✓' : '?'} {label} ; }
+
+export function SettingsPage() {
+ const snapshot = useSystemStatus();
+ const [session, setSession] = useState(null);
+ useEffect(() => {
+ const controller = new AbortController();
+ void readSessionCapabilities(controller.signal).then(setSession).catch(() => setSession(null));
+ return () => controller.abort();
+ }, []);
+ 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: 'Sources & health', detail: 'Controleer freshness, componentstatus en bronconfiguratie.', links: [{ href: '/status', title: copy.settings.systemStatus, detail: copy.settings.systemStatusDetail, access: copy.settings.viewAccess }, { 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: 'Presentation', detail: 'Beheer dashboards en start wallboardweergaven vanuit dezelfde presentatielaag.', links: [{ href: '/dashboards', title: copy.settings.dashboards, detail: copy.settings.dashboardsDetail, access: copy.settings.editorChanges }, { href: '/wallboard', title: copy.navigation.wallboard, detail: 'Open de read-only wallboardweergave voor afstandsmonitoring.', access: copy.settings.viewAccess }] },
+ { title: 'Inventory & relations', detail: 'Inspecteer ontdekte entiteiten, provenance, overrides en relationele context.', links: [{ href: '/inventory', title: copy.settings.inventory, detail: copy.settings.inventoryDetail, access: copy.settings.viewAccess }, { href: '/topology', title: copy.navigation.topology, detail: 'Onderzoek dependencies en mogelijke impactrelaties.', access: copy.settings.viewAccess }] },
+ ];
+ const release = snapshot.status?.release;
+ const permissions = session?.permissions;
+ return <>{copy.settings.eyebrow}
{copy.settings.title} {copy.settings.intro}
{copy.settings.current}
{copy.settings.environment} {copy.settings.source} {sourceDetail} {connected}/{total || '—'}
Release {release?.commit ? release.commit.slice(0, 12) : 'Commit niet beschikbaar'} {release?.version || snapshot.status?.version || '—'}
Sessie {session?.subject || 'Gebruiker niet beschikbaar'} {session?.role || 'Onbekend'}
{copy.settings.language} {copy.settings.languageDetail} {copy.settings.languageValue}
Access & RBAC
Jouw operationele rechten {session?.source === 'session' ? 'Session API' : 'Fallback'} View {permissions?.view ? 'Ja' : 'Nee'} Operate {permissions?.operate ? 'Ja' : 'Nee'} Edit {permissions?.edit ? 'Ja' : 'Nee'} Admin {permissions?.admin ? 'Ja' : 'Nee'}
Niet-beschikbare write-controls worden niet als werkende acties gepresenteerd. De Unraid-infrastructuurgrens blijft read-only.
Monitoring policy
Serverbeheerde instellingen
Retention Serverbeheerd · geen veilige write-API
Collectorgrens Read-only
Historische query's Bounded semantic metrics {copy.settings.management} {groups.map((group) => {copy.settings.management}
{group.title} {group.detail}
)} >;
+}
diff --git a/apps/web/src/StoragePage.tsx b/apps/web/src/StoragePage.tsx
index edaf122..a4f5879 100644
--- a/apps/web/src/StoragePage.tsx
+++ b/apps/web/src/StoragePage.tsx
@@ -10,7 +10,9 @@ type Pool = { id: string; name: string; filesystem: string; state: string; utili
type PoolSnapshot = { source: SourceStatus; pools: Pool[] };
type ArrayMember = { id: string; name: string; role: string; state: string };
type ArraySnapshot = { source: SourceStatus; state: string; members: ArrayMember[] };
-export type StorageData = { array: ArraySnapshot; disks: DiskSnapshot; pools: PoolSnapshot };
+type CapacityForecast = { entityId: string; name: string; kind: string; confidence: string; daysToCapacity?: number; projectedAt?: string; rateBytesPerDay: number };
+type ForecastSnapshot = { items?: CapacityForecast[]; qualifiedCount?: number; reason?: string };
+export type StorageData = { array: ArraySnapshot; disks: DiskSnapshot; pools: PoolSnapshot; forecasts?: ForecastSnapshot };
function availability(value: string): string { return value === 'online' || value === 'healthy' || value === 'operational' ? 'healthy' : value === 'unknown' ? 'unknown' : 'degraded'; }
function visualSeverity(...values: Array): string {
@@ -41,10 +43,19 @@ export function buildStorageNodes(data: StorageData): StorageMapNode[] {
export function StoragePage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [data, setData] = useState(null);
- useEffect(() => { const controller = new AbortController(); Promise.all([fetch('/api/v1/array', { signal: controller.signal }).then((response) => response.json() as Promise), fetch('/api/v1/disks?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise), fetch('/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise)]).then(([array, disks, pools]) => { setData({ array, disks, pools }); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
+ useEffect(() => { const controller = new AbortController(); Promise.all([
+ fetch('/api/v1/array', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('array'); return response.json() as Promise; }),
+ fetch('/api/v1/disks?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('disks'); return response.json() as Promise; }),
+ fetch('/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('pools'); return response.json() as Promise; }),
+ fetch('/api/v1/forecasts', { signal: controller.signal }).then((response) => response.ok ? response.json() as Promise : null).catch(() => null),
+ ]).then(([array, disks, pools, forecasts]) => { setData({ array, disks, pools, forecasts: forecasts ?? undefined }); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
if (state === 'loading') return ;
if (state === 'error' || !data) return × {copy.storage.errorTitle} {copy.storage.errorDetail}
;
const nodes = buildStorageNodes(data);
const heatmap: HeatmapPoint[] = (data.disks.disks ?? []).slice(0, 64).map((disk) => ({ id: disk.id, label: disk.name, observedAt: disk.temperature?.observedAt || '', value: disk.temperature?.celsius ?? null, status: visualSeverity(disk.thermalSeverity ?? disk.temperature?.status ?? 'unknown'), href: '/disks/' + encodeURIComponent(disk.id) }));
- return <>{copy.storage.eyebrow}
{copy.storage.title} {copy.storage.intro}
{copy.storage.source}
{copy.storage.sourceTitle} {copy.storage.accessible}
{copy.storage.arraySource} {copy.storage.diskSource} {copy.storage.poolSource} {nodes.length} {copy.storage.mapNodes} · {heatmap.length} {copy.storage.heatmapPoints}
>;
+ const diskProblems = data.disks.disks.filter((disk) => visualSeverity(availability(disk.state), disk.capacitySeverity, disk.thermalSeverity ?? disk.temperature?.status) !== 'healthy');
+ const maxPool = [...data.pools.pools].sort((left, right) => right.utilizationPercent - left.utilizationPercent)[0];
+ const qualifiedForecasts = (data.forecasts?.items ?? []).filter((item) => (item.confidence === 'high' || item.confidence === 'medium') && Number.isFinite(item.daysToCapacity)).sort((left, right) => (left.daysToCapacity ?? Number.POSITIVE_INFINITY) - (right.daysToCapacity ?? Number.POSITIVE_INFINITY));
+ const earliest = qualifiedForecasts[0];
+ return <>{copy.storage.eyebrow}
{copy.storage.title} {copy.storage.intro}
Array Disks Pools Shares Capacity {copy.storage.source}
{copy.storage.sourceTitle} {copy.storage.accessible}
{copy.storage.arraySource} {copy.storage.diskSource} {copy.storage.poolSource} {nodes.length} {copy.storage.mapNodes} · {heatmap.length} {copy.storage.heatmapPoints}
>;
}
diff --git a/apps/web/src/TopologyPage.tsx b/apps/web/src/TopologyPage.tsx
index cf77ef1..0e13480 100644
--- a/apps/web/src/TopologyPage.tsx
+++ b/apps/web/src/TopologyPage.tsx
@@ -34,7 +34,23 @@ function ServiceLink({ node }: { node: TopologyNode }) {
return {node.label} {statusLabel(node.state)} · {node.id} ;
}
-export function TopologyWidget({ topology, compact = false }: { topology: TopologyData; compact?: boolean }) {
+function nodeRoute(node: TopologyNode): string {
+ const kind = (node.kind ?? '').toLowerCase();
+ const id = encodeURIComponent(node.id);
+ if (kind.includes('container')) return '/containers/' + id;
+ if (kind.includes('application') || kind === 'app') return '/applications/' + id;
+ if (kind.includes('disk')) return '/disks/' + id;
+ if (kind.includes('pool')) return '/pools/' + id;
+ if (kind.includes('share')) return '/shares/' + id;
+ if (kind.includes('service') || node.known) 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'));
+}
+
+export function TopologyWidget({ topology, compact = false, selectedId, onSelectNode }: { topology: TopologyData; compact?: boolean; selectedId?: string; onSelectNode?: (node: TopologyNode) => void }) {
const nodes = [...topology.nodes].sort((left, right) => left.id.localeCompare(right.id));
const edges = [...topology.edges].sort((left, right) => left.id.localeCompare(right.id));
const nodeByID = new Map(nodes.map((node) => [node.id, node]));
@@ -44,7 +60,7 @@ export function TopologyWidget({ topology, compact = false }: { topology: Topolo
{copy.topology.nodesTitle}
- {nodes.length === 0 ? {copy.topology.noNodes}
: }
+ {nodes.length === 0 ? {copy.topology.noNodes}
: {nodes.map((node) => {onSelectNode ? onSelectNode(node)}>{node.label} {node.known ? statusLabel(node.state) + ' · ' + node.id : copy.topology.nodeUnknown} : } )} }
{copy.topology.edgesTitle}
@@ -65,6 +81,7 @@ export function TopologyPage() {
const [query, setQuery] = useState('');
const [stateFilter, setStateFilter] = useState('all');
const [relationFilter, setRelationFilter] = useState('all');
+ const [selectedId, setSelectedId] = useState('');
useEffect(() => {
const controller = new AbortController();
setState('loading');
@@ -89,6 +106,9 @@ export function TopologyPage() {
const edges = topology.edges.filter((edge) => nodeIDs.has(edge.from) && nodeIDs.has(edge.to) && (relationFilter === 'all' || edge.relationType === relationFilter));
return { ...topology, nodes, edges };
}, [topology, query, stateFilter, relationFilter]);
+ const selected = topology?.nodes.find((node) => node.id === selectedId) ?? null;
+ const selectedDependencies = selected && topology ? topology.edges.filter((edge) => edge.from === selected.id).map((edge) => ({ edge, node: topology.nodes.find((node) => node.id === edge.to) })).filter((item): item is { edge: TopologyEdge; node: TopologyNode } => Boolean(item.node)).slice(0, 20) : [];
+ const selectedImpacted = selected && topology ? topology.edges.filter((edge) => edge.to === selected.id).map((edge) => ({ edge, node: topology.nodes.find((node) => node.id === edge.from) })).filter((item): item is { edge: TopologyEdge; node: TopologyNode } => Boolean(item.node)).slice(0, 20) : [];
if (state === 'loading') return ;
if (state === 'unauthorized') return ! {copy.topology.unauthorizedTitle} {copy.topology.unauthorizedDetail}
;
if (state === 'error' || !topology || !filtered) return × {copy.topology.errorTitle} {copy.topology.errorDetail}
setReload((value) => value + 1)}>{copy.topology.retry} ;
@@ -96,5 +116,5 @@ export function TopologyPage() {
const unavailable = topology.capabilityState === 'unavailable';
return <>{copy.topology.eyebrow}
{copy.topology.title} {copy.topology.intro}
{unavailable ? copy.topology.unavailable : copy.topology.notConfigured} {unavailable ? copy.topology.unavailableDetail : copy.topology.notConfiguredDetail}
{unavailable ? setReload((value) => value + 1)}>{copy.topology.retry} : {copy.topology.configure} } >;
}
- return <>{copy.topology.eyebrow}
{copy.topology.title} {copy.topology.intro}
{topology.truncated && {copy.topology.truncated}
}>;
+ return <>{copy.topology.eyebrow}
{copy.topology.title} {copy.topology.intro}
setSelectedId((current) => current === node.id ? '' : node.id)} />{selected && Context inspector
{selected.label} {statusLabel(selected.state)}
Type {selected.kind || 'Onbekend'}
Bron {selected.sourceId || copy.topology.sourceUnknown}
Identiteit {selected.id}
Statusreden {selected.reason || 'Geen extra reden aangeleverd'} navigate(nodeRoute(selected))}>Open detail navigate('/inventory/' + encodeURIComponent(selected.id))}>Open inventory
Dependencies
Deze node gebruikt {selectedDependencies.length ? {selectedDependencies.map(({ edge, node }) => setSelectedId(node.id)}>{node.label} {relationLabel(edge.relationType)} · {Math.round(edge.confidence * 100)}% → )} : Geen uitgaande dependencyrelaties in de huidige dataset.
}Impact context
Mogelijk geraakt Dit is relationele context uit de topologie en bewijst geen causaliteit.
{selectedImpacted.length ? {selectedImpacted.map(({ edge, node }) => setSelectedId(node.id)}>{node.label} {relationLabel(edge.relationType)} · {Math.round(edge.confidence * 100)}% → )} : Geen inkomende relaties gevonden.
} } {topology.truncated && {copy.topology.truncated}
}>;
}
diff --git a/apps/web/src/WidgetConfigDrawer.tsx b/apps/web/src/WidgetConfigDrawer.tsx
index 41df049..50e7bd8 100644
--- a/apps/web/src/WidgetConfigDrawer.tsx
+++ b/apps/web/src/WidgetConfigDrawer.tsx
@@ -19,10 +19,11 @@ type Props = {
previewing: boolean;
previewError: string;
viewportLabel: string;
- width: number;
+ placement: { x: number; y: number; w: number; h: number; visible?: boolean };
maxWidth: number;
+ locked: boolean;
onChange: (widget: EditorWidget) => void;
- onWidthChange: (width: number) => void;
+ onPlacementChange: (field: 'x' | 'y' | 'w' | 'h', value: number) => void;
onPreviewStateChange: (state: string) => void;
onPreview: () => void;
};
@@ -42,17 +43,19 @@ function fieldId(name: string): string {
return 'widget-config-' + name.replaceAll('.', '-');
}
-export function WidgetConfigDrawer({ widget, errors, preview, previewState, previewing, previewError, viewportLabel, width, maxWidth, onChange, onWidthChange, onPreviewStateChange, onPreview }: Props) {
+export function WidgetConfigDrawer({ widget, errors, preview, previewState, previewing, previewError, viewportLabel, placement, maxWidth, locked, onChange, onPlacementChange, onPreviewStateChange, onPreview }: Props) {
const data = record(widget.data);
const visualization = record(widget.visualization);
const behavior = record(widget.behavior);
const sourceType = text(data.sourceType, 'inventory');
+ const thresholds = Array.isArray(visualization.thresholds) ? visualization.thresholds as Array> : [];
const error = (name: string) => errors[name] ? {errors[name]}
: null;
const update = (section: 'root' | 'data' | 'visualization' | 'behavior', name: string, value: unknown) => {
if (section === 'root') onChange({ ...widget, [name]: value });
else onChange({ ...widget, [section]: { ...record(widget[section]), [name]: value } });
};
const invalid = Object.keys(errors).length > 0;
+ const updateThreshold = (index: number, name: 'value' | 'color' | 'label', value: unknown) => update('visualization', 'thresholds', thresholds.map((threshold, thresholdIndex) => thresholdIndex === index ? { ...threshold, [name]: value } : threshold));
return
{configCopy.kicker}
@@ -70,7 +73,8 @@ export function WidgetConfigDrawer({ widget, errors, preview, previewState, prev
{configCopy.data}
{configCopy.source} update('data', 'sourceType', event.target.value)}>{configCopy.sourceSemanticMetric} {configCopy.sourceInventory} {configCopy.sourceEvents} {configCopy.sourceAlerts} {configCopy.sourceIncidents} {configCopy.sourceText}
{error('data.sourceType')}
- {sourceType === 'semantic-metric' && <>{configCopy.metric} update('data', 'metric', event.target.value)} /> {error('data.metric')}{configCopy.range} update('data', 'range', event.target.value)}>{configCopy.rangeLive} {configCopy.range15m} {configCopy.range1h} {configCopy.range6h} {configCopy.range24h} {configCopy.range7d} {configCopy.aggregation} update('data', 'aggregation', event.target.value)}>{configCopy.aggregationAvg} {configCopy.aggregationMin} {configCopy.aggregationMax} {configCopy.aggregationSum} {configCopy.aggregationLast}
{error('data.range')}{error('data.aggregation')}>}
+ {sourceType === 'semantic-metric' && <>{configCopy.metric} update('data', 'metric', event.target.value)} /> {error('data.metric')}{configCopy.range} update('data', 'range', event.target.value)}>{configCopy.rangeLive} {configCopy.range15m} {configCopy.range1h} {configCopy.range6h} {configCopy.range24h} {configCopy.range7d} 30 dagen {configCopy.aggregation} update('data', 'aggregation', event.target.value)}>{configCopy.aggregationAvg} {configCopy.aggregationMin} {configCopy.aggregationMax} {configCopy.aggregationSum} {configCopy.aggregationLast}
{error('data.range')}{error('data.aggregation')}>}
+ {sourceType === 'text' && {configCopy.sourceText} update('data', 'text', event.target.value)} /> }
{configCopy.limit} update('data', 'limit', Number(event.target.value))} />
{error('data.limit')}
@@ -79,18 +83,20 @@ export function WidgetConfigDrawer({ widget, errors, preview, previewState, prev
{configCopy.visualization}
{configCopy.unit} update('visualization', 'unit', event.target.value)} /> {configCopy.decimals} update('visualization', 'decimals', Number(event.target.value))} />
{error('visualization.decimals')}
{configCopy.minimum} update('visualization', 'min', event.target.value === '' ? null : Number(event.target.value))} /> {configCopy.maximum} update('visualization', 'max', event.target.value === '' ? null : Number(event.target.value))} />
{error('visualization.max')}
+ {configCopy.thresholds} {thresholds.map((threshold, index) =>
{configCopy.thresholdValue} updateThreshold(index, 'value', Number(event.target.value))} /> {configCopy.thresholdColor} updateThreshold(index, 'color', event.target.value)} /> {configCopy.thresholdLabel} updateThreshold(index, 'label', event.target.value)} /> update('visualization', 'thresholds', thresholds.filter((_, thresholdIndex) => thresholdIndex !== index))}>{configCopy.removeThreshold}
)}
= 10} onClick={() => update('visualization', 'thresholds', [...thresholds, { value: 0, color: '#78a7ff', label: '' }])}>{configCopy.addThreshold} {error('visualization.thresholds')}
-
+
-
+
+ {configCopy.behavior} Refresh, visibility en drilldown
+ {configCopy.refresh} update('behavior', 'liveIntervalSeconds', Number(event.target.value))} /> update('behavior', 'hideWhenEmpty', event.target.checked)} /> {configCopy.hideWhenEmpty} update('behavior', 'showOnlyOnProblem', event.target.checked)} /> {configCopy.showOnlyOnProblem}
{error('behavior.liveIntervalSeconds')}
+ {configCopy.link} update('behavior', 'link', event.target.value || null)} /> {error('behavior.link')}
+
{configCopy.preview}
diff --git a/apps/web/src/apiClient.ts b/apps/web/src/apiClient.ts
new file mode 100644
index 0000000..19e94f6
--- /dev/null
+++ b/apps/web/src/apiClient.ts
@@ -0,0 +1,48 @@
+export type ApiProblem = { title?: string; detail?: string; status?: number; code?: string; correlationId?: string; instance?: string };
+
+export class ApiProblemError extends Error {
+ constructor(readonly status: number, readonly problem: ApiProblem, readonly correlationId?: string) {
+ super(problem.detail || problem.title || `API request failed (${status})`);
+ this.name = 'ApiProblemError';
+ }
+}
+
+type RequestOptions = RequestInit & { retries?: number };
+
+function retryable(method: string, status: number): boolean {
+ return method === 'GET' && (status === 429 || status === 502 || status === 503 || status === 504);
+}
+
+async function problemFrom(response: Response): Promise
{
+ const contentType = response.headers.get('content-type') || '';
+ if (!contentType.includes('json')) return {};
+ try {
+ const value = await response.json() as Record;
+ const bounded = (field: string, limit: number) => typeof value[field] === 'string' ? value[field].slice(0, limit) : undefined;
+ return { title: bounded('title', 240), detail: bounded('detail', 1000), code: bounded('code', 120), correlationId: bounded('correlationId', 160), instance: bounded('instance', 500), status: typeof value.status === 'number' ? value.status : undefined };
+ } catch { return {}; }
+}
+
+export async function requestJSON(url: string, options: RequestOptions = {}): Promise {
+ const { retries = 0, ...init } = options;
+ const method = (init.method || 'GET').toUpperCase();
+ const attempts = Math.min(2, Math.max(0, Math.trunc(retries))) + 1;
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ let response: Response;
+ try { response = await fetch(url, { cache: 'no-store', ...init }); }
+ catch (error) {
+ if (attempt + 1 < attempts && method === 'GET' && !(error instanceof DOMException && error.name === 'AbortError')) continue;
+ throw error;
+ }
+ if (response.ok) return response.status === 204 ? undefined as T : response.json() as Promise;
+ if (attempt + 1 < attempts && retryable(method, response.status)) continue;
+ const problem = await problemFrom(response);
+ const correlationId = (response.headers.get('x-correlation-id') || problem.correlationId)?.slice(0, 160);
+ throw new ApiProblemError(response.status, problem, correlationId || undefined);
+ }
+ throw new ApiProblemError(500, { title: 'Onbereikbare requeststatus' });
+}
+
+export function isAbortError(error: unknown): boolean {
+ return error instanceof DOMException && error.name === 'AbortError';
+}
diff --git a/apps/web/src/copy.ts b/apps/web/src/copy.ts
index ea8cc11..561baf0 100644
--- a/apps/web/src/copy.ts
+++ b/apps/web/src/copy.ts
@@ -3,6 +3,61 @@ export const copy = {
name: 'ITWorx Pulse',
context: 'Observability voor Unraid',
},
+ operator: {
+ commandBar: 'Globale operatorbalk',
+ operationalTruth: 'Operationele verbindingsstatus',
+ server: 'Server',
+ serverUnknown: 'Onbekende server',
+ transport: 'API-verbinding',
+ connected: 'Verbonden',
+ reachable: 'Bereikbaar; sessie vereist',
+ stale: 'Verbonden; gegevens verouderd',
+ connecting: 'Verbinden…',
+ unavailable: 'Niet beschikbaar',
+ sources: 'Bronnen',
+ sourcesUnknown: 'Bronstatus onbekend',
+ sourcesCurrent: 'actueel',
+ range: 'Globale periode',
+ rangeLive: 'Live',
+ range15m: '15 min',
+ range1h: '1 uur',
+ range6h: '6 uur',
+ range24h: '24 uur',
+ range7d: '7 dagen',
+ range30d: '30 dagen',
+ rangeCustom: 'Aangepast',
+ customFrom: 'Vanaf',
+ customTo: 'Tot',
+ customInvalid: 'Kies een geldige begin- en eindtijd; de begintijd moet vóór de eindtijd liggen.',
+ pause: 'Live grafieken pauzeren',
+ resume: 'Live grafieken hervatten',
+ refresh: 'Actuele gegevens vernieuwen',
+ filters: 'Actieve filters',
+ noFilters: 'Geen actieve filters',
+ alerts: 'Actieve meldingen',
+ incidents: 'Open incidenten',
+ search: 'Zoeken in Pulse',
+ searchPlaceholder: 'Pagina, service, container, disk…',
+ searchHint: 'Ctrl/⌘ K',
+ searchResults: 'Zoekresultaten',
+ navigationResult: 'Pagina',
+ entityResult: 'Entiteit',
+ noSearchResults: 'Geen overeenkomsten gevonden.',
+ searchLoading: 'Zoeken…',
+ session: 'Sessie',
+ sessionActive: 'Aangemeld',
+ sessionRequired: 'Aanmelden vereist',
+ sessionUnknown: 'Sessiestatus onbekend',
+ environment: 'Omgeving',
+ user: 'Gebruiker',
+ role: 'Rol',
+ openSettings: 'Instellingen openen',
+ release: 'Release',
+ currentPage: 'Huidige pagina',
+ countCapped: '100+',
+ close: 'Sluiten',
+ },
+
navigation: {
label: 'Hoofdnavigatie',
groups: {
@@ -28,7 +83,7 @@ export const copy = {
network: 'Netwerk',
applications: 'Applicaties',
inventory: 'Inventaris',
- dashboards: 'Dashboards', wallboard: 'Wallboard',
+ dashboards: 'Dashboards', explore: 'Verkennen', wallboard: 'Wallboard',
alerts: 'Meldingen',
events: 'Gebeurtenissen',
incidents: 'Incidenten',
@@ -467,6 +522,9 @@ export const copy = {
retryDetail: 'De widget blijft begrensd verversen zodra de bron data levert.',
unavailableDetail: 'De bron kon niet veilig worden gelezen.', checkedComponents: 'gecontroleerde componenten',
semanticMetric: 'Semantische metriek', events: 'Gebeurtenissen', inventory: 'Inventaris',
+ alerts: 'Meldingen', incidents: 'Incidenten', text: 'Notitie',
+ capabilityDisabled: 'Niet ondersteund voor deze bron', unknownType: 'Onbekend widgettype',
+ partial: 'Gedeeltelijk', stale: 'Verouderd', rows: 'rijen',
},
},
widgets: {
@@ -487,6 +545,13 @@ export const copy = {
queryInspector: 'Query-inspecteur',
unknown: 'Widget',
},
+ explore: {
+ eyebrow: 'Semantische meetruimte', title: 'Metrics verkennen', intro: 'Vind, open en vergelijk goedgekeurde metrics zonder PromQL te schrijven.',
+ workspaces: 'Metricwerkruimte', explore: 'Verkennen', compare: 'Vergelijken', history: 'Historie',
+ catalog: 'Metriccatalogus', findMetric: 'Vind een metric', search: 'Zoeken', searchPlaceholder: 'CPU, opslag, responstijd…', loading: 'Catalogus laden…', catalogError: 'De metriccatalogus kon niet veilig worden geladen.', empty: 'Geen metrics gevonden.', unavailable: 'Niet beschikbaar',
+ range: 'Periode', queryRange: 'Queryperiode', historyRange: 'Historische periode', invalidRange: 'Ongeldige periode', budgetExceeded: 'Serverbudget overschreden', budgetDetail: '{metric} ondersteunt maximaal {days} dagen. Kies een kortere periode.', compareLimit: 'Maximaal vier metrics kunnen tegelijk worden vergeleken.',
+ selectTitle: 'Selecteer een metric', selectDetail: 'Zoek in de catalogus en open een ondersteunde semantische metric.', closeMetric: 'Sluiten', queryError: 'De metricquery kon niet veilig worden uitgevoerd.',
+ },
metrics: {
fresh: 'Actueel',
delayed: 'Vertraagd',
@@ -506,6 +571,7 @@ export const copy = {
chartTitle: 'Tijdreeks voor',
metric: 'meting',
totalSeries: 'Totaal',
+ seriesVisibility: 'Zichtbare reeksen', zoomRecent: 'Zoom op recente helft', resetZoom: 'Volledige periode', annotations: 'Incident- en meldingsannotaties',
summary: 'Tekstuele samenvatting per reeks',
series: 'Reeks',
latest: 'Laatste',
@@ -646,6 +712,10 @@ export const copy = {
errorTitle: 'Er ging iets mis',
errorDetail: 'De pagina kon niet worden geladen. Probeer het opnieuw.',
retry: 'Opnieuw proberen',
+ boundaryTitle: 'Deze pagina kon niet worden weergegeven',
+ boundaryDetail: 'Een onverwachte fout is veilig opgevangen. Probeer de pagina opnieuw te laden.',
+ correlationReference: 'Referentie',
+ reloadPage: 'Pagina opnieuw laden',
unauthorizedTitle: 'Geen toegang',
unauthorizedDetail: 'Je account heeft geen toegang tot deze pagina.',
returnHome: 'Terug naar overzicht',
@@ -707,6 +777,7 @@ export const copy = {
serviceMatrix: 'Servicematrix',
alertSummary: 'Meldingssamenvatting',
text: 'Notitie',
+ queryInspector: 'Query-inspecteur',
fallback: 'Widget',
newWidget: 'Nieuwe widget',
untitled: 'Widget zonder titel',
@@ -725,6 +796,10 @@ export const copy = {
moveDown: 'Widget omlaag: ',
moveUpVisible: 'Omhoog',
moveDownVisible: 'Omlaag',
+ moveLeft: 'Widget naar links: ',
+ moveRight: 'Widget naar rechts: ',
+ moveLeftVisible: 'Links',
+ moveRightVisible: 'Rechts',
unlockLabel: 'Widget ontgrendelen: ',
lockLabel: 'Widget vergrendelen: ',
unlock: 'Ontgrendel',
@@ -753,6 +828,7 @@ export const copy = {
previewFailedConnection: 'Voorbeeld kon niet worden geladen. Controleer de verbinding.',
saveBlockedWidgets: 'Opslaan geblokkeerd: herstel eerst de gemarkeerde widgetconfiguratie.',
saveBlockedVariables: 'Opslaan geblokkeerd: herstel eerst de dashboardvariabelen.',
+ saveBlockedLayouts: 'Opslaan geblokkeerd: widgets overlappen in minstens één viewport.',
saveConflict: 'Conflict: de serverversie is gewijzigd. Herlaad de serverversie voordat je verdergaat.',
saveFailed: 'Opslaan is niet gelukt.',
saveFailedConnection: 'Opslaan is niet gelukt. Controleer de verbinding.',
@@ -770,6 +846,8 @@ export const copy = {
limitRange: 'De limiet moet een geheel getal tussen 1 en 1000 zijn.',
decimalsRange: 'Decimalen moeten tussen 0 en 6 liggen.',
maxBelowMin: 'Maximum moet groter of gelijk aan minimum zijn.',
+ thresholdsInvalid: 'Drempels vereisen een getal en een hexkleur, met maximaal tien regels.',
+ linkInvalid: 'Gebruik alleen een intern Pulse-pad dat met / begint.',
intervalRange: 'Verversing moet tussen 1 en 300 seconden liggen.',
},
config: {
@@ -808,12 +886,23 @@ export const copy = {
decimals: 'Decimalen',
minimum: 'Minimum',
maximum: 'Maximum',
+ thresholds: 'Drempels',
+ thresholdValue: 'Waarde',
+ thresholdColor: 'Kleur',
+ thresholdLabel: 'Label',
+ addThreshold: 'Drempel toevoegen',
+ removeThreshold: 'Drempel verwijderen',
layout: 'Layout',
+ positionX: 'Kolom (x)',
+ positionY: 'Rij (y)',
width: 'Breedte in kolommen',
- widthHint: 'De breedte geldt voor de actieve layout en is ook met pijltjestoetsen op de kaart aan te passen.',
+ height: 'Hoogte in rijen',
+ widthHint: 'x, y, breedte en hoogte gelden alleen voor de actieve layout. Vergrendelde widgets kunnen hier niet worden gewijzigd.',
behavior: 'Gedrag',
refresh: 'Verversing (sec.)',
hideWhenEmpty: 'Verberg bij lege data',
+ showOnlyOnProblem: 'Toon alleen bij problemen',
+ link: 'Interne drilldownlink',
preview: 'Voorbeeld',
previewState: 'Toestand',
previewStateLoading: 'Laden',
@@ -879,3 +968,5 @@ export const copy = {
skipToContent: 'Ga naar hoofdinhoud',
},
} as const;
+
+export { dashboardManagerCopy } from './dashboardManagerCopy';
diff --git a/apps/web/src/dashboard-manager.css b/apps/web/src/dashboard-manager.css
new file mode 100644
index 0000000..365a0cd
--- /dev/null
+++ b/apps/web/src/dashboard-manager.css
@@ -0,0 +1,39 @@
+.dashboard-manager { display: grid; gap: var(--space-5); }
+.dashboard-manager-header { display: flex; align-items: end; justify-content: space-between; gap: var(--space-5); }
+.dashboard-manager-header .button { flex: 0 0 auto; }
+.dashboard-manager-viewer { display: grid; gap: .35rem; border-left: 2px solid var(--unknown); }
+.dashboard-manager-viewer p { margin: 0; color: var(--muted); }
+.dashboard-create-panel { display: grid; gap: var(--space-4); }
+.dashboard-create-panel h2 { margin: 0; }
+.dashboard-create-grid { display: grid; grid-template-columns: minmax(0, 2fr) repeat(2, minmax(8rem, 1fr)); gap: var(--space-3); }
+.dashboard-create-grid label, .dashboard-manager-actions label { display: grid; gap: .35rem; color: var(--muted); font-size: .7rem; font-weight: 700; }
+.dashboard-create-grid input, .dashboard-create-grid select, .dashboard-create-grid textarea, .dashboard-manager-actions input, .dashboard-manager-actions textarea { width: 100%; min-height: 2.6rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-lowest); color: var(--text); padding: .5rem .65rem; }
+.dashboard-create-description { grid-column: 1 / -1; }
+.dashboard-manager-message { position: sticky; top: calc(var(--command-height) + .5rem); z-index: 10; margin: 0; }
+.dashboard-manager-list > ul { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
+.dashboard-manager-row { border-top: 1px solid var(--border-quiet); }
+.dashboard-manager-row:first-child { border-top: 0; }
+.dashboard-manager-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .75rem; padding: .4rem 0; }
+.dashboard-manager-open { display: flex; width: 100%; min-width: 0; min-height: 4rem; align-items: center; justify-content: space-between; gap: 1rem; padding: .65rem .75rem; border: 1px solid transparent; border-radius: .4rem; background: transparent; color: var(--text); text-align: left; }
+.dashboard-manager-open:hover, .dashboard-manager-open:focus-visible { border-color: var(--accent); background: rgb(120 167 255 / .05); }
+.dashboard-manager-open > span:first-child { display: grid; min-width: 0; gap: .18rem; }
+.dashboard-manager-open strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dashboard-manager-open small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
+.dashboard-manager-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem; color: var(--muted); font: 600 .63rem ui-monospace, monospace; }
+.dashboard-manager-meta span { padding: .2rem .35rem; border: 1px solid var(--border-quiet); border-radius: .3rem; }
+.dashboard-manager-manage { min-width: 6rem; }
+.dashboard-manager-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-4); margin: 0 .75rem .8rem; padding: .8rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface-lowest); }
+.dashboard-manager-actions section:first-child { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: end; gap: .65rem; }
+.dashboard-manager-compact-actions { display: flex; flex-wrap: wrap; align-items: end; justify-content: flex-end; gap: .5rem; }
+.dashboard-manager-note { max-width: 18rem; margin: 0; color: var(--muted); font-size: .68rem; }
+.dashboard-manager-confirm, .dashboard-version-panel { grid-column: 1 / -1; }
+.dashboard-manager-confirm { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: .65rem; padding: .7rem; border: 1px solid var(--attention); border-radius: .4rem; }
+.dashboard-manager-confirm p { margin: 0; color: var(--muted); font-size: .72rem; }
+.dashboard-version-panel { border-top: 1px solid var(--border-quiet); padding-top: .7rem; }
+.dashboard-version-panel ol { display: grid; gap: .4rem; margin: 0; padding: 0; list-style: none; }
+.dashboard-version-panel li { display: flex; min-height: 3.2rem; align-items: center; justify-content: space-between; gap: 1rem; padding: .45rem .55rem; border: 1px solid var(--border-quiet); border-radius: .35rem; }
+.dashboard-version-panel li > span { display: grid; gap: .15rem; }
+.dashboard-version-panel small { color: var(--muted); }
+.dashboard-manager-list--archived { opacity: .78; }
+@media (max-width: 900px) { .dashboard-create-grid { grid-template-columns: 1fr 1fr; } .dashboard-create-grid label:first-child, .dashboard-create-description { grid-column: 1 / -1; } .dashboard-manager-actions { grid-template-columns: 1fr; } .dashboard-manager-actions section:first-child { grid-template-columns: 1fr; align-items: stretch; } .dashboard-manager-compact-actions { justify-content: flex-start; } }
+@media (max-width: 700px) { .dashboard-manager-header { align-items: stretch; flex-direction: column; } .dashboard-create-grid { grid-template-columns: 1fr; } .dashboard-create-grid label:first-child, .dashboard-create-description { grid-column: 1; } .dashboard-manager-summary { grid-template-columns: 1fr; } .dashboard-manager-manage { margin: 0 .75rem .5rem; min-height: 2.75rem; } .dashboard-manager-open { align-items: flex-start; flex-direction: column; } .dashboard-manager-meta { justify-content: flex-start; } .dashboard-manager-actions { margin-inline: 0; } .dashboard-manager-confirm { grid-template-columns: 1fr; } .dashboard-version-panel li { align-items: stretch; flex-direction: column; } }
diff --git a/apps/web/src/dashboard-spatial-runtime.css b/apps/web/src/dashboard-spatial-runtime.css
new file mode 100644
index 0000000..0440f62
--- /dev/null
+++ b/apps/web/src/dashboard-spatial-runtime.css
@@ -0,0 +1,9 @@
+.dashboard-grid[data-spatial="true"] { grid-auto-rows: 2.5rem; grid-auto-flow: row; }
+.dashboard-grid[data-spatial="true"] > .widget-card { min-height: 0; height: 100%; overflow: hidden; }
+.dashboard-grid--edit[data-spatial="true"] { position: relative; background-image: linear-gradient(to right, rgb(120 167 255 / .035) 1px, transparent 1px), linear-gradient(to bottom, rgb(120 167 255 / .035) 1px, transparent 1px); background-size: calc(100% / 18) 100%, 100% 2.5rem; }
+.dashboard-grid--edit[data-viewport="tablet"] { grid-template-columns: repeat(8, minmax(0, 1fr)); background-size: calc(100% / 8) 100%, 100% 2.5rem; }
+.dashboard-grid--edit[data-viewport="wallboard"] { grid-template-columns: repeat(24, minmax(0, 1fr)); background-size: calc(100% / 24) 100%, 100% 2.5rem; }
+.editor-spatial-controls { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
+.editor-spatial-controls button { min-width: 2rem; }
+.editor-spatial-position { color: var(--muted); font: 600 .62rem ui-monospace, monospace; }
+@media (max-width: 700px) { .dashboard-grid[data-spatial="true"] { grid-auto-rows: auto; } .dashboard-grid--edit[data-spatial="true"] { background-image: none; } }
diff --git a/apps/web/src/dashboard-spatial.css b/apps/web/src/dashboard-spatial.css
new file mode 100644
index 0000000..a040300
--- /dev/null
+++ b/apps/web/src/dashboard-spatial.css
@@ -0,0 +1,7 @@
+.dashboard-grid[data-spatial="true"] { grid-auto-rows: 1.5rem; grid-auto-flow: row; }
+.dashboard-grid[data-spatial="true"] > .widget-card { min-height: 0; height: 100%; overflow: hidden; }
+.dashboard-grid--edit[data-spatial="true"] { position: relative; background-image: linear-gradient(to right, rgb(120 167 255 / .035) 1px, transparent 1px), linear-gradient(to bottom, rgb(120 167 255 / .035) 1px, transparent 1px); background-size: calc(100% / 18) 100%, 100% 2.5rem; }
+.dashboard-grid--edit[data-viewport="tablet"] { grid-template-columns: repeat(8, minmax(0, 1fr)); background-size: calc(100% / 8) 100%, 100% 2.5rem; }
+.editor-spatial-controls { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
+.editor-spatial-controls button { min-width: 2rem; }
+@media (max-width: 700px) { .dashboard-grid[data-spatial="true"] { grid-auto-rows: auto; } .dashboard-grid--edit[data-spatial="true"] { background-image: none; } }
diff --git a/apps/web/src/dashboardClient.ts b/apps/web/src/dashboardClient.ts
new file mode 100644
index 0000000..0f82bba
--- /dev/null
+++ b/apps/web/src/dashboardClient.ts
@@ -0,0 +1,107 @@
+export type DashboardSummary = {
+ id: string;
+ slug: string;
+ name: string;
+ description: string;
+ ownerId: string;
+ scope: string;
+ archivedAt?: string;
+ revision: number;
+ currentVersion: number;
+};
+
+export type DashboardVersion = {
+ id: string;
+ dashboardId: string;
+ number: number;
+ schemaVersion: number;
+ document: Record;
+ changeSummary: string;
+ createdBy: string;
+ createdAt: string;
+};
+
+export class DashboardApiError extends Error {
+ constructor(readonly status: number, readonly code: string, detail: string, readonly correlationId?: string) { super(detail); }
+ get conflict(): boolean { return this.status === 409 || this.code === 'REVISION_CONFLICT'; }
+}
+
+type ApiRecord = Record;
+function field(record: ApiRecord, name: string): T | undefined {
+ const upper = name.charAt(0).toUpperCase() + name.slice(1);
+ return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
+}
+
+export function dashboardSummaryFromApi(record: ApiRecord): DashboardSummary {
+ const archived = field(record, 'archivedAt');
+ return {
+ id: String(field(record, 'id') ?? ''),
+ slug: String(field(record, 'slug') ?? ''),
+ name: String(field(record, 'name') ?? ''),
+ description: String(field(record, 'description') ?? ''),
+ ownerId: String(field(record, 'ownerId') ?? ''),
+ scope: String(field(record, 'scope') ?? 'unknown'),
+ archivedAt: typeof archived === 'string' && archived ? archived : undefined,
+ revision: Number(field(record, 'revision') ?? 0),
+ currentVersion: Number(field(record, 'currentVersion') ?? 0),
+ };
+}
+
+export function dashboardVersionFromApi(record: ApiRecord): DashboardVersion {
+ return {
+ id: String(field(record, 'id') ?? ''),
+ dashboardId: String(field(record, 'dashboardId') ?? ''),
+ number: Number(field(record, 'number') ?? 0),
+ schemaVersion: Number(field(record, 'schemaVersion') ?? 0),
+ document: field(record, 'document') ?? {},
+ changeSummary: String(field(record, 'changeSummary') ?? ''),
+ createdBy: String(field(record, 'createdBy') ?? ''),
+ createdAt: String(field(record, 'createdAt') ?? ''),
+ };
+}
+
+async function request(url: string, init: RequestInit = {}): Promise {
+ try { return await requestJSON(url, init); }
+ catch (error) {
+ if (!(error instanceof ApiProblemError)) throw error;
+ const problem = error.problem;
+ const detail = problem.detail || (error.status === 403 ? 'Je hebt geen rechten voor deze dashboardwijziging.' : 'De dashboardbewerking kon niet worden uitgevoerd.');
+ throw new DashboardApiError(error.status, problem.code || 'DASHBOARD_REQUEST_FAILED', detail, error.correlationId);
+ }
+}
+
+export async function listDashboards(signal?: AbortSignal): Promise {
+ const data = await request<{ items?: ApiRecord[] }>('/api/v1/dashboards?limit=100', { signal });
+ return (data.items ?? []).map(dashboardSummaryFromApi).filter((item) => item.id !== '');
+}
+
+export async function createDashboard(document: Record): Promise {
+ const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(document) });
+ return dashboardSummaryFromApi(data.dashboard);
+}
+
+export async function updateDashboardMetadata(item: DashboardSummary, name: string, description: string): Promise {
+ const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(item.id), { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'If-Match': String(item.revision) }, body: JSON.stringify({ name, description }) });
+ return dashboardSummaryFromApi(data.dashboard);
+}
+
+export async function cloneDashboard(item: DashboardSummary, slug: string, name: string): Promise {
+ const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(item.id) + '/clone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug, name }) });
+ return dashboardSummaryFromApi(data.dashboard);
+}
+
+export async function archiveDashboard(item: DashboardSummary): Promise {
+ const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(item.id), { method: 'DELETE', headers: { 'If-Match': String(item.revision) } });
+ return dashboardSummaryFromApi(data.dashboard);
+}
+
+export async function listDashboardVersions(item: DashboardSummary, signal?: AbortSignal): Promise {
+ const data = await request<{ items?: ApiRecord[] }>('/api/v1/dashboards/' + encodeURIComponent(item.id) + '/versions', { signal });
+ return (data.items ?? []).map(dashboardVersionFromApi).filter((version) => version.number > 0);
+}
+
+export async function restoreDashboardVersion(item: DashboardSummary, version: number): Promise {
+ const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(item.id) + '/restore/' + version, { method: 'POST', headers: { 'If-Match': String(item.revision) } });
+ return dashboardSummaryFromApi(data.dashboard);
+}
+import { ApiProblemError, requestJSON } from './apiClient';
diff --git a/apps/web/src/dashboardEditorM15Copy.ts b/apps/web/src/dashboardEditorM15Copy.ts
new file mode 100644
index 0000000..766b420
--- /dev/null
+++ b/apps/web/src/dashboardEditorM15Copy.ts
@@ -0,0 +1,11 @@
+export const dashboardEditorM15Copy = {
+ moveLeft: 'Verplaats widget één kolom naar links: ',
+ moveRight: 'Verplaats widget één kolom naar rechts: ',
+ moveUp: 'Verplaats widget één rij omhoog: ',
+ moveDown: 'Verplaats widget één rij omlaag: ',
+ position: 'Positie',
+ heightDecrease: 'Maak widget één rij lager: ',
+ heightIncrease: 'Maak widget één rij hoger: ',
+ spatialHint: 'Sleep vrij over het raster of gebruik de pijlknoppen. Botsingen worden naar de eerstvolgende vrije rij opgelost.',
+ mobileHint: 'Op mobiel blijft de eenvoudige volgorde-editor actief; vrije kolomplaatsing is daar bewust uitgeschakeld.',
+} as const;
diff --git a/apps/web/src/dashboardManagerCopy.ts b/apps/web/src/dashboardManagerCopy.ts
new file mode 100644
index 0000000..5ce6731
--- /dev/null
+++ b/apps/web/src/dashboardManagerCopy.ts
@@ -0,0 +1,3 @@
+export const dashboardManagerCopy = {
+ eyebrow: 'Dashboards', title: 'Dashboardbeheer', intro: 'Open bestaande dashboards of beheer hun lifecycle met versiebeveiliging en rolgebaseerde controls.', active: 'Actieve dashboards', archived: 'Gearchiveerd', showArchived: 'Toon gearchiveerde dashboards', hideArchived: 'Verberg gearchiveerde dashboards', create: 'Nieuw dashboard', createTitle: 'Dashboard aanmaken', name: 'Naam', description: 'Beschrijving', scope: 'Bereik', personal: 'Persoonlijk', shared: 'Gedeeld', system: 'Systeem', template: 'Template', templateBlank: 'Leeg', templateOperations: 'Operationeel overzicht', templateStorage: 'Opslagoverzicht', createAction: 'Dashboard maken', creating: 'Aanmaken…', cancel: 'Annuleren', open: 'Openen', manage: 'Beheren', closeManage: 'Beheer sluiten', rename: 'Metadata wijzigen', saveMetadata: 'Naam en beschrijving opslaan', clone: 'Dupliceren', archive: 'Archiveren', archiveConfirm: 'Dit dashboard archiveren?', archiveConfirmDetail: 'Het dashboard verdwijnt uit de actieve lijst maar de versiehistoriek blijft bewaard.', confirmArchive: 'Ja, archiveren', versions: 'Versiehistoriek', loadVersions: 'Versies bekijken', restore: 'Herstellen als huidige versie', currentVersion: 'Huidige versie', revision: 'Revisie', changedBy: 'Gewijzigd door', systemProtected: 'Systeemdashboard: archiveren is via deze UI uitgeschakeld.', archivedBadge: 'Gearchiveerd', noActive: 'Geen actieve dashboards beschikbaar.', noArchived: 'Geen gearchiveerde dashboards.', viewerTitle: 'Alleen bekijken', viewerDetail: 'Je huidige rol heeft geen dashboardbewerkingsrecht. Beheercontrols zijn daarom verborgen.', loading: 'Dashboards laden…', unauthorized: 'Aanmelden vereist om dashboards te bekijken.', error: 'Dashboardbeheer is tijdelijk niet beschikbaar.', retry: 'Opnieuw laden', created: 'Dashboard aangemaakt.', updated: 'Dashboardmetadata bijgewerkt.', cloned: 'Dashboard gedupliceerd.', archivedDone: 'Dashboard gearchiveerd.', restored: 'Dashboardversie hersteld.', conflict: 'Het dashboard is intussen gewijzigd. De actuele lijst is opnieuw geladen; voer je wijziging opnieuw uit.', failed: 'De dashboardbewerking kon niet worden uitgevoerd.', nameRequired: 'Geef een dashboardnaam op.', noVersions: 'Geen versiehistoriek beschikbaar.', unknownActor: 'onbekend',
+} as const;
diff --git a/apps/web/src/dashboardSpatial.ts b/apps/web/src/dashboardSpatial.ts
new file mode 100644
index 0000000..f7e8fc8
--- /dev/null
+++ b/apps/web/src/dashboardSpatial.ts
@@ -0,0 +1,61 @@
+export type SpatialViewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
+export type SpatialPlacement = { x: number; y: number; w: number; h: number; visible?: boolean };
+export type SpatialItem = { id: string; placement: SpatialPlacement };
+
+export const spatialColumns: Record = { desktop: 18, tablet: 8, mobile: 1, wallboard: 24 };
+
+function integer(value: unknown, fallback: number): number {
+ const number = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback;
+ return number;
+}
+
+export function normalizePlacement(value: Record | undefined, viewport: SpatialViewport): SpatialPlacement {
+ const columns = spatialColumns[viewport];
+ const w = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, integer(value?.w, viewport === 'tablet' ? 8 : 6)));
+ return {
+ x: viewport === 'mobile' ? 0 : Math.min(columns - w, Math.max(0, integer(value?.x, 0))),
+ y: Math.max(0, integer(value?.y, 0)),
+ w,
+ h: Math.max(1, Math.min(24, integer(value?.h, 4))),
+ visible: value?.visible !== false,
+ };
+}
+
+export function overlaps(left: SpatialPlacement, right: SpatialPlacement): boolean {
+ if (left.visible === false || right.visible === false) return false;
+ return left.x < right.x + right.w && left.x + left.w > right.x && left.y < right.y + right.h && left.y + left.h > right.y;
+}
+
+export function resolvePlacement(candidate: SpatialPlacement, others: readonly SpatialItem[], viewport: SpatialViewport): SpatialPlacement {
+ if (viewport === 'mobile') return { ...normalizePlacement(candidate as unknown as Record, viewport), x: 0, w: 1 };
+ let next = normalizePlacement(candidate as unknown as Record, viewport);
+ const bounded = others.filter((item) => item.placement.visible !== false);
+ for (let attempts = 0; attempts < bounded.length + 4; attempts += 1) {
+ const collisions = bounded.filter((item) => overlaps(next, item.placement));
+ if (collisions.length === 0) return next;
+ next = { ...next, y: Math.max(...collisions.map((item) => item.placement.y + item.placement.h)) };
+ }
+ return next;
+}
+
+export function movePlacement(current: SpatialPlacement, dx: number, dy: number, others: readonly SpatialItem[], viewport: SpatialViewport): SpatialPlacement {
+ const columns = spatialColumns[viewport];
+ const candidate = { ...current, x: Math.min(columns - current.w, Math.max(0, current.x + Math.round(dx))), y: Math.max(0, current.y + Math.round(dy)) };
+ return resolvePlacement(candidate, others, viewport);
+}
+
+export function resizePlacement(current: SpatialPlacement, dw: number, dh: number, others: readonly SpatialItem[], viewport: SpatialViewport): SpatialPlacement {
+ const columns = spatialColumns[viewport];
+ const candidate = { ...current, w: viewport === 'mobile' ? 1 : Math.min(columns - current.x, Math.max(1, current.w + Math.round(dw))), h: Math.max(1, Math.min(24, current.h + Math.round(dh))) };
+ return resolvePlacement(candidate, others, viewport);
+}
+
+export function spatialStyle(value: Record | undefined, viewport: SpatialViewport): Record {
+ const placement = normalizePlacement(value, viewport);
+ if (viewport === 'mobile') return { '--widget-span': '1' };
+ return {
+ '--widget-span': String(placement.w),
+ gridColumn: `${placement.x + 1} / span ${placement.w}`,
+ gridRow: `${placement.y + 1} / span ${placement.h}`,
+ };
+}
diff --git a/apps/web/src/dashboardTemplates.ts b/apps/web/src/dashboardTemplates.ts
new file mode 100644
index 0000000..3344590
--- /dev/null
+++ b/apps/web/src/dashboardTemplates.ts
@@ -0,0 +1,120 @@
+export type DashboardTemplateId = 'blank' | 'operations' | 'host' | 'storage' | 'workloads' | 'services' | 'incidents' | 'capacity' | 'wallboard';
+export type DashboardDocument = Record;
+
+type IDFactory = () => string;
+
+function defaultID(): string {
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `dashboard-${Date.now().toString(36)}`;
+}
+
+export function dashboardSlug(name: string, suffix = defaultID().slice(0, 6)): string {
+ const base = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 62) || 'dashboard';
+ return `${base}-${suffix.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8) || 'copy'}`.slice(0, 80).replace(/-+$/g, '');
+}
+
+function layouts(x: number, y: number, w: number, h: number) {
+ return {
+ desktop: { x, y, w, h, visible: true },
+ tablet: { x: 0, y, w: 8, h, visible: true },
+ mobile: { x: 0, y, w: 1, h, visible: true },
+ wallboard: { x, y, w: Math.min(24, Math.max(w, 8)), h, visible: true },
+ };
+}
+
+function behavior(link: string, seconds: number) {
+ return { locked: false, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, link, liveIntervalSeconds: seconds, independentTimeRange: null };
+}
+
+function operationsWidgets(id: IDFactory) {
+ return [
+ { id: id(), type: 'stat', title: 'Serverstatus', description: 'Samengevoegde status van de server.', data: { sourceType: 'inventory', scope: { entityType: 'server', variable: 'server' }, transformations: [] }, visualization: { legend: false, showSparkline: false, decimals: 0, thresholds: [] }, behavior: behavior('/host', 5), layouts: layouts(0, 0, 6, 4) },
+ { id: id(), type: 'timeseries', title: 'CPU-belasting', description: 'Historische en live CPU-benutting.', data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: '$server' }, range: '$timeRange', aggregation: 'avg', groupBy: ['instance'], transformations: [] }, visualization: { unit: 'percent', decimals: 1, legend: true, showSparkline: false, min: 0, max: 100, thresholds: [] }, behavior: behavior('/host', 5), layouts: layouts(6, 0, 12, 7) },
+ { id: id(), type: 'status-grid', title: 'Applicaties', description: 'Actuele applicatiestatus.', data: { sourceType: 'inventory', scope: { entityType: 'application' }, transformations: [], limit: 50 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/applications', 10), layouts: layouts(0, 7, 9, 7) },
+ { id: id(), type: 'event-timeline', title: 'Recente gebeurtenissen', description: 'Recente operationele gebeurtenissen.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(9, 7, 9, 7) },
+ ];
+}
+
+function storageWidgets(id: IDFactory) {
+ return [
+ { id: id(), type: 'storage-map', title: 'Array en pools', description: 'Array-, pool- en diskstatus in één opslagweergave.', data: { sourceType: 'inventory', scope: { entityTypes: ['array', 'pool', 'disk'] }, transformations: [] }, visualization: { legend: true, decimals: 1, thresholds: [] }, behavior: behavior('/storage', 15), layouts: layouts(0, 0, 12, 8) },
+ { id: id(), type: 'event-timeline', title: 'Opslaggebeurtenissen', description: 'Recente gebeurtenissen voor operationele context.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 15), layouts: layouts(12, 0, 6, 8) },
+ ];
+}
+
+
+function hostWidgets(id: IDFactory) {
+ return [
+ { id: id(), type: 'timeseries', title: 'CPU-belasting', description: 'CPU-benutting voor de geselecteerde server.', data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: '$server' }, range: '$timeRange', aggregation: 'avg', transformations: [] }, visualization: { unit: 'percent', decimals: 1, legend: true, min: 0, max: 100, thresholds: [{ value: 85, color: '#f2a65a', label: 'Aandacht' }, { value: 95, color: '#e76f51', label: 'Kritiek' }] }, behavior: behavior('/host', 5), layouts: layouts(0, 0, 12, 7) },
+ { id: id(), type: 'timeseries', title: 'Geheugengebruik', description: 'Geheugenbenutting en trend.', data: { sourceType: 'semantic-metric', metric: 'host.memory.utilization', scope: { serverId: '$server' }, range: '$timeRange', aggregation: 'avg', transformations: [] }, visualization: { unit: 'percent', decimals: 1, legend: true, min: 0, max: 100, thresholds: [{ value: 85, color: '#f2a65a', label: 'Aandacht' }, { value: 95, color: '#e76f51', label: 'Kritiek' }] }, behavior: behavior('/host', 5), layouts: layouts(12, 0, 6, 7) },
+ { id: id(), type: 'ranked-list', title: 'Processen', description: 'Operationele procescontext.', data: { sourceType: 'inventory', scope: { entityType: 'process' }, transformations: [], limit: 20 }, visualization: { legend: false, decimals: 1, thresholds: [] }, behavior: behavior('/processes', 10), layouts: layouts(0, 7, 9, 7) },
+ { id: id(), type: 'event-timeline', title: 'Hostgebeurtenissen', description: 'Recente host- en systeemgebeurtenissen.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(9, 7, 9, 7) },
+ ];
+}
+
+function workloadWidgets(id: IDFactory) {
+ return [
+ { id: id(), type: 'status-grid', title: 'Applicaties', description: 'Applicaties als primaire workloadcontext.', data: { sourceType: 'inventory', scope: { entityType: 'application' }, transformations: [], limit: 60 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/applications', 10), layouts: layouts(0, 0, 9, 7) },
+ { id: id(), type: 'ranked-list', title: 'Containers', description: 'Containers en hun actuele toestand.', data: { sourceType: 'inventory', scope: { entityType: 'container' }, transformations: [], limit: 60 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/containers', 10), layouts: layouts(9, 0, 9, 7) },
+ { id: id(), type: 'event-timeline', title: 'Workload events', description: 'Recente gebeurtenissen rond workloads.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(0, 7, 18, 7) },
+ ];
+}
+
+function serviceWidgets(id: IDFactory) {
+ return [
+ { id: id(), type: 'service-matrix', title: 'Service health', description: 'Beschikbaarheid en serviceproblemen.', data: { sourceType: 'inventory', scope: { entityType: 'service' }, transformations: [], limit: 100 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/services', 10), layouts: layouts(0, 0, 12, 8) },
+ { id: id(), type: 'topology', title: 'Dependencies', description: 'Relationele servicecontext.', data: { sourceType: 'inventory', scope: { entityType: 'service' }, transformations: [], limit: 100 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/topology', 15), layouts: layouts(12, 0, 6, 8) },
+ { id: id(), type: 'event-timeline', title: 'Service events', description: 'Recente servicegebeurtenissen.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(0, 8, 18, 6) },
+ ];
+}
+
+function incidentWidgets(id: IDFactory) {
+ return [
+ { id: id(), type: 'alert-summary', title: 'Actieve meldingen', description: 'Actuele alerts op prioriteit.', data: { sourceType: 'alerts', scope: {}, transformations: [], limit: 100 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/alerts', 5), layouts: layouts(0, 0, 9, 6) },
+ { id: id(), type: 'ranked-list', title: 'Open incidenten', description: 'Incidenten die operatoraandacht vragen.', data: { sourceType: 'incidents', scope: {}, transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/incidents', 5), layouts: layouts(9, 0, 9, 6) },
+ { id: id(), type: 'event-timeline', title: 'Correlatiecontext', description: 'Events rond alerts en incidenten.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(0, 6, 18, 8) },
+ ];
+}
+
+function capacityWidgets(id: IDFactory) {
+ return [
+ ...storageWidgets(id),
+ { id: id(), type: 'ranked-list', title: 'Capaciteitsfocus', description: 'Storage-entiteiten op bruikbare operationele volgorde.', data: { sourceType: 'inventory', scope: { entityTypes: ['pool', 'disk', 'share'] }, transformations: [], limit: 50 }, visualization: { legend: false, decimals: 1, thresholds: [] }, behavior: behavior('/capacity', 30), layouts: layouts(0, 8, 18, 6) },
+ ];
+}
+
+function wallboardWidgets(id: IDFactory) {
+ return [
+ { id: id(), type: 'alert-summary', title: 'Actieve meldingen', description: 'Meldingen die nu aandacht vragen.', data: { sourceType: 'alerts', scope: {}, transformations: [], limit: 50 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/alerts', 5), layouts: layouts(0, 0, 8, 6) },
+ { id: id(), type: 'service-matrix', title: 'Services', description: 'Afstandsleesbare servicestatus.', data: { sourceType: 'inventory', scope: { entityType: 'service' }, transformations: [], limit: 60 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/services', 10), layouts: layouts(8, 0, 10, 6) },
+ { id: id(), type: 'storage-map', title: 'Storage', description: 'Array, pools en disks.', data: { sourceType: 'inventory', scope: { entityTypes: ['array', 'pool', 'disk'] }, transformations: [] }, visualization: { legend: true, decimals: 1, thresholds: [] }, behavior: behavior('/storage', 15), layouts: layouts(0, 6, 10, 8) },
+ { id: id(), type: 'status-grid', title: 'Applicaties', description: 'Applicatiestatus.', data: { sourceType: 'inventory', scope: { entityType: 'application' }, transformations: [], limit: 40 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/applications', 10), layouts: layouts(10, 6, 8, 8) },
+ ];
+}
+
+export function buildDashboardDocument(options: { name: string; description?: string; scope: 'personal' | 'shared'; template: DashboardTemplateId; idFactory?: IDFactory }): DashboardDocument {
+ const id = options.idFactory ?? defaultID;
+ const dashboardID = id();
+ const widgets = options.template === 'operations' ? operationsWidgets(id)
+ : options.template === 'host' ? hostWidgets(id)
+ : options.template === 'storage' ? storageWidgets(id)
+ : options.template === 'workloads' ? workloadWidgets(id)
+ : options.template === 'services' ? serviceWidgets(id)
+ : options.template === 'incidents' ? incidentWidgets(id)
+ : options.template === 'capacity' ? capacityWidgets(id)
+ : options.template === 'wallboard' ? wallboardWidgets(id)
+ : [];
+ return {
+ schemaVersion: 2,
+ id: dashboardID,
+ slug: dashboardSlug(options.name, dashboardID.slice(0, 6)),
+ name: options.name.trim(),
+ description: options.description?.trim() ?? '',
+ scope: options.scope,
+ variables: [
+ { name: 'server', type: 'server', label: 'Server', default: 'primary' },
+ { name: 'timeRange', type: 'time-range', label: 'Periode', default: '1h' },
+ ],
+ widgets,
+ settings: { defaultTimeRange: '1h', live: true, refreshSeconds: 5, rotationSeconds: null },
+ };
+}
diff --git a/apps/web/src/liveBuffer.ts b/apps/web/src/liveBuffer.ts
index e393c23..22c2048 100644
--- a/apps/web/src/liveBuffer.ts
+++ b/apps/web/src/liveBuffer.ts
@@ -48,18 +48,22 @@ export class SeriesRingBuffer {
export function historicalSamplesFromData(data: unknown): LiveSample[] {
if (!data || typeof data !== 'object' || !Array.isArray((data as { result?: unknown[] }).result)) return [];
const result: LiveSample[] = [];
- ((data as { result: unknown[] }).result).forEach((item) => {
+ let remaining = 10_000;
+ ((data as { result: unknown[] }).result).slice(0, 100).forEach((item) => {
+ if (remaining <= 0) return;
if (!item || typeof item !== 'object') return;
const record = item as { metric?: Record; values?: unknown[] };
const labels = record.metric ?? {};
const series = labels.__name__ || JSON.stringify(Object.fromEntries(Object.entries(labels).sort(([a], [b]) => a.localeCompare(b))));
if (!series || !Array.isArray(record.values)) return;
- record.values.forEach((raw) => {
+ record.values.slice(-remaining).forEach((raw) => {
+ if (remaining <= 0) return;
if (!Array.isArray(raw) || raw.length < 2) return;
const seconds = Number(raw[0]);
const value = Number(raw[1]);
if (!Number.isFinite(seconds)) return;
result.push({ series, timestamp: new Date(seconds * 1000).toISOString(), value: Number.isFinite(value) ? value : null, freshness: 'fresh', labels });
+ remaining -= 1;
});
});
return result;
@@ -165,4 +169,4 @@ export class LiveChartAdapter {
get pointCount(): number { return this.store.pointCount(); }
clear(): void { this.store.clear(); }
-}
\ No newline at end of file
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index cf91907..5b8938b 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -2,7 +2,9 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
-
+import './operator-shell.css';
+import './operator-flow.css';
+import './dashboard-spatial-runtime.css';
createRoot(document.getElementById('root')!).render(
diff --git a/apps/web/src/metricClient.ts b/apps/web/src/metricClient.ts
index 0c83786..1aa0933 100644
--- a/apps/web/src/metricClient.ts
+++ b/apps/web/src/metricClient.ts
@@ -1,6 +1,6 @@
import { UI_LOCALE, UI_TIME_ZONE } from './locale';
export type MetricFreshness = 'fresh' | 'delayed' | 'stale' | 'unavailable';
-export type MetricRangePreset = 'live' | '15m' | '1h' | '6h' | '24h' | '7d';
+export type MetricRangePreset = 'live' | '15m' | '1h' | '6h' | '24h' | '7d' | '30d';
export type MetricRange = { from: string; to: string; stepSeconds: number };
export type MetricQueryRequest = { metric: string; scope?: Record; range: MetricRange; aggregation?: string; groupBy?: string[]; maxSeries?: number; maxPoints?: number };
export type MetricInspector = { semanticMetric: string; generatedQuery: string; cost: { series: number; points: number; estimatedSamples: number }; limits: { maxSeries: number; maxPoints: number } };
@@ -30,11 +30,27 @@ export class MetricClient {
}
export function rangeForPreset(preset: MetricRangePreset, now = new Date()): MetricRange {
- const seconds: Record = { live: 300, '15m': 900, '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800 };
+ const seconds: Record = { live: 300, '15m': 900, '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800, '30d': 2592000 };
const to = new Date(now.getTime());
const from = new Date(to.getTime() - seconds[preset] * 1000);
const stepSeconds = preset === 'live' ? 15 : Math.max(15, Math.ceil(seconds[preset] / 4000));
return { from: from.toISOString(), to: to.toISOString(), stepSeconds };
}
+export function rangeForSelection(selection: string, now = new Date()): MetricRange {
+ if (selection === 'paused') return rangeForPreset('live', now);
+ if (selection.startsWith('custom|')) {
+ const [, fromRaw = '', toRaw = ''] = selection.split('|', 3);
+ const from = new Date(fromRaw);
+ const to = new Date(toRaw);
+ if (!Number.isNaN(from.getTime()) && !Number.isNaN(to.getTime()) && from < to) {
+ const seconds = Math.max(1, (to.getTime() - from.getTime()) / 1000);
+ return { from: from.toISOString(), to: to.toISOString(), stepSeconds: Math.max(15, Math.ceil(seconds / 4000)) };
+ }
+ return rangeForPreset('1h', now);
+ }
+ const supported: readonly MetricRangePreset[] = ['live', '15m', '1h', '6h', '24h', '7d', '30d'];
+ return rangeForPreset(supported.includes(selection as MetricRangePreset) ? selection as MetricRangePreset : '1h', now);
+}
+
export function displayTime(iso: string, locale = UI_LOCALE): string { const date = new Date(iso); return Number.isNaN(date.getTime()) ? 'Onbekende tijd' : new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeStyle: 'short', timeZone: UI_TIME_ZONE }).format(date); }
diff --git a/apps/web/src/operator-flow.css b/apps/web/src/operator-flow.css
new file mode 100644
index 0000000..84f69e9
--- /dev/null
+++ b/apps/web/src/operator-flow.css
@@ -0,0 +1,34 @@
+/* M16 operator flow and information architecture. */
+.workspace-context { position: sticky; top: var(--command-height); z-index: 20; display: grid; grid-template-columns: minmax(0,1fr) auto; gap: .65rem; align-items: center; padding: .45rem 1rem; border-bottom: 1px solid var(--border-quiet); background: rgb(8 13 21 / .96); backdrop-filter: blur(10px); }
+.workspace-context-main { display: flex; min-width: 0; align-items: center; gap: .75rem; }
+.workspace-back { display: inline-flex; min-height: 2rem; align-items: center; gap: .35rem; padding: .25rem .45rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); font-size: .68rem; }
+.workspace-back:hover,.workspace-back:focus-visible { border-color: var(--accent); color: var(--accent); }
+.workspace-breadcrumbs { display: flex; min-width: 0; align-items: center; gap: .35rem; color: var(--muted); font-size: .68rem; }
+.workspace-breadcrumbs a,.workspace-breadcrumbs button { overflow: hidden; max-width: 14rem; border: 0; background: transparent; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
+.workspace-breadcrumbs a:hover,.workspace-breadcrumbs a:focus-visible,.workspace-breadcrumbs button:hover,.workspace-breadcrumbs button:focus-visible { color: var(--accent); }
+.workspace-breadcrumbs strong { overflow: hidden; max-width: 18rem; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
+.workspace-actions { display: flex; align-items: center; gap: .35rem; }
+.workspace-action { min-height: 2rem; padding: .25rem .5rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); font-size: .65rem; }
+.workspace-action:hover,.workspace-action:focus-visible,.workspace-action--active { border-color: var(--accent); color: var(--accent); background: rgb(120 167 255 / .06); }
+.workspace-subnav { grid-column: 1 / -1; display: flex; min-width: 0; gap: .25rem; overflow-x: auto; scrollbar-width: thin; }
+.workspace-subnav a { flex: 0 0 auto; padding: .28rem .5rem; border: 1px solid transparent; border-radius: .35rem; color: var(--muted); font-size: .66rem; font-weight: 650; text-decoration: none; }
+.workspace-subnav a:hover,.workspace-subnav a:focus-visible { color: var(--text); border-color: var(--border); }
+.workspace-subnav a[aria-current="page"] { color: var(--accent); border-color: rgb(120 167 255 / .35); background: rgb(120 167 255 / .07); }
+.operator-search-section-title { margin: .5rem .75rem .25rem; color: var(--muted); font-size: .58rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
+.operator-search-results .operator-workspace-row small { text-transform: none; }
+.operator-search-results .operator-workspace-row .operator-workspace-kind { color: var(--accent); text-transform: uppercase; }
+.nav-link--domain { min-height: 2.65rem; }
+.nav-link--domain .nav-icon { color: var(--accent); }
+.action-queue button.problem-link,.overview-data-list button.problem-link { display: grid; width: 100%; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: .65rem; padding: .35rem 0; border: 0; background: transparent; color: inherit; text-align: left; }
+.action-queue button.problem-link:hover strong,.action-queue button.problem-link:focus-visible strong,.overview-data-list button.problem-link:hover strong,.overview-data-list button.problem-link:focus-visible strong { color: var(--accent); }
+.problem-link-arrow { color: var(--muted); }
+@media (max-width: 860px) { .workspace-context { top: 3.45rem; padding-inline: .75rem; } .workspace-actions .workspace-action span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } }
+@media (max-width: 700px) { .workspace-context { position: static; grid-template-columns: minmax(0,1fr) auto; padding: .4rem .65rem; } .workspace-breadcrumbs { font-size: .62rem; } .workspace-breadcrumbs > :first-child,.workspace-breadcrumbs > :nth-child(2) { display: none; } .workspace-breadcrumbs button { min-height: 2.75rem; padding-inline: .35rem; } .workspace-back { min-width: 2.75rem; min-height: 2.75rem; justify-content: center; } .workspace-action { min-width: 2.75rem; min-height: 2.75rem; } .workspace-subnav { margin-inline: -.1rem; } .workspace-subnav a { min-height: 2.5rem; display: inline-flex; align-items: center; } }
+
+.settings-facts-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .4rem .9rem; }
+.settings-access-panel,.settings-policy-panel { margin-top: 1rem; }
+.settings-permission-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: .45rem; margin: .6rem 0; }
+.settings-permission-grid span { display: flex; min-height: 2.8rem; align-items: center; justify-content: space-between; gap: .5rem; padding: .5rem .6rem; border: 1px solid var(--border-quiet); border-radius: .4rem; background: var(--surface-lowest); color: var(--muted); font-size: .68rem; }
+.settings-permission-grid span[data-enabled="true"] { border-color: rgb(92 201 146 / .3); color: var(--ready); }
+.settings-permission-grid strong { color: currentColor; }
+@media (max-width: 700px) { .settings-facts-grid,.settings-permission-grid { grid-template-columns: 1fr; } }
diff --git a/apps/web/src/operator-shell.css b/apps/web/src/operator-shell.css
new file mode 100644
index 0000000..a885c3a
--- /dev/null
+++ b/apps/web/src/operator-shell.css
@@ -0,0 +1,57 @@
+/* M15 operator command bar: truth-first global controls layered after M14. */
+.operator-command-bar { position: sticky; top: 0; z-index: 25; display: grid; grid-template-columns: minmax(13rem, auto) minmax(15rem, .75fr) minmax(0, 1.4fr); min-height: var(--command-height); align-items: center; gap: .75rem; padding: .4rem 1rem; border-bottom: 1px solid var(--border-quiet); background: rgb(7 11 18 / .97); }
+.operator-identity, .operator-truth, .operator-controls { display: flex; min-width: 0; align-items: center; gap: .5rem; }
+.operator-controls { position: relative; justify-content: flex-end; }
+.operator-server-mark { display: grid; width: 1.8rem; height: 1.8rem; flex: 0 0 auto; place-items: center; border: 1px solid var(--border); border-radius: .4rem; background: var(--surface-raised); color: var(--signal); font: 700 .72rem ui-monospace, monospace; }
+.operator-server-copy, .operator-route, .operator-truth-item > span:last-child { display: grid; min-width: 0; line-height: 1.1; }
+.operator-server-copy small, .operator-route small, .operator-truth-item small, .operator-counter small, .operator-filter-state small { color: var(--muted); font-size: .56rem; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; }
+.operator-server-copy strong, .operator-route strong, .operator-truth-item strong { max-width: 13rem; overflow: hidden; color: var(--text); font-size: .74rem; text-overflow: ellipsis; white-space: nowrap; }
+.operator-divider { align-self: stretch; width: 1px; min-height: 1.7rem; background: var(--border-quiet); }
+.operator-route strong { color: var(--accent); }
+.operator-truth { justify-content: center; }
+.operator-truth-item { display: flex; min-width: 0; align-items: center; gap: .35rem; padding: .28rem .45rem; border: 1px solid var(--border-quiet); border-radius: .35rem; color: var(--unknown); background: var(--surface-lowest); }
+.operator-truth-item--ready { color: var(--ready); }
+.operator-truth-item > span:first-child { font-size: .55rem; }
+.operator-truth-item strong { color: currentColor; font-size: .66rem; }
+.operator-range { display: grid; gap: .12rem; color: var(--muted); font-size: .56rem; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
+.operator-range select { min-height: 2.15rem; padding: .25rem 1.6rem .25rem .55rem; border: 1px solid var(--border); border-radius: .35rem; background: #0a111c; color: var(--text); font-size: .68rem; font-weight: 650; }
+.operator-icon-button, .operator-search-trigger, .operator-counter, .operator-session > summary { min-height: 2.15rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); }
+.operator-icon-button { display: grid; min-width: 2.15rem; place-items: center; padding: .3rem; }
+.operator-icon-button:hover, .operator-icon-button:focus-visible, .operator-icon-button--active { border-color: var(--accent); color: var(--accent); background: rgb(120 167 255 / .08); }
+.operator-icon-button:disabled { cursor: not-allowed; opacity: .4; }
+.operator-search-trigger { display: flex; min-width: 9rem; align-items: center; gap: .45rem; padding: .3rem .5rem; text-align: left; }
+.operator-search-trigger > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .68rem; }
+.operator-search-trigger kbd { margin-left: auto; color: #71869f; font: 600 .58rem ui-monospace, monospace; }
+.operator-counter, .operator-filter-state { display: grid; min-width: 3.4rem; align-content: center; justify-items: center; padding: .22rem .4rem; }
+.operator-counter:hover, .operator-counter:focus-visible { border-color: var(--accent); }
+.operator-counter strong, .operator-filter-state strong { color: var(--text); font: 700 .78rem ui-monospace, monospace; }
+.operator-filter-state { min-height: 2.15rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); }
+.operator-session { position: relative; }
+.operator-session > summary { display: grid; width: 2.15rem; place-items: center; cursor: pointer; list-style: none; }
+.operator-session > summary::-webkit-details-marker { display: none; }
+.operator-session[open] > summary { border-color: var(--accent); color: var(--accent); }
+.operator-session-popover { position: absolute; top: calc(100% + .45rem); right: 0; display: grid; width: 17rem; gap: .45rem; padding: .8rem; border: 1px solid var(--border); border-radius: .45rem; background: #0c1420; box-shadow: 0 .9rem 2rem #02050aaa; }
+.operator-session-popover strong { font-size: .8rem; }
+.operator-session-popover small { color: var(--muted); line-height: 1.4; }
+.operator-session-popover button, .operator-session-popover .button { min-height: 2.5rem; margin: 0; padding: .45rem .65rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-raised); color: var(--text); text-align: left; }
+.operator-custom-range { position: absolute; top: calc(100% + .45rem); right: 0; z-index: 4; display: grid; width: min(29rem, calc(100vw - 2rem)); grid-template-columns: repeat(2, minmax(0, 1fr)) auto; gap: .55rem; padding: .75rem; border: 1px solid var(--border); border-radius: .45rem; background: #0c1420; box-shadow: 0 .9rem 2rem #02050aaa; }
+.operator-custom-range label { display: grid; gap: .25rem; color: var(--muted); font-size: .62rem; font-weight: 700; }
+.operator-custom-range input { min-width: 0; min-height: 2.35rem; padding: .4rem .5rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-lowest); color: var(--text); }
+.operator-custom-range p { grid-column: 1 / -1; margin: 0; color: var(--attention); font-size: .68rem; }
+.operator-custom-range .operator-icon-button { align-self: end; }
+.operator-search-panel { position: fixed; top: 4.35rem; left: 50%; z-index: 60; width: min(42rem, calc(100vw - 2rem)); transform: translateX(-50%); overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; background: #0a1019; box-shadow: 0 1.5rem 4rem #02050acc; }
+.operator-search-input { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: .55rem; padding: .7rem .8rem; border-bottom: 1px solid var(--border-quiet); }
+.operator-search-input input { width: 100%; min-height: 2.5rem; border: 0; background: transparent; color: var(--text); font-size: .95rem; outline: 0; }
+.operator-search-input button { display: grid; width: 2.4rem; height: 2.4rem; place-items: center; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-raised); color: var(--muted); }
+.operator-search-results { display: grid; max-height: min(65vh, 32rem); overflow-y: auto; padding: .45rem; }
+.operator-search-results button { display: flex; min-height: 3.25rem; align-items: center; justify-content: space-between; gap: 1rem; padding: .6rem .75rem; border: 1px solid transparent; border-radius: .4rem; background: transparent; color: var(--text); text-align: left; }
+.operator-search-results button:hover, .operator-search-results button:focus-visible { border-color: var(--accent); background: rgb(120 167 255 / .06); }
+.operator-search-results button span:first-child { display: grid; min-width: 0; gap: .12rem; }
+.operator-search-results small { color: var(--muted); font-size: .62rem; text-transform: uppercase; }
+.operator-search-results strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.operator-search-results > p { margin: .5rem; color: var(--muted); font-size: .75rem; }
+.dashboard-controls > label:first-child { display: none; }
+@media (max-width: 1180px) { .operator-command-bar { grid-template-columns: minmax(12rem, auto) minmax(0, 1fr); } .operator-truth { display: none; } .operator-search-trigger { min-width: 2.15rem; width: 2.15rem; padding: .3rem; justify-content: center; } .operator-search-trigger > span:nth-child(2), .operator-search-trigger kbd { display: none; } }
+@media (max-width: 860px) { .operator-command-bar { grid-template-columns: minmax(0, 1fr) auto; padding-inline: .75rem; } .operator-route, .operator-divider, .operator-filter-state { display: none; } .operator-counter { min-width: 2.7rem; } .operator-counter small { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } }
+@media (max-width: 700px) { .operator-command-bar { min-height: 3.45rem; grid-template-columns: minmax(0, 1fr) auto; padding: .35rem .7rem; } .operator-server-mark { display: none; } .operator-server-copy strong { max-width: 10rem; } .operator-controls { gap: .3rem; } .operator-range span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } .operator-range select { width: 5.6rem; min-height: 2.75rem; } .operator-icon-button, .operator-search-trigger, .operator-session > summary { min-width: 2.75rem; min-height: 2.75rem; } .operator-session-popover button, .operator-session-popover .button { min-height: 2.75rem; } .operator-counter, .operator-filter-state { display: none; } .operator-custom-range { position: fixed; top: 3.8rem; right: .7rem; left: .7rem; width: auto; grid-template-columns: 1fr; } .operator-custom-range p { grid-column: 1; } .operator-custom-range .operator-icon-button { position: absolute; top: .4rem; right: .4rem; } .operator-search-panel { top: 3.8rem; width: calc(100vw - 1rem); } .mobile-primary-list { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; } .mobile-primary-list .nav-link > span:last-child { font-size: .75rem; letter-spacing: -.07em; } .mobile-navigation { grid-template-columns: minmax(0, 1fr) 2.75rem; gap: .15rem; padding-inline: .25rem; } .mobile-more .nav-list { right: -.25rem; } }
+@media (max-width: 430px) { .operator-server-copy { display: none; } .operator-command-bar { grid-template-columns: 1fr; } .operator-controls { justify-content: space-between; } .operator-range { flex: 1; } .operator-range select { width: 100%; } }
diff --git a/apps/web/src/operatorContext.ts b/apps/web/src/operatorContext.ts
new file mode 100644
index 0000000..911541d
--- /dev/null
+++ b/apps/web/src/operatorContext.ts
@@ -0,0 +1,148 @@
+import { useSyncExternalStore } from 'react';
+
+export type OperatorRangePreset = 'live' | '15m' | '1h' | '6h' | '24h' | '7d' | '30d' | 'custom';
+export type OperatorContextState = {
+ range: OperatorRangePreset;
+ customFrom: string;
+ customTo: string;
+ paused: boolean;
+ refreshVersion: number;
+};
+
+const STORAGE_PREFIX = 'pulse.operator.';
+const RANGE_PARAM = 'range';
+const FROM_PARAM = 'from';
+const TO_PARAM = 'to';
+export const operatorNavigationEvent = 'pulse:navigation-state';
+const presets = new Set(['live', '15m', '1h', '6h', '24h', '7d', '30d', 'custom']);
+const initialState: OperatorContextState = { range: '1h', customFrom: '', customTo: '', paused: false, refreshVersion: 0 };
+let state = { ...initialState };
+let hydrated = false;
+const listeners = new Set<() => void>();
+
+function localInputFromISO(value: string | null): string {
+ if (!value) return '';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '';
+ const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
+ return local.toISOString().slice(0, 16);
+}
+
+function readStored(name: string): string {
+ try { return window.localStorage.getItem(STORAGE_PREFIX + name) ?? ''; } catch { return ''; }
+}
+
+function storedBoolean(name: string): boolean {
+ return readStored(name) === 'true';
+}
+
+function ensureHydrated(): void {
+ if (hydrated || typeof window === 'undefined') return;
+ hydrated = true;
+ const params = new URLSearchParams(window.location.search);
+ const rawRange = params.get(RANGE_PARAM) || readStored('range') || initialState.range;
+ const range = presets.has(rawRange as OperatorRangePreset) ? rawRange as OperatorRangePreset : initialState.range;
+ const customFrom = range === 'custom' ? localInputFromISO(params.get(FROM_PARAM)) || readStored('customFrom') : readStored('customFrom');
+ const customTo = range === 'custom' ? localInputFromISO(params.get(TO_PARAM)) || readStored('customTo') : readStored('customTo');
+ state = { ...state, range, customFrom, customTo, paused: storedBoolean('paused') };
+}
+
+function persist(next: OperatorContextState): void {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.setItem(STORAGE_PREFIX + 'range', next.range);
+ window.localStorage.setItem(STORAGE_PREFIX + 'customFrom', next.customFrom);
+ window.localStorage.setItem(STORAGE_PREFIX + 'customTo', next.customTo);
+ window.localStorage.setItem(STORAGE_PREFIX + 'paused', String(next.paused));
+ } catch { /* Storage is optional; URL state remains the shareable source. */ }
+ const params = new URLSearchParams(window.location.search);
+ params.set(RANGE_PARAM, next.range);
+ params.delete(FROM_PARAM);
+ params.delete(TO_PARAM);
+ if (next.range === 'custom' && operatorCustomRangeValid(next)) {
+ params.set(FROM_PARAM, new Date(next.customFrom).toISOString());
+ params.set(TO_PARAM, new Date(next.customTo).toISOString());
+ }
+ const query = params.toString();
+ window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '') + window.location.hash);
+ window.dispatchEvent(new CustomEvent(operatorNavigationEvent));
+}
+
+function publish(next: OperatorContextState): void {
+ state = next;
+ persist(next);
+ [...listeners].forEach((listener) => listener());
+}
+
+function update(change: (current: OperatorContextState) => OperatorContextState): void {
+ ensureHydrated();
+ publish(change(state));
+}
+
+function syncFromLocation(): void {
+ if (typeof window === 'undefined') return;
+ const params = new URLSearchParams(window.location.search);
+ const raw = params.get(RANGE_PARAM);
+ if (!raw || !presets.has(raw as OperatorRangePreset)) return;
+ const range = raw as OperatorRangePreset;
+ const customFrom = range === 'custom' ? localInputFromISO(params.get(FROM_PARAM)) || state.customFrom : state.customFrom;
+ const customTo = range === 'custom' ? localInputFromISO(params.get(TO_PARAM)) || state.customTo : state.customTo;
+ const next = { ...state, range, customFrom, customTo };
+ if (JSON.stringify(next) === JSON.stringify(state)) return;
+ state = next;
+ [...listeners].forEach((listener) => listener());
+}
+
+function subscribe(listener: () => void): () => void {
+ ensureHydrated();
+ listeners.add(listener);
+ if (typeof window !== 'undefined' && listeners.size === 1) window.addEventListener('popstate', syncFromLocation);
+ return () => {
+ listeners.delete(listener);
+ if (typeof window !== 'undefined' && listeners.size === 0) window.removeEventListener('popstate', syncFromLocation);
+ };
+}
+
+function getSnapshot(): OperatorContextState { ensureHydrated(); return state; }
+function getServerSnapshot(): OperatorContextState { return initialState; }
+
+export function useOperatorContext(): OperatorContextState {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+export function setOperatorRangePreset(range: OperatorRangePreset): void {
+ update((current) => ({ ...current, range, paused: range === 'live' ? current.paused : false }));
+}
+
+export function setOperatorCustomRange(customFrom: string, customTo: string): void {
+ update((current) => ({ ...current, range: 'custom', customFrom, customTo, paused: false }));
+}
+
+export function setOperatorPaused(paused: boolean): void {
+ update((current) => ({ ...current, paused: current.range === 'live' ? paused : false }));
+}
+
+export function refreshOperatorData(): void {
+ ensureHydrated();
+ update((current) => ({ ...current, refreshVersion: current.refreshVersion + 1 }));
+}
+
+export function operatorCustomRangeValid(value: Pick): boolean {
+ const from = Date.parse(value.customFrom);
+ const to = Date.parse(value.customTo);
+ return Number.isFinite(from) && Number.isFinite(to) && from < to;
+}
+
+export function operatorRangeSelection(value: OperatorContextState): string {
+ if (value.range !== 'custom') return value.range;
+ if (!operatorCustomRangeValid(value)) return '1h';
+ return `custom|${new Date(value.customFrom).toISOString()}|${new Date(value.customTo).toISOString()}`;
+}
+
+/** Clears the singleton between isolated DOM tests; production code never calls this. */
+export function resetOperatorContextForTests(): void {
+ listeners.clear();
+ if (typeof window !== 'undefined') window.removeEventListener('popstate', syncFromLocation);
+ state = { ...initialState };
+ hydrated = false;
+}
diff --git a/apps/web/src/operatorWorkspace.ts b/apps/web/src/operatorWorkspace.ts
new file mode 100644
index 0000000..874ff0b
--- /dev/null
+++ b/apps/web/src/operatorWorkspace.ts
@@ -0,0 +1,97 @@
+export type WorkspaceItem = {
+ id: string;
+ label: string;
+ path: string;
+ kind: 'favorite' | 'view' | 'recent';
+ updatedAt: string;
+};
+
+type WorkspaceState = {
+ favorites: WorkspaceItem[];
+ views: WorkspaceItem[];
+ recent: WorkspaceItem[];
+};
+
+const STORAGE_KEY = 'pulse.operator.workspace.v1';
+export const WORKSPACE_EVENT = 'pulse-workspace-changed';
+
+const emptyState = (): WorkspaceState => ({ favorites: [], views: [], recent: [] });
+
+function validItem(value: unknown): value is WorkspaceItem {
+ if (!value || typeof value !== 'object') return false;
+ const item = value as Partial;
+ return typeof item.id === 'string' && typeof item.label === 'string' && typeof item.path === 'string'
+ && ['favorite', 'view', 'recent'].includes(String(item.kind)) && typeof item.updatedAt === 'string';
+}
+
+export function readWorkspaceState(): WorkspaceState {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return emptyState();
+ const parsed = JSON.parse(raw) as Partial;
+ return {
+ favorites: Array.isArray(parsed.favorites) ? parsed.favorites.filter(validItem).slice(0, 24) : [],
+ views: Array.isArray(parsed.views) ? parsed.views.filter(validItem).slice(0, 24) : [],
+ recent: Array.isArray(parsed.recent) ? parsed.recent.filter(validItem).slice(0, 12) : [],
+ };
+ } catch {
+ return emptyState();
+ }
+}
+
+function writeWorkspaceState(state: WorkspaceState): void {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ } catch {
+ return;
+ }
+ window.dispatchEvent(new CustomEvent(WORKSPACE_EVENT));
+}
+
+function timestamp(): string { return new Date().toISOString(); }
+function cleanPath(path: string): string {
+ if (!path.startsWith('/')) return '/';
+ return path.slice(0, 2048);
+}
+function cleanLabel(label: string): string { return label.trim().slice(0, 160) || 'Pulse'; }
+
+export function isFavorite(path: string): boolean {
+ const clean = cleanPath(path);
+ return readWorkspaceState().favorites.some((item) => item.path === clean);
+}
+
+export function toggleFavorite(label: string, path: string): boolean {
+ const state = readWorkspaceState();
+ const clean = cleanPath(path);
+ const existing = state.favorites.some((item) => item.path === clean);
+ const favorite: WorkspaceItem = { id: 'favorite:' + clean, label: cleanLabel(label), path: clean, kind: 'favorite', updatedAt: timestamp() };
+ state.favorites = existing
+ ? state.favorites.filter((item) => item.path !== clean)
+ : [favorite, ...state.favorites].slice(0, 24);
+ writeWorkspaceState(state);
+ return !existing;
+}
+
+export function saveCurrentView(label: string, path: string): void {
+ const state = readWorkspaceState();
+ const clean = cleanPath(path);
+ const item: WorkspaceItem = { id: 'view:' + clean, label: cleanLabel(label), path: clean, kind: 'view', updatedAt: timestamp() };
+ state.views = [item, ...state.views.filter((value) => value.path !== clean)].slice(0, 24);
+ writeWorkspaceState(state);
+}
+
+export function removeSavedView(path: string): void {
+ const state = readWorkspaceState();
+ const clean = cleanPath(path);
+ state.views = state.views.filter((item) => item.path !== clean);
+ writeWorkspaceState(state);
+}
+
+export function recordRecent(label: string, path: string): void {
+ const state = readWorkspaceState();
+ const clean = cleanPath(path);
+ if (clean === '/404') return;
+ const item: WorkspaceItem = { id: 'recent:' + clean, label: cleanLabel(label), path: clean, kind: 'recent', updatedAt: timestamp() };
+ state.recent = [item, ...state.recent.filter((value) => value.path !== clean)].slice(0, 12);
+ writeWorkspaceState(state);
+}
diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts
index 598570b..7ed88ba 100644
--- a/apps/web/src/routes.ts
+++ b/apps/web/src/routes.ts
@@ -10,6 +10,6 @@ export const routeFromLocation = (pathname: string): RoutePath => {
if (pathname.startsWith('/applications/') && pathname.length > '/applications/'.length) return pathname;
if (pathname.startsWith('/incidents/') && pathname.length > '/incidents/'.length) return pathname;
if (pathname.startsWith('/inventory/') && pathname.length > '/inventory/'.length) return pathname;
- const supported = ['/', '/topology', '/network', '/host', '/array', '/disks', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/applications', '/inventory', '/dashboards', '/wallboard', '/alerts', '/events', '/incidents', '/settings', '/status', '/onboarding', '/loading', '/error', '/unauthorized', '/404'];
+ const supported = ['/', '/topology', '/network', '/host', '/array', '/disks', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/applications', '/inventory', '/dashboards', '/explore', '/wallboard', '/alerts', '/events', '/incidents', '/settings', '/status', '/onboarding', '/loading', '/error', '/unauthorized', '/404'];
return supported.includes(pathname) ? pathname : '/404';
};
diff --git a/apps/web/src/sessionClient.ts b/apps/web/src/sessionClient.ts
new file mode 100644
index 0000000..97aceca
--- /dev/null
+++ b/apps/web/src/sessionClient.ts
@@ -0,0 +1,33 @@
+export type SessionPermissions = { view: boolean; operate: boolean; edit: boolean; admin: boolean };
+export type SessionCapabilities = { subject: string; role: string; environment: string; permissions: SessionPermissions; source: 'session' | 'fallback' };
+
+const none: SessionPermissions = { view: false, operate: false, edit: false, admin: false };
+
+async function fallbackCapabilities(signal?: AbortSignal): Promise {
+ const response = await fetch('/api/v1/widgets/preview', { method: 'GET', cache: 'no-store', signal });
+ if (response.status === 401) throw Object.assign(new Error('unauthorized'), { status: 401 });
+ const edit = response.status === 405;
+ return { subject: '', role: edit ? 'editor+' : 'viewer/operator', environment: '', permissions: { ...none, view: true, operate: edit, edit }, source: 'fallback' };
+}
+
+export async function readSessionCapabilities(signal?: AbortSignal): Promise {
+ const response = await fetch('/api/v1/session', { method: 'GET', cache: 'no-store', signal });
+ if (response.ok) {
+ const value = await response.json() as Partial & { permissions?: Partial };
+ return {
+ subject: typeof value.subject === 'string' ? value.subject : '',
+ role: typeof value.role === 'string' ? value.role : 'unknown',
+ environment: typeof value.environment === 'string' ? value.environment : '',
+ permissions: {
+ view: value.permissions?.view === true,
+ operate: value.permissions?.operate === true,
+ edit: value.permissions?.edit === true,
+ admin: value.permissions?.admin === true,
+ },
+ source: 'session',
+ };
+ }
+ if (response.status === 401) throw Object.assign(new Error('unauthorized'), { status: 401 });
+ if (response.status === 404 || response.status === 405) return fallbackCapabilities(signal);
+ return { subject: '', role: 'unknown', environment: '', permissions: { ...none }, source: 'fallback' };
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 938e24a..420d8f9 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -141,6 +141,66 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
.setting-row { align-items: flex-start; flex-direction: column; }
}
+/* Bounded dashboard runtime adapters. */
+.dashboard-runtime-table-wrap { overflow: auto; max-height: 22rem; border: 1px solid var(--border-quiet); border-radius: .4rem; }
+.dashboard-runtime-table-wrap:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
+.dashboard-runtime-table { width: 100%; border-collapse: collapse; font-size: .78rem; }
+.dashboard-runtime-table th, .dashboard-runtime-table td { padding: .55rem .65rem; border-bottom: 1px solid var(--border-quiet); text-align: left; vertical-align: top; }
+.dashboard-runtime-table th small { display: block; margin-top: .2rem; color: var(--muted); font-weight: 400; }
+.dashboard-alert-summary dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0 0 .65rem; background: var(--border-quiet); }
+.dashboard-alert-summary dl > div { padding: .6rem; background: var(--surface-lowest); }
+.dashboard-alert-summary dt { color: var(--muted); font-size: .68rem; text-transform: uppercase; }
+.dashboard-alert-summary dd { margin: .2rem 0 0; font: 700 1.25rem ui-monospace, SFMono-Regular, Consolas, monospace; }
+.dashboard-text-widget { overflow: auto; max-height: 22rem; line-height: 1.55; }
+.dashboard-text-widget p { margin: 0 0 .65rem; }
+@media (max-width: 700px) { .dashboard-alert-summary dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+
+/* Explore / compare workspace. */
+.explore-mode-tabs { display: flex; gap: .35rem; margin-bottom: .8rem; }
+.explore-mode-tabs button, .metric-series-toggles button { min-height: 2.35rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); cursor: pointer; }
+.explore-mode-tabs button[aria-selected="true"], .metric-series-toggles button[aria-pressed="true"] { border-color: var(--accent); color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
+.explore-layout { display: grid; grid-template-columns: minmax(17rem, 23rem) minmax(0, 1fr); gap: .8rem; align-items: start; }
+.explore-catalog { position: sticky; top: 4rem; max-height: calc(100vh - 5rem); overflow: auto; }
+.explore-catalog > label { display: grid; gap: .3rem; }
+.explore-metric-list { display: grid; gap: .35rem; margin: .7rem 0 0; padding: 0; list-style: none; }
+.explore-metric-list button { display: flex; width: 100%; justify-content: space-between; gap: .65rem; padding: .65rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: transparent; color: inherit; text-align: left; cursor: pointer; }
+.explore-metric-list button[aria-pressed="true"] { border-color: var(--accent); background: rgb(120 167 255 / .08); }
+.explore-metric-list button:disabled { cursor: not-allowed; opacity: .55; }
+.explore-metric-list strong, .explore-metric-list small { display: block; overflow-wrap: anywhere; }
+.explore-metric-list small { margin-top: .2rem; color: var(--muted); }
+.explore-workspace, .explore-results { display: grid; min-width: 0; gap: .8rem; }
+.explore-range { display: flex; align-items: end; justify-content: space-between; gap: .8rem; }
+.explore-range label { display: grid; gap: .25rem; }
+.explore-custom-range { display: flex; gap: .5rem; }
+.metric-interaction-toolbar { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .45rem; }
+.metric-series-toggles { display: flex; overflow-x: auto; gap: .3rem; }
+.metric-series-toggles button { flex: 0 0 auto; min-height: 2rem; padding: .3rem .5rem; font-size: .7rem; }
+.metric-interactive-plane { position: relative; }
+.metric-interactive-plane[style]::after { position: absolute; top: 0; bottom: 0; left: var(--metric-crosshair); width: 1px; pointer-events: none; background: var(--text); opacity: .45; content: ""; }
+.metric-crosshair-output { position: absolute; right: .4rem; bottom: .4rem; padding: .25rem .4rem; border: 1px solid var(--border); border-radius: .25rem; background: var(--surface-lowest); color: var(--text); font: 650 .68rem ui-monospace, SFMono-Regular, Consolas, monospace; }
+.metric-annotations { display: flex; overflow-x: auto; gap: .4rem; margin: .5rem 0 0; padding: 0; list-style: none; }
+.metric-annotations li { flex: 0 0 auto; max-width: 18rem; padding: .35rem .5rem; border-inline-start: 2px solid var(--attention); background: var(--surface-lowest); font-size: .68rem; }
+.metric-annotations li[data-tone="critical"] { border-inline-start-color: var(--critical); }
+.metric-annotations time, .metric-annotations span { display: block; }
+.metric-annotations time { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
+@media (max-width: 1100px) { .explore-layout { grid-template-columns: 1fr; } .explore-catalog { position: static; max-height: 22rem; } }
+@media (max-width: 700px) { .explore-range, .metric-interaction-toolbar { align-items: stretch; flex-direction: column; } .explore-custom-range { flex-direction: column; } }
+
+.incident-alert-list { display: grid; gap: .45rem; margin: .75rem 0; padding: 0; list-style: none; }
+.incident-alert-list > li { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .7rem; border: 1px solid var(--border-quiet); border-radius: .4rem; background: var(--surface-lowest); }
+.incident-alert-list strong, .incident-alert-list small { display: block; }
+.incident-alert-list small { margin-top: .25rem; color: var(--muted); }
+.incident-alert-action { display: flex; flex: 0 0 auto; align-items: center; gap: .5rem; }
+.incident-read-only-boundary { padding-inline-start: .65rem; border-inline-start: 2px solid var(--attention); }
+.settings-capabilities { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
+.settings-capability-card .compact-list li { display: grid; gap: .2rem; }
+.settings-capability-card .compact-list span { color: var(--muted); }
+.settings-capability-list { display: grid; gap: 1px; background: var(--border-quiet); }
+.settings-capability-list > div { display: flex; justify-content: space-between; gap: .75rem; padding: .65rem; background: var(--surface-lowest); }
+.settings-capability-list dd { margin: 0; font-weight: 700; }
+@media (max-width: 900px) { .settings-capabilities { grid-template-columns: 1fr; } }
+@media (max-width: 700px) { .incident-alert-list > li, .incident-alert-action { align-items: stretch; flex-direction: column; } .incident-alert-list button, .incident-follow-up button, .incident-notes-panel button, .settings-capability-card .button { min-height: 44px; width: 100%; } }
+
.event-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.event-summary h2, .event-summary-action strong { display: block; margin: .3rem 0; color: var(--text); font-size: clamp(1.35rem, 2vw, 2rem); }
.event-summary-action { width: 100%; border-color: color-mix(in srgb, var(--critical) 45%, var(--border)); color: inherit; text-align: left; cursor: pointer; }
@@ -187,7 +247,7 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
.metric-query-status { max-width: 28rem; color: var(--muted); font-size: .8rem; }
.dashboard-controls input { min-width: 15rem; }
.view-mode-note { margin-left: auto; color: var(--muted); font-size: .78rem; }
-.dashboard-grid { display: grid; grid-template-columns: repeat(18, minmax(0, 1fr)); gap: 1rem; align-items: stretch; }
+.dashboard-grid { display: grid; grid-template-columns: repeat(18, minmax(0, 1fr)); grid-auto-rows: 3rem; gap: 1rem; align-items: stretch; }
.dashboard-grid > * { grid-column: span var(--widget-span, 6); min-width: 0; }
.widget-card { min-height: 12rem; padding: 1.25rem; border: 1px solid var(--border); border-radius: .8rem; background: var(--surface); box-shadow: 0 1rem 3rem #05080d33; }
.widget-card--error { border-color: var(--unknown); }
@@ -219,7 +279,7 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
.dashboard-view-header { gap: 1rem; }
}
@media (max-width: 700px) {
- .dashboard-grid { grid-template-columns: 1fr; }
+ .dashboard-grid { grid-template-columns: 1fr; grid-auto-rows: auto; }
.dashboard-grid > * { grid-column: span 1; }
.dashboard-view-header { flex-direction: column; }
.dashboard-view-meta { justify-content: flex-start; }
@@ -244,12 +304,13 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
.editor-toolbar .button { margin-top: 0; }
.editor-message { margin: 0; color: var(--unknown); font-size: .85rem; }
.editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 16rem; gap: 1rem; align-items: start; }
-.dashboard-grid--edit { min-height: 12rem; padding: .75rem; border: 1px dashed var(--accent); border-radius: .8rem; background: #0e1622; }
+.dashboard-grid--edit { min-height: 12rem; grid-auto-rows: 3rem; padding: .75rem; border: 1px dashed var(--accent); border-radius: .8rem; background: #0e1622; }
+.dashboard-grid--edit .editor-widget { height: 100%; min-height: 0; overflow: auto; }
.dashboard-grid--edit .editor-widget { cursor: grab; user-select: none; }
.dashboard-grid--edit .editor-widget:active { cursor: grabbing; }
.editor-widget--selected { border-color: var(--accent); box-shadow: 0 0 0 2px #83b8ff44; }
.editor-widget--locked { cursor: default !important; border-color: var(--unknown); }
-.editor-widget--hidden { opacity: .55; }
+.editor-widget--hidden { border-style: dashed; background: color-mix(in srgb, var(--surface) 82%, var(--surface-lowest)); }
.editor-lock-state { color: var(--muted); font-size: .72rem; }
.editor-widget-actions { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: 1rem; }
.editor-widget-actions button { padding: .3rem .45rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-raised); color: var(--text); font-size: .72rem; }
@@ -270,6 +331,10 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
.config-section input:focus-visible, .config-section select:focus-visible, .config-section textarea:focus-visible, .config-preview select:focus-visible { border-color: var(--accent); outline: 2px solid #83b8ff66; outline-offset: 1px; }
.config-section [aria-invalid="true"] { border-color: var(--unknown); }
.config-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; }
+.config-thresholds { display: grid; gap: .55rem; }
+.config-thresholds > span { color: var(--muted); font-size: .78rem; font-weight: 700; }
+.config-threshold-row { display: grid; grid-template-columns: minmax(4.5rem, .7fr) minmax(6rem, .8fr) minmax(7rem, 1fr) auto; gap: .45rem; align-items: end; }
+.config-threshold-row .button, .config-thresholds > .button { min-height: 2.4rem; margin-top: 0; }
.checkbox-field { display: flex !important; align-items: center; gap: .45rem !important; min-height: 2.3rem; }
.checkbox-field input { width: auto; }
.field-error { margin: -.3rem 0 0; color: var(--unknown); font-size: .78rem; }
@@ -281,9 +346,9 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
.preview-state--empty { border-color: var(--unknown); }
.preview-state--error { border-color: var(--critical); }
.preview-state--stale { border-color: var(--attention); }
-@media (max-width: 700px) { .config-fields { grid-template-columns: 1fr; } .editor-config-drawer { margin-top: 0; } }
+@media (max-width: 700px) { .config-fields, .config-threshold-row { grid-template-columns: 1fr; } .editor-config-drawer { margin-top: 0; } }
-.dashboard-grid--edit[data-viewport="mobile"] { grid-template-columns: 1fr; }
+.dashboard-grid--edit[data-viewport="mobile"] { grid-template-columns: 1fr; grid-auto-rows: auto; }
.dashboard-grid--edit[data-viewport="mobile"] > * { grid-column: span 1; }
.dashboard-grid--edit[data-viewport="tablet"] { grid-template-columns: repeat(8, minmax(0, 1fr)); }
.dashboard-grid--edit[data-viewport="wallboard"] { grid-template-columns: repeat(24, minmax(0, 1fr)); }
@@ -589,14 +654,17 @@ a.topology-node:hover, a.topology-node:focus-visible { border-color: var(--accen
.alert-operation-summary .alert-summary-critical { border-color: color-mix(in srgb, var(--critical) 42%, var(--border)); }
.alert-operation-summary .alert-summary-critical strong { color: var(--critical); }
.alert-operation-list-items { display: grid; margin: 1rem 0 0; padding: 0; list-style: none; }
-.alert-operation-list-items li { display: grid; grid-template-columns: minmax(7.5rem, auto) minmax(0, 1fr) auto; align-items: center; gap: .8rem; min-height: 4.1rem; padding: .65rem 0; border-top: 1px solid var(--border); }
+.alert-operation-list-items li { display: grid; grid-template-areas: "severity detail action"; grid-template-columns: minmax(7.5rem, auto) minmax(0, 1fr) auto; align-items: center; gap: .8rem; min-height: 4.1rem; padding: .65rem 0; border-top: 1px solid var(--border); }
+.alert-operation-list-items li > .status-badge { grid-area: severity; }
+.alert-operation-button { grid-area: detail; }
+.alert-operation-action { grid-area: action; }
.alert-operation-button { display: grid; gap: .25rem; min-width: 0; padding: .55rem 0; border: 0; background: transparent; color: var(--text); text-align: left; }
.alert-operation-button small { color: var(--muted); }
.alert-operation-action { min-height: 44px; margin: 0; }
.alert-detail { margin-top: 1rem; padding: 1rem; border: 1px solid var(--border); border-radius: .55rem; }
.alert-detail h3 { margin: 0 0 .35rem; }
.alert-detail p { margin: 0 0 .35rem; color: var(--muted); }
-@media (max-width: 700px) { .alert-section-nav, .alert-operation-summary { grid-template-columns: 1fr; } .alert-section-nav { position: static; } .alert-operation-summary { gap: .65rem; } .alert-operation-summary button { min-height: 5.5rem; } .alert-operation-list-items li { grid-template-columns: minmax(6.25rem, auto) minmax(0, 1fr); } .alert-operation-action { grid-column: 2; justify-self: start; } }
+@media (max-width: 700px) { .alert-section-nav, .alert-operation-summary { grid-template-columns: 1fr; } .alert-section-nav { position: static; } .alert-operation-summary { gap: .65rem; } .alert-operation-summary button { min-height: 5.5rem; } .alert-operation-list-items li { display: flex; align-items: stretch; flex-direction: column; } .alert-operation-button, .alert-operation-action { position: relative; width: 100%; } .alert-operation-button { z-index: 1; } .alert-operation-action { z-index: 2; align-self: stretch; } }
/* M8-10 incident list, detail and timeline */
.incident-list-panel, .incident-timeline-panel, .incident-notes-panel, .incident-workflow-placeholder { max-width: 72rem; margin-top: 1rem; }
.incident-list { display: grid; gap: .25rem; margin: 1.5rem 0 0; padding: 0; list-style: none; }
@@ -644,7 +712,7 @@ a.topology-node:hover, a.topology-node:focus-visible { border-color: var(--accen
.onboarding-summary div { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .8rem 0; border-top: 1px solid var(--border); }
.onboarding-summary dt { color: var(--muted); }
.onboarding-summary dd { margin: 0; font-weight: 750; text-align: right; }
-.onboarding-complete .button { min-height: 44px; }
+.onboarding-complete .button { position: relative; z-index: 1; min-height: 44px; }
.setting-row small { display: block; margin-top: .25rem; color: var(--muted); }
@media (max-width: 800px) { .onboarding-grid { grid-template-columns: 1fr; } }
@media (max-width: 560px) { .onboarding-capabilities li { align-items: flex-start; flex-direction: column; gap: .45rem; } }
@@ -1174,8 +1242,8 @@ button, input, select, textarea { font-family: inherit; }
.brand-copy { position: static; width: auto; height: auto; overflow: visible; clip: auto; white-space: normal; }
.brand small { display: none; }
.mobile-navigation { background: rgb(8 13 21 / .98); box-shadow: 0 -.5rem 2rem #02050aaa; }
- .mobile-navigation { grid-template-columns: minmax(0, 4fr) minmax(3.75rem, 1fr); }
- .mobile-primary-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+ .mobile-navigation { grid-template-columns: minmax(0, 1fr) 3.25rem; }
+ .mobile-primary-list { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.mobile-primary-list .nav-link { width: 100%; min-height: 52px; }
.mobile-primary-list .nav-link > span:last-child { position: static; display: block; width: auto; max-width: none; padding: 0; border: 0; background: transparent; box-shadow: none; font-size: .75rem; font-weight: 650; }
.context-server, .context-divider, .context-product, .context-location > span[aria-hidden="true"] { display: none; }
@@ -1184,6 +1252,8 @@ button, input, select, textarea { font-family: inherit; }
.context-live { font-size: 0; }
.context-live span { font-size: .72rem; }
.content { padding: 1rem 1rem calc(6rem + env(safe-area-inset-bottom)); }
+ html { scroll-padding-block: calc(var(--command-height) + 1rem) calc(6rem + env(safe-area-inset-bottom)); }
+ .content :is(button, a, input, select, textarea) { scroll-margin-block: calc(var(--command-height) + 1rem) calc(6rem + env(safe-area-inset-bottom)); }
.instrument-band { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.instrument-cell { border-top: 1px solid var(--border-quiet); }
.instrument-cell:nth-child(-n + 2) { border-top: 0; }
@@ -1424,6 +1494,8 @@ button, input, select, textarea { font-family: inherit; }
.incident-command-strip > span:first-child { border-inline-start: 0; }
.incident-command-strip small { display: block; margin-bottom: .3rem; color: var(--muted); font-size: .65rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.incident-command-strip strong { display: block; overflow-wrap: anywhere; font: 650 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
+.incident-command-actions { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: .5rem; padding: .65rem .75rem; border-top: 1px solid var(--border-quiet); }
+.incident-command-actions .button { min-height: 2.75rem; }
.incident-rationale { border-inline-start: 2px solid var(--attention); }
.incident-facts { gap: 0; overflow: hidden; border: 1px solid var(--border-quiet); border-radius: .4rem; }
.incident-facts > div { padding: .7rem; border-inline-start: 1px solid var(--border-quiet); }
@@ -1464,7 +1536,7 @@ button, input, select, textarea { font-family: inherit; }
@media (max-width: 700px) {
.incident-page-intro { padding-bottom: .6rem; }
- .incident-command-strip { position: sticky; top: 2.9rem; z-index: 7; grid-template-columns: repeat(2, minmax(0, 1fr)); box-shadow: 0 .75rem 1.5rem #02050a99; }
+ .incident-command-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.incident-command-strip > span { min-height: 3.8rem; padding: .65rem .75rem; border-top: 1px solid var(--border-quiet); }
.incident-command-strip > span:nth-child(-n + 2) { border-top: 0; }
.incident-command-strip > span:nth-child(odd) { border-inline-start: 0; }
diff --git a/apps/web/src/widgetCatalog.ts b/apps/web/src/widgetCatalog.ts
new file mode 100644
index 0000000..dd265b9
--- /dev/null
+++ b/apps/web/src/widgetCatalog.ts
@@ -0,0 +1,40 @@
+import { copy } from './copy';
+
+export const widgetTypes = ['stat', 'timeseries', 'gauge', 'ranked-list', 'status-grid', 'table', 'heatmap', 'event-timeline', 'storage-map', 'topology', 'service-matrix', 'alert-summary', 'text', 'query-inspector'] as const;
+export type WidgetType = typeof widgetTypes[number];
+export type WidgetCatalogItem = { type: WidgetType; title: string; capabilities: string[] };
+
+const labels: 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,
+};
+
+export function isWidgetType(value: unknown): value is WidgetType {
+ return typeof value === 'string' && (widgetTypes as readonly string[]).includes(value);
+}
+export function widgetLabel(value: unknown): string { return isWidgetType(value) ? labels[value] : copy.widgets.unknown; }
+export const fallbackWidgetCatalog: WidgetCatalogItem[] = widgetTypes.map((type) => ({ type, title: labels[type], capabilities: [] }));
+
+function field(record: Record, name: string): unknown {
+ const upper = name.charAt(0).toUpperCase() + name.slice(1);
+ return record[name] ?? record[upper] ?? record[name.toUpperCase()];
+}
+
+export async function readWidgetCatalog(signal?: AbortSignal): Promise {
+ const response = await fetch('/api/v1/widgets/catalog', { signal, cache: 'no-store' });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const payload = await response.json() as { items?: unknown[] };
+ const items = (payload.items ?? []).slice(0, widgetTypes.length).flatMap((value): WidgetCatalogItem[] => {
+ if (!value || typeof value !== 'object') return [];
+ const record = value as Record;
+ const type = field(record, 'type');
+ if (!isWidgetType(type)) return [];
+ const capabilities = field(record, 'capabilities');
+ return [{ type, title: labels[type], capabilities: Array.isArray(capabilities) ? capabilities.filter((item): item is string => typeof item === 'string').slice(0, 20) : [] }];
+ });
+ const byType = new Map(items.map((item) => [item.type, item]));
+ return widgetTypes.map((type) => byType.get(type) ?? fallbackWidgetCatalog.find((item) => item.type === type)!);
+}
diff --git a/apps/web/tests/e2e/accessibility.spec.ts b/apps/web/tests/e2e/accessibility.spec.ts
index 05da32a..2018d4c 100644
--- a/apps/web/tests/e2e/accessibility.spec.ts
+++ b/apps/web/tests/e2e/accessibility.spec.ts
@@ -19,6 +19,14 @@ async function mockAPI(page: Page): Promise {
});
return;
}
+ if (path === '/api/v1/session') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ subject: 'operator@example.test', role: 'operator', environment: 'test', permissions: { view: true, operate: true, edit: false, admin: false } }) });
+ return;
+ }
+ if (path === '/api/v1/alerts') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
+ return;
+ }
if (path === '/api/v1/dashboards') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
@@ -70,7 +78,8 @@ test('overview exposes degraded/unknown state and passes axe', async ({ page },
await expect(page.locator('.desktop-navigation')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toHaveCSS('position', 'fixed');
- await expect(page.locator('.mobile-primary-list .nav-link')).toHaveCount(4);
+ await expect(page.locator('.mobile-primary-list .nav-link')).toHaveCount(5);
+ await expect(page.locator('.mobile-primary-list .nav-link span:last-child')).toHaveText(['Overzicht', 'Incidenten', 'Containers', 'Opslag', 'Services']);
const mobileTargets = await page.locator('.mobile-primary-list .nav-link').evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
expect(mobileTargets.every((height) => height >= 44)).toBe(true);
await expect(page.locator('.signal-path-stage')).toHaveCount(6);
@@ -82,9 +91,9 @@ test('overview exposes degraded/unknown state and passes axe', async ({ page },
} else {
await expect(page.locator('.desktop-navigation')).toBeVisible();
const rail = await page.locator('.sidebar').boundingBox();
- const commandHeader = await page.locator('.context-bar').boundingBox();
+ const commandHeader = await page.locator('.operator-command-bar').boundingBox();
expect(rail?.width).toBeLessThanOrEqual(72);
- expect(commandHeader?.height).toBe(56);
+ expect(commandHeader?.height).toBeGreaterThanOrEqual(48);
await expect(page.locator('.overview-kpi')).toHaveCount(4);
await expect(page.locator('.source-health-strip')).toHaveAttribute('tabindex', '0');
await expect(page.locator('.nav-group')).toHaveCount(6);
@@ -101,18 +110,22 @@ test('overview exposes degraded/unknown state and passes axe', async ({ page },
}
});
-test('grouped navigation reaches infrastructure in two actions', async ({ page }, testInfo) => {
+test('navigation reaches the storage domain in at most two actions', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard heeft geen productnavigatie.');
await page.goto('/');
if (testInfo.project.name === 'mobile-chromium') {
- await page.getByText('Meer', { exact: true }).click();
- await page.getByRole('link', { name: /Disks/ }).click();
+ await page.getByRole('link', { name: 'Opslag' }).click();
} else {
await page.locator('.nav-group').filter({ hasText: 'Infrastructuur' }).locator('summary').click();
- await page.getByRole('link', { name: /Disks/ }).click();
+ await page.getByRole('link', { name: /Opslagkaart/ }).click();
}
- await expect(page).toHaveURL(/\/disks$/);
+ await expect(page).toHaveURL(/\/storage$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ if (testInfo.project.name === 'mobile-chromium') {
+ await page.getByText('Meer', { exact: true }).click();
+ await page.locator('.mobile-more').getByRole('link', { name: /Disks/ }).click();
+ await expect(page).toHaveURL(/\/disks$/);
+ }
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
diff --git a/apps/web/tests/e2e/alert-workspace.spec.ts b/apps/web/tests/e2e/alert-workspace.spec.ts
index 6bd2ddb..3e4a172 100644
--- a/apps/web/tests/e2e/alert-workspace.spec.ts
+++ b/apps/web/tests/e2e/alert-workspace.spec.ts
@@ -57,6 +57,12 @@ test('alertwerkruimte prioriteert operatie en opent configuratie doelgericht', a
await expect(rows.first()).toContainText('Kritiek');
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
+ if (testInfo.project.name === 'mobile-chromium') {
+ const detailBox = await rows.first().locator('.alert-operation-button').boundingBox();
+ const actionBox = await rows.first().locator('.alert-operation-action').boundingBox();
+ expect((detailBox?.y ?? 0) + (detailBox?.height ?? 0)).toBeLessThanOrEqual(actionBox?.y ?? 0);
+ }
+
const acknowledge = page.getByRole('button', { name: 'Erkennen' }).first();
page.once('dialog', (dialog) => dialog.dismiss());
await acknowledge.click();
diff --git a/apps/web/tests/e2e/dashboard-editor-polish.spec.ts b/apps/web/tests/e2e/dashboard-editor-polish.spec.ts
index 3d4749a..19cb721 100644
--- a/apps/web/tests/e2e/dashboard-editor-polish.spec.ts
+++ b/apps/web/tests/e2e/dashboard-editor-polish.spec.ts
@@ -21,7 +21,9 @@ const widgets = [
async function mockDashboard(page: Page): Promise {
await page.route('**/api/v1/**', async (route) => {
const requestPath = new URL(route.request().url()).pathname;
- const body = requestPath === '/api/v1/dashboards/polish-dashboard'
+ const body = requestPath === '/api/v1/session'
+ ? { subject: 'editor-1', role: 'editor', environment: 'test', permissions: { view: true, operate: true, edit: true, admin: false } }
+ : requestPath === '/api/v1/dashboards/polish-dashboard'
? { dashboard, version: { document: { schemaVersion: 2, widgets, variables: [], settings: { defaultTimeRange: '1h' } } } }
: requestPath === '/api/v1/metrics/query-range'
? { status: 'success', data: { result: [{ metric: { __name__: 'host_network_receive', host: 'tower', interface: 'eth0' }, values: [[1786420800, '1200'], [1786420815, '1500']] }] }, provenance: { source: 'prometheus', metric: 'host.network.receive', catalogVersion: '1', cacheKey: 'test' }, sourceObservedAt: '2026-08-11T04:01:00Z', receivedAt: '2026-08-11T04:01:01Z', freshness: 'fresh', cacheHit: false }
@@ -75,7 +77,8 @@ test('dashboard and editor use human labels, safe modes and accessible keyboard
await page.mouse.down();
await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 75, { steps: 4 });
await page.mouse.up();
- await expect(editorWidgets.locator('h3')).toHaveText(['Recente wijzigingen', 'Netwerkbelasting']);
+ await expect(editorWidgets.locator('h3')).toHaveText(['Netwerkbelasting', 'Recente wijzigingen']);
+ await expect(editorWidgets.first()).toHaveCSS('grid-row-start', '2');
const resize = page.getByRole('slider', { name: 'Breedte aanpassen: Netwerkbelasting' });
await expect(resize).toHaveAttribute('aria-valuenow', '9');
@@ -89,3 +92,21 @@ test('dashboard and editor use human labels, safe modes and accessible keyboard
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-advanced-open.png'), fullPage: true });
});
+
+test('mobile composer uses reorder and per-viewport visibility without horizontal overflow', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'mobile-chromium', 'De mobiele composer gebruikt de ondersteunde 390px-viewport.');
+ await mockDashboard(page);
+ await page.goto('/dashboards/polish-dashboard');
+ await page.getByRole('button', { name: 'Bewerken' }).click();
+ await expect(page.getByRole('heading', { level: 1, name: 'Dashboard aanpassen' })).toBeVisible();
+ await expect(page.getByLabel('Layout')).toHaveValue('mobile');
+ await expect(page.getByLabel('Breedte in kolommen')).toHaveValue('1');
+ const widgets = page.locator('.editor-widget');
+ await widgets.nth(1).getByRole('button', { name: 'Widget omhoog: Recente wijzigingen' }).click();
+ await expect(widgets.locator('h3')).toHaveText(['Recente wijzigingen', 'Netwerkbelasting']);
+ await widgets.first().getByRole('button', { name: 'Verberg in Mobiel' }).click();
+ await expect(widgets.first()).toHaveClass(/editor-widget--hidden/);
+ await expect(page.getByRole('button', { name: /Widget naar links/ })).toHaveCount(0);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
+ await expectNoSeriousAxeViolations(page);
+});
diff --git a/apps/web/tests/e2e/dashboard-lifecycle.spec.ts b/apps/web/tests/e2e/dashboard-lifecycle.spec.ts
new file mode 100644
index 0000000..9f9dbf5
--- /dev/null
+++ b/apps/web/tests/e2e/dashboard-lifecycle.spec.ts
@@ -0,0 +1,94 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test, type Page, type Route } from '@playwright/test';
+
+type Dashboard = { id: string; slug: string; name: string; description: string; ownerId: string; scope: string; revision: number; currentVersion: number; archivedAt?: string };
+
+const observedAt = new Date().toISOString();
+const initial: Dashboard = { id: 'dashboard-1', slug: 'operations', name: 'Operations', description: 'Dagelijkse operatie', ownerId: 'editor-1', scope: 'shared', revision: 1, currentVersion: 2 };
+
+async function fulfill(route: Route, body: unknown, status = 200): Promise {
+ await route.fulfill({ status, contentType: status >= 400 ? 'application/problem+json' : 'application/json', body: JSON.stringify(body) });
+}
+
+async function mockShell(page: Page, session: unknown): Promise {
+ await page.route('**/api/v1/system/status', (route) => fulfill(route, { version: '1.5.0', generatedAt: observedAt, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'test', state: 'healthy', reason: 'ok' }] }));
+ await page.route('**/api/v1/session', (route) => fulfill(route, session));
+ await page.route('**/api/v1/host', (route) => fulfill(route, { identity: { name: 'lifecycle-fixture' } }));
+ await page.route('**/api/v1/alerts**', (route) => fulfill(route, { items: [] }));
+ await page.route('**/api/v1/incidents**', (route) => fulfill(route, { items: [] }));
+}
+
+test('editor completes the dashboard lifecycle with revision locks', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'Dashboardbeheer is geen wallboardtaak.');
+ const dashboards: Dashboard[] = [{ ...initial }];
+ const revisionHeaders: string[] = [];
+ await mockShell(page, { subject: 'editor-1', role: 'editor', environment: 'test', permissions: { view: true, operate: true, edit: true, admin: false } });
+ await page.route('**/api/v1/dashboards**', async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ const path = url.pathname;
+ const method = request.method();
+ if (path === '/api/v1/dashboards' && method === 'GET') return fulfill(route, { items: dashboards });
+ if (path === '/api/v1/dashboards' && method === 'POST') {
+ const document = request.postDataJSON() as { slug: string; name: string; description: string; scope: string };
+ const created = { id: 'dashboard-created', slug: document.slug, name: document.name, description: document.description, ownerId: 'editor-1', scope: document.scope, revision: 1, currentVersion: 1 };
+ dashboards.push(created); return fulfill(route, { dashboard: created }, 201);
+ }
+ const item = dashboards.find((candidate) => path.includes(candidate.id));
+ if (!item) return fulfill(route, { code: 'NOT_FOUND' }, 404);
+ if (path.endsWith('/versions') && method === 'GET') return fulfill(route, { items: [
+ { id: 'v2', dashboardId: item.id, number: 2, schemaVersion: 2, document: {}, changeSummary: 'metadata', createdBy: 'editor-1', createdAt: observedAt },
+ { id: 'v1', dashboardId: item.id, number: 1, schemaVersion: 2, document: {}, changeSummary: 'initial', createdBy: 'editor-1', createdAt: observedAt },
+ ] });
+ if (path.endsWith('/restore/1') && method === 'POST') { revisionHeaders.push(request.headers()['if-match'] ?? ''); item.revision += 1; item.currentVersion += 1; return fulfill(route, { dashboard: item }); }
+ if (path.endsWith('/clone') && method === 'POST') {
+ const body = request.postDataJSON() as { slug: string; name: string };
+ const clone = { ...item, id: 'dashboard-clone', slug: body.slug, name: body.name, revision: 1, currentVersion: 1 };
+ dashboards.push(clone); return fulfill(route, { dashboard: clone }, 201);
+ }
+ if (method === 'PATCH') { revisionHeaders.push(request.headers()['if-match'] ?? ''); const body = request.postDataJSON() as { name: string; description: string }; Object.assign(item, body, { revision: item.revision + 1 }); return fulfill(route, { dashboard: item }); }
+ if (method === 'DELETE') { revisionHeaders.push(request.headers()['if-match'] ?? ''); item.revision += 1; item.archivedAt = observedAt; return fulfill(route, { dashboard: item }); }
+ return fulfill(route, { code: 'NOT_FOUND' }, 404);
+ });
+
+ await page.goto('/dashboards');
+ await expect(page.getByRole('heading', { level: 1, name: 'Dashboardbeheer' })).toBeVisible();
+ await page.getByRole('button', { name: 'Nieuw dashboard' }).click();
+ await page.getByLabel('Naam').fill('Nachtzicht');
+ await page.getByLabel('Template').selectOption('blank');
+ await page.getByRole('button', { name: 'Dashboard maken' }).click();
+ await expect(page.locator('.dashboard-manager-row').filter({ has: page.getByText('Nachtzicht', { exact: true }) })).toBeVisible();
+
+ let row = page.locator('.dashboard-manager-row').filter({ hasText: 'Operations' });
+ await row.getByRole('button', { name: 'Beheren' }).click();
+ await row.getByLabel('Naam').fill('Operations EU');
+ await row.getByRole('button', { name: 'Naam en beschrijving opslaan' }).click();
+ row = page.locator('.dashboard-manager-row').filter({ has: page.getByText('Operations EU', { exact: true }) });
+ await row.getByRole('button', { name: 'Beheren' }).click();
+ await row.getByRole('button', { name: 'Dupliceren' }).click();
+ await expect(page.getByText('Operations EU (kopie)', { exact: true })).toBeVisible();
+ await row.getByRole('button', { name: 'Versies bekijken' }).click();
+ await row.getByRole('button', { name: 'Herstellen als huidige versie' }).click();
+ await row.getByRole('button', { name: 'Archiveren' }).click();
+ await row.getByRole('button', { name: 'Ja, archiveren' }).click();
+ await expect(page.getByText('Dashboard gearchiveerd.')).toBeVisible();
+ expect(revisionHeaders).toEqual(['1', '2', '3']);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
+ const serious = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
+ expect(serious).toEqual([]);
+});
+
+test('viewer gets a read-only dashboard manager', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'Dashboardbeheer is geen wallboardtaak.');
+ await mockShell(page, { subject: 'viewer-1', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } });
+ await page.route('**/api/v1/dashboards**', (route) => new URL(route.request().url()).pathname === '/api/v1/dashboards/dashboard-1'
+ ? fulfill(route, { dashboard: initial, version: { document: { schemaVersion: 2, id: '00000000-0000-4000-8000-000000000001', slug: 'operations', name: 'Operations', scope: 'shared', variables: [], widgets: [], settings: { defaultTimeRange: '1h' } } } })
+ : fulfill(route, { items: [initial] }));
+ await page.goto('/dashboards');
+ await expect(page.getByText('Alleen bekijken')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Nieuw dashboard' })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Beheren' })).toHaveCount(0);
+ await page.locator('.dashboard-manager-open').click();
+ await expect(page.getByRole('heading', { level: 1, name: 'Operations' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Bewerken' })).toHaveCount(0);
+});
diff --git a/apps/web/tests/e2e/dashboard-widget-runtime.spec.ts b/apps/web/tests/e2e/dashboard-widget-runtime.spec.ts
new file mode 100644
index 0000000..06a4aaf
--- /dev/null
+++ b/apps/web/tests/e2e/dashboard-widget-runtime.spec.ts
@@ -0,0 +1,40 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test, type Page, type Route } from '@playwright/test';
+
+const observedAt = '2026-09-01T10:00:00Z';
+const layout = { desktop: { x: 0, y: 0, w: 6, h: 4, visible: true }, tablet: { x: 0, y: 0, w: 6, h: 4, visible: true }, mobile: { x: 0, y: 0, w: 1, h: 4, visible: true }, wallboard: { x: 0, y: 0, w: 6, h: 4, visible: true } };
+
+async function fulfill(route: Route, body: unknown): Promise { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); }
+async function shell(page: Page): Promise {
+ await page.route('**/api/v1/system/status', (route) => fulfill(route, { version: '1.5.0', generatedAt: observedAt, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }));
+ await page.route('**/api/v1/session', (route) => fulfill(route, { subject: 'viewer-1', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } }));
+ await page.route('**/api/v1/host', (route) => fulfill(route, { identity: { name: 'runtime-fixture' } }));
+}
+
+test('dashboard renders bounded alerts, incidents, text and contains an unknown type', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'De runtimeflow wordt apart als wallboard gevalideerd.');
+ await shell(page);
+ await page.route('**/api/v1/alerts**', (route) => fulfill(route, { items: [{ id: 'alert-1', ruleName: 'Disk warm', state: 'firing', severity: 'critical', entityName: 'disk1', reason: 'Temperatuur hoog', updatedAt: observedAt }] }));
+ await page.route('**/api/v1/incidents**', (route) => fulfill(route, { items: [{ id: 'incident-1', title: 'Opslagincident', status: 'open', severity: 'warning', summary: 'Controle nodig', updatedAt: observedAt }] }));
+ await page.route('**/api/v1/dashboards/runtime', (route) => fulfill(route, {
+ dashboard: { id: 'runtime', slug: 'runtime', name: 'Runtimebewijs', description: 'Widgetadapters', scope: 'shared', revision: 1, currentVersion: 1 },
+ version: { document: { variables: [], widgets: [
+ { id: 'alert-widget', type: 'alert-summary', title: 'Actieve meldingen', data: { sourceType: 'alerts', limit: 1000 }, behavior: { liveIntervalSeconds: 1 }, layouts: layout },
+ { id: 'incident-widget', type: 'table', title: 'Open incidenten', data: { sourceType: 'incidents', limit: 1000 }, behavior: { liveIntervalSeconds: 1 }, layouts: { ...layout, desktop: { ...layout.desktop, x: 6 } } },
+ { id: 'text-widget', type: 'text', title: 'Runbook', data: { sourceType: 'text', text: '# Start\n[Open incidenten](/incidents)\n' }, behavior: {}, layouts: { ...layout, desktop: { ...layout.desktop, y: 4 } } },
+ { id: 'unknown-widget', type: 'made-up', title: 'Onbekend', data: { sourceType: 'alerts', limit: 5 }, behavior: {}, layouts: { ...layout, desktop: { ...layout.desktop, x: 6, y: 4 } } },
+ ] } },
+ }));
+
+ await page.goto('/dashboards/runtime');
+ await expect(page.getByRole('heading', { level: 1, name: 'Runtimebewijs' })).toBeVisible();
+ await expect(page.getByText('Disk warm')).toBeVisible();
+ await expect(page.getByText('Opslagincident')).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Open incidenten' })).toHaveAttribute('href', '/incidents');
+ await expect(page.getByText('')).toBeVisible();
+ await expect(page.getByText('Onbekend widgettype')).toBeVisible();
+ expect(await page.evaluate(() => document.querySelectorAll('script').length)).toBeLessThan(10);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
+ const serious = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
+ expect(serious).toEqual([]);
+});
diff --git a/apps/web/tests/e2e/event-timeline.spec.ts b/apps/web/tests/e2e/event-timeline.spec.ts
index bbba6c7..b1a5f9e 100644
--- a/apps/web/tests/e2e/event-timeline.spec.ts
+++ b/apps/web/tests/e2e/event-timeline.spec.ts
@@ -47,12 +47,14 @@ test('100 events blijven compact, filterbaar en toetsenbordnavigeerbaar', async
await expect(rows).toHaveCount(10);
await page.getByLabel('Onderdeel').selectOption('entity-00');
await expect(rows).toHaveCount(10);
- await page.getByLabel('Zoeken').fill('Gebeurtenis 091');
+ await page.getByRole('searchbox', { name: 'Zoeken' }).fill('Gebeurtenis 091');
await expect(rows).toHaveCount(1);
expect(eventRequests).toBe(baselineRequests);
await page.getByRole('button', { name: 'Filters wissen' }).click();
const next = page.getByRole('button', { name: 'Volgende pagina' });
+ // Keep keyboard focus reliable when animation frames are throttled.
+ await page.evaluate(() => { window.requestAnimationFrame = () => 0; });
await next.focus();
await page.keyboard.press('Enter');
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
diff --git a/apps/web/tests/e2e/explore-metrics.spec.ts b/apps/web/tests/e2e/explore-metrics.spec.ts
new file mode 100644
index 0000000..47635af
--- /dev/null
+++ b/apps/web/tests/e2e/explore-metrics.spec.ts
@@ -0,0 +1,47 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test, type Page, type Route } from '@playwright/test';
+
+const nowSeconds = Math.floor(Date.parse('2026-09-01T10:00:00Z') / 1000);
+const metrics = [
+ { semanticName: 'host.cpu.utilization', description: 'CPU-gebruik van de host', unit: 'percent', valueKind: 'gauge', defaultAggregation: 'avg', allowedVisualizations: ['timeseries'], limits: { maxRangeSeconds: 2_592_000, maxSeries: 20, maxPoints: 4000, timeoutSeconds: 5 } },
+ { semanticName: 'host.memory.utilization', description: 'Geheugengebruik van de host', unit: 'percent', valueKind: 'gauge', defaultAggregation: 'avg', allowedVisualizations: ['timeseries'], limits: { maxRangeSeconds: 2_592_000, maxSeries: 20, maxPoints: 4000, timeoutSeconds: 5 } },
+];
+async function fulfill(route: Route, body: unknown): Promise { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); }
+async function shell(page: Page): Promise {
+ await page.route('**/api/v1/system/status', (route) => fulfill(route, { version: '1.5.0', generatedAt: '2026-09-01T10:00:00Z', overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }));
+ await page.route('**/api/v1/session', (route) => fulfill(route, { subject: 'viewer', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } }));
+ await page.route('**/api/v1/host', (route) => fulfill(route, { identity: { name: 'explore-fixture' } }));
+ await page.route('**/api/v1/incidents**', (route) => fulfill(route, { items: [{ id: 'incident-1', title: 'Opslagincident', severity: 'warning', updatedAt: '2026-09-01T09:55:00Z' }] }));
+ await page.route('**/api/v1/alerts**', (route) => fulfill(route, { items: [] }));
+}
+
+test('operator finds, compares and queries a local custom range as UTC', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name === 'wallboard-chromium', 'Explore is een interactieve operatorroute.');
+ const requests: Array> = [];
+ await shell(page);
+ await page.route('**/api/v1/metrics/catalog', (route) => fulfill(route, { version: 'v1', metrics, bindings: metrics.map((metric) => ({ semanticName: metric.semanticName, state: 'supported' })) }));
+ await page.route('**/api/v1/metrics/query-range', async (route) => {
+ const body = route.request().postDataJSON() as Record; requests.push(body);
+ await fulfill(route, { status: 'success', data: { result: [{ metric: { host: 'tower' }, values: [[nowSeconds - 60, '41'], [nowSeconds, '42']] }] }, provenance: { source: 'fixture', metric: body.metric, catalogVersion: 'v1', cacheKey: 'bounded' }, sourceObservedAt: '2026-09-01T10:00:00Z', receivedAt: '2026-09-01T10:00:01Z', freshness: 'fresh', cacheHit: false });
+ });
+
+ await page.goto('/explore');
+ await expect(page.getByRole('heading', { level: 1, name: 'Metrics verkennen' })).toBeVisible();
+ await page.getByPlaceholder('CPU, opslag, responstijd…').fill('cpu');
+ await page.getByRole('button', { name: /host.cpu.utilization/ }).click();
+ await expect(page.getByRole('img', { name: /Tijdreeks/ })).toBeVisible();
+ await expect(page.getByRole('list', { name: 'Incident- en meldingsannotaties' })).toContainText('Opslagincident');
+ await page.getByRole('tab', { name: 'Vergelijken' }).click();
+ await page.getByPlaceholder('CPU, opslag, responstijd…').fill('memory');
+ await page.getByRole('button', { name: /host.memory.utilization/ }).click();
+ await expect(page.getByRole('heading', { name: 'host.memory.utilization' })).toBeVisible();
+ await page.getByLabel('Periode').last().selectOption('custom');
+ await page.getByLabel('Vanaf').last().fill('2026-09-01T08:00');
+ await page.getByLabel('Tot').last().fill('2026-09-01T10:00');
+ await expect.poll(() => requests.filter((request) => (request.range as { from?: string })?.from === new Date('2026-09-01T08:00').toISOString()).length).toBeGreaterThan(0);
+ const latest = requests.at(-1)!;
+ expect(latest).toMatchObject({ maxSeries: 20, maxPoints: 4000, range: { from: new Date('2026-09-01T08:00').toISOString(), to: new Date('2026-09-01T10:00').toISOString() } });
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
+ const serious = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
+ expect(serious).toEqual([]);
+});
diff --git a/apps/web/tests/e2e/large-lists.spec.ts b/apps/web/tests/e2e/large-lists.spec.ts
index 38749d3..0d8400c 100644
--- a/apps/web/tests/e2e/large-lists.spec.ts
+++ b/apps/web/tests/e2e/large-lists.spec.ts
@@ -46,7 +46,7 @@ test('process and inventory filters survive navigation with mobile-first cards',
test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
await page.goto('/processes?q=worker&container=pulse&sort=memory');
await expect(page.getByRole('heading', { name: 'Topprocessen' })).toBeVisible();
- await expect(page.getByLabel('Zoeken')).toHaveValue('worker');
+ await expect(page.getByRole('searchbox', { name: 'Zoeken' })).toHaveValue('worker');
await expect(page.getByLabel('Container')).toHaveValue('pulse');
await expect(page).toHaveURL(/sort=memory/);
if (testInfo.project.name === 'mobile-chromium') await expect(page.locator('.mobile-data-list > li:visible')).toHaveCount(25);
@@ -55,7 +55,7 @@ test('process and inventory filters survive navigation with mobile-first cards',
await page.goto('/inventory?q=Entity&type=container&status=operational&order=desc');
await expect(page.getByRole('heading', { name: 'Wat Pulse kan zien' })).toBeVisible();
- await expect(page.getByLabel('Zoeken')).toHaveValue('Entity');
+ await expect(page.getByRole('searchbox', { name: 'Zoeken' })).toHaveValue('Entity');
await expect(page.getByLabel('Type')).toHaveValue('container');
await expect(page.getByRole('textbox', { name: 'Status' })).toHaveValue('operational');
await expect(page.locator('.inventory-entity-list > li')).toHaveCount(25);
@@ -72,7 +72,8 @@ test('all 150 containers remain reachable with shareable filters and bounded mob
await expect(page.getByRole('heading', { name: 'Containers' })).toBeVisible();
const visibleRows = testInfo.project.name === 'mobile-chromium' ? page.locator('.mobile-data-list > li:visible') : page.locator('.desktop-data-view tbody tr:visible');
await expect(visibleRows).toHaveCount(25);
- expect(Date.now() - started).toBeLessThan(3000);
+ const firstPageBudgetMs = process.env.CI ? 8_000 : 3_000;
+ expect(Date.now() - started).toBeLessThan(firstPageBudgetMs);
const seen = new Set();
for (let pageNumber = 1; pageNumber <= 6; pageNumber += 1) {
await expect(visibleRows).toHaveCount(25);
@@ -91,7 +92,7 @@ test('all 150 containers remain reachable with shareable filters and bounded mob
expect(controls.every((height) => height >= 44)).toBe(true);
}
- await page.getByLabel('Zoeken').fill('container-150');
+ await page.getByRole('searchbox', { name: 'Zoeken' }).fill('container-150');
await expect(visibleRows).toHaveCount(1);
await expect(page).toHaveURL(/q=container-150/);
await page.reload();
diff --git a/apps/web/tests/e2e/management-workspace.spec.ts b/apps/web/tests/e2e/management-workspace.spec.ts
index 0c9dcdc..c118ea6 100644
--- a/apps/web/tests/e2e/management-workspace.spec.ts
+++ b/apps/web/tests/e2e/management-workspace.spec.ts
@@ -39,19 +39,28 @@ test('beheerhub, afgeronde onboarding en backupouderdom blijven taakgericht en w
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(onboarding) });
return;
}
+ if (pathname === '/api/v1/host') {
+ await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ hardware: { status: { state: 'healthy' }, capabilities: [], temperatures: [], fans: [], gpus: [] } }) });
+ return;
+ }
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
});
await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'Pulse configureren' })).toBeVisible();
const hub = page.getByRole('region', { name: 'Beheerfuncties' });
- await expect(hub.getByRole('link')).toHaveCount(6);
+ await expect(hub.getByRole('link')).toHaveCount(8);
await expect(hub.getByRole('link', { name: /Systeemstatus en backup/ })).toHaveAttribute('href', '/status');
await expect(hub.getByRole('link', { name: /Eerste configuratie/ })).toHaveAttribute('href', '/onboarding');
await expect(hub.getByRole('link', { name: /Alertregels/ })).toHaveAttribute('href', '/alerts?section=rules');
await expect(hub.getByRole('link', { name: /Stiltes en onderhoud/ })).toHaveAttribute('href', '/alerts?section=controls');
- await expect(hub.getByText('Backupactie: beheerder')).toBeVisible();
+ await expect(hub.getByText('Wijzigen: beheerder')).toBeVisible();
await expect(hub.getByText('Wijzigen: operator')).toBeVisible();
+ const capabilities = page.getByRole('region', { name: 'Capabilitystatus' });
+ await expect(capabilities).toContainText('Unraid');
+ await expect(capabilities).toContainText('HardwaretelemetrieBeschikbaar');
+ await expect(capabilities).toContainText('UPSNiet geconfigureerd');
+ await expect(capabilities.getByRole('link', { name: 'Open systeemstatus en back-up' })).toHaveAttribute('href', '/status');
if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `settings-${testInfo.project.name}.png`), fullPage: true });
}
@@ -70,7 +79,19 @@ test('beheerhub, afgeronde onboarding en backupouderdom blijven taakgericht en w
await page.getByRole('region', { name: 'Beheerfuncties' }).locator('a[href="/onboarding"]').click();
await expect(page.getByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
await expect(page.getByRole('radio')).toHaveCount(0);
- await page.getByRole('button', { name: 'Herconfiguratie openen' }).click();
+ const reconfigure = page.getByRole('button', { name: 'Herconfiguratie openen' });
+ if (testInfo.project.name === 'mobile-chromium') {
+ await reconfigure.scrollIntoViewIfNeeded();
+ expect(await reconfigure.evaluate((button) => {
+ const box = button.getBoundingClientRect();
+ const target = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
+ return target === button || button.contains(target);
+ })).toBe(true);
+ await reconfigure.focus();
+ await page.keyboard.press('Enter');
+ } else {
+ await reconfigure.click();
+ }
await expect(page.getByRole('radio')).toHaveCount(4);
await page.getByRole('button', { name: 'Annuleren' }).click();
await expect(page.getByRole('radio')).toHaveCount(0);
diff --git a/apps/web/tests/e2e/mobile-incident.spec.ts b/apps/web/tests/e2e/mobile-incident.spec.ts
index 6ce185a..753ff1a 100644
--- a/apps/web/tests/e2e/mobile-incident.spec.ts
+++ b/apps/web/tests/e2e/mobile-incident.spec.ts
@@ -5,9 +5,14 @@ const now = new Date().toISOString();
test('mobile incident command mode is prioritized, bounded and accessible', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile incident mode uses the supported 390x844 viewport.');
+ const mutations: Array<{ path: string; method: string; ifMatch: string | null; idempotencyKey: string | null; body: unknown }> = [];
+ let canOperate = true;
await page.route('**/api/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname;
- const body = path === '/api/v1/system/status' ? {
+ if (route.request().method() !== 'GET') mutations.push({ path, method: route.request().method(), ifMatch: route.request().headers()['if-match'] ?? null, idempotencyKey: route.request().headers()['idempotency-key'] ?? null, body: route.request().postDataJSON() });
+ const body = path === '/api/v1/session' ? {
+ subject: canOperate ? 'operator-1' : 'viewer-1', role: canOperate ? 'operator' : 'viewer', environment: 'test', permissions: { view: true, operate: canOperate, edit: false, admin: false },
+ } : path === '/api/v1/system/status' ? {
version: '1', generatedAt: now, overallState: 'degraded', components: [],
backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
} : path === '/api/v1/incidents/incident-1' ? {
@@ -19,6 +24,10 @@ test('mobile incident command mode is prioritized, bounded and accessible', asyn
{ alertId: 'alert-1', rationale: 'Capaciteitsdrempel van 90% overschreden', confidence: .84, correlationMethod: 'temporal-window', manual: false, createdAt: now },
], notes: [{ id: 'note-1', incidentId: 'incident-1', author: 'Pulse', body: 'Mover is niet actief.', createdAt: now }],
},
+ } : path === '/api/v1/alerts/alert-1' ? {
+ alert: { id: 'alert-1', state: 'firing', retainedState: 'firing', ruleName: 'Cachepoolcapaciteit', severity: 'critical', reason: 'Capaciteitsdrempel overschreden', revision: 7, updatedAt: now },
+ } : path === '/api/v1/alerts/alert-1/acknowledge' ? {
+ alert: { id: 'alert-1', state: 'acknowledged', revision: 8 },
} : {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
@@ -28,12 +37,23 @@ test('mobile incident command mode is prioritized, bounded and accessible', asyn
await expect(page.locator('.incident-command-strip')).toContainText('Kritiek');
await expect(page.locator('.incident-command-strip')).toContainText('84%');
await expect(page.locator('.incident-timeline')).toContainText('Capaciteitsdrempel van 90% overschreden');
+ await page.getByRole('button', { name: 'Melding erkennen' }).click();
+ await expect(page.getByRole('status')).toContainText('lost de infrastructuur niet automatisch op');
+ expect(mutations).toHaveLength(1);
+ expect(mutations[0]).toMatchObject({ path: '/api/v1/alerts/alert-1/acknowledge', method: 'POST', ifMatch: '7', body: { evaluationKey: 'incident-incident-1-alert-alert-1-ack-7' } });
+ expect(mutations[0].idempotencyKey).toBe('incident-incident-1-alert-alert-1-ack-7');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
- const targets = await page.locator('button, .mobile-navigation a, .mobile-more summary').evaluateAll((items) => items.filter((item) => {
- const style = getComputedStyle(item); return style.display !== 'none' && style.visibility !== 'hidden';
- }).map((item) => item.getBoundingClientRect().height));
- expect(targets.every((height) => height >= 44)).toBe(true);
+ const undersizedTargets = await page.locator('button, .mobile-navigation a, .mobile-more summary').evaluateAll((items) => items.filter((item) => {
+ const style = getComputedStyle(item); return style.display !== 'none' && style.visibility !== 'hidden' && item.getClientRects().length > 0;
+ }).map((item) => ({ label: item.textContent?.trim(), height: item.getBoundingClientRect().height })).filter((item) => item.height < 44));
+ expect(undersizedTargets).toEqual([]);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
+ canOperate = false;
+ await page.reload();
+ await expect(page.getByRole('button', { name: 'Melding erkennen' })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Eigenaar opslaan' })).toBeDisabled();
+ await expect(page.getByText('Je rol kan de opvolgcontext alleen bekijken.')).toBeVisible();
+ expect(mutations).toHaveLength(1);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: '../../artifacts/evidence/M14-04/mobile-incident-command.png', fullPage: true });
});
diff --git a/apps/web/tests/e2e/responsive-polish.spec.ts b/apps/web/tests/e2e/responsive-polish.spec.ts
index 8456166..b57a810 100644
--- a/apps/web/tests/e2e/responsive-polish.spec.ts
+++ b/apps/web/tests/e2e/responsive-polish.spec.ts
@@ -33,7 +33,9 @@ test('mobile primary navigation and operational reasons remain readable at 390px
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const labels = page.locator('.mobile-primary-list .nav-link span:last-child');
- await expect(labels).toHaveCount(4);
+ await expect(labels).toHaveCount(5);
+ const navigation = await page.locator('.mobile-navigation').boundingBox();
+ expect(navigation?.height).toBeLessThanOrEqual(65);
const metrics = await labels.evaluateAll((nodes) => nodes.map((node) => {
const box = node.getBoundingClientRect();
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, font: Number.parseFloat(getComputedStyle(node).fontSize) };
@@ -45,7 +47,7 @@ test('mobile primary navigation and operational reasons remain readable at 390px
await expect(reason).toHaveCSS('white-space', 'normal');
const overflow = await page.evaluate(() => [...document.querySelectorAll('body *')]
.map((element) => ({ tag: element.tagName, className: element.className, right: Math.round(element.getBoundingClientRect().right), scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }))
- .filter((item) => item.right > innerWidth + 1 || item.scrollWidth > item.clientWidth + 1)
+ .filter((item) => item.clientWidth > 1 && (item.right > innerWidth + 1 || item.scrollWidth > item.clientWidth + 1))
.slice(0, 12));
expect(overflow).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
diff --git a/apps/web/tests/unit/AppOverview.test.tsx b/apps/web/tests/unit/AppOverview.test.tsx
index 5483da1..9cd5659 100644
--- a/apps/web/tests/unit/AppOverview.test.tsx
+++ b/apps/web/tests/unit/AppOverview.test.tsx
@@ -18,6 +18,11 @@ function json(value: unknown, status = 200): Response {
const healthySource = { state: 'healthy', freshness: 'fresh' };
+function signalStage(name: RegExp): HTMLElement {
+ const heading = screen.getByRole('heading', { name: 'Signaalpad' });
+ return within(heading.closest('article')!).getByRole('button', { name });
+}
+
describe('Stitch command overview', () => {
it('shows real source values while failed sources remain explicitly unavailable', async () => {
window.history.replaceState({}, '', '/');
@@ -51,13 +56,13 @@ describe('Stitch command overview', () => {
expect(document.querySelector('.focus-panel')).toBeInTheDocument();
expect(document.querySelector('.context-inspector')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
- const unavailableStorage = screen.getByRole('button', { name: /Opslag: Niet beschikbaar/ });
+ const unavailableStorage = signalStage(/Opslag: Niet beschikbaar/);
expect(unavailableStorage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
- const staleSource = screen.getByRole('button', { name: /Bronnen: Aandacht.*0\/1/ });
+ const staleSource = signalStage(/Bronnen: Aandacht.*0\/1/);
expect(staleSource.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'attention');
expect(screen.getAllByText('62,3%')[0]).toBeVisible();
expect(screen.getByText('tower-lab')).toBeVisible();
- expect(screen.getByRole('button', { name: /Workloads: Kritiek.*1\/2/ })).toBeVisible();
+ expect(signalStage(/Workloads: Kritiek.*1\/2/)).toBeVisible();
expect(screen.queryByText('0 pools')).not.toBeInTheDocument();
const storageCard = screen.getByRole('heading', { name: 'Capaciteit en toestand' }).closest('article');
expect(storageCard).not.toBeNull();
@@ -128,7 +133,7 @@ describe('Stitch command overview', () => {
render( );
- expect(await screen.findByRole('button', { name: /Incidenten: Niet beschikbaar/ })).toBeVisible();
+ await waitFor(() => expect(signalStage(/Incidenten: Niet beschikbaar/)).toBeVisible());
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
expect(incidentCard).not.toBeNull();
expect(within(incidentCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
@@ -150,8 +155,8 @@ describe('Stitch command overview', () => {
render( );
- expect(await screen.findByRole('button', { name: /Bronnen: Geen toegang/ })).toBeVisible();
- expect(await screen.findByRole('button', { name: /Opslag: Geen toegang/ })).toBeVisible();
+ await waitFor(() => expect(signalStage(/Bronnen: Geen toegang/)).toBeVisible());
+ expect(signalStage(/Opslag: Geen toegang/)).toBeVisible();
expect(screen.getByText('Opslag: Geen toegang')).toBeVisible();
expect(screen.getAllByText(/account heeft geen toegang tot deze overzichtsbron/).length).toBeGreaterThan(0);
expect(screen.queryByRole('link', { name: /Aanmelden/ })).not.toBeInTheDocument();
diff --git a/apps/web/tests/unit/DashboardCache.test.tsx b/apps/web/tests/unit/DashboardCache.test.tsx
index 7b3c923..5619788 100644
--- a/apps/web/tests/unit/DashboardCache.test.tsx
+++ b/apps/web/tests/unit/DashboardCache.test.tsx
@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import App from '../../src/App';
+import { DashboardManagerPage } from '../../src/DashboardManagerPage';
afterEach(() => {
vi.unstubAllGlobals();
@@ -21,9 +21,9 @@ describe('dashboard request caching', () => {
return new Response(JSON.stringify({ error: 'not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
}));
- render( );
+ render( );
- expect(await screen.findByRole('heading', { level: 1, name: 'Jouw dashboards' })).toBeVisible();
+ expect(await screen.findByRole('heading', { level: 1, name: 'Dashboardbeheer' })).toBeVisible();
const dashboardRequest = requests.find((request) => request.url.startsWith('/api/v1/dashboards'));
expect(dashboardRequest?.init?.cache).toBe('no-store');
});
diff --git a/apps/web/tests/unit/DashboardEditor.test.tsx b/apps/web/tests/unit/DashboardEditor.test.tsx
new file mode 100644
index 0000000..59df1ec
--- /dev/null
+++ b/apps/web/tests/unit/DashboardEditor.test.tsx
@@ -0,0 +1,71 @@
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { DashboardEditor, validateDashboardLayouts, validateWidget, type EditorWidget } from '../../src/DashboardEditor';
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+function layouts(x: number, y: number, w = 6, h = 4) {
+ return {
+ desktop: { x, y, w, h, visible: true }, tablet: { x: Math.min(x, 2), y, w: Math.min(w, 8), h, visible: true },
+ mobile: { x: 0, y, w: 1, h, visible: true }, wallboard: { x, y, w, h, visible: true },
+ };
+}
+
+function widget(id: string, title: string, x: number, y: number, locked = false): EditorWidget {
+ return { id, type: 'stat', title, data: { sourceType: 'inventory', limit: 10 }, visualization: { decimals: 0, thresholds: [] }, behavior: { locked, hidden: false, liveIntervalSeconds: 30 }, layouts: layouts(x, y) };
+}
+
+const document = { schemaVersion: 2, id: '00000000-0000-4000-8000-000000000001', slug: 'test', name: 'Test', scope: 'personal', variables: [], widgets: [widget('00000000-0000-4000-8000-000000000011', 'CPU', 0, 0), widget('00000000-0000-4000-8000-000000000012', 'Geheugen', 10, 8)], settings: { defaultTimeRange: '1h' } };
+
+describe('DashboardEditor spatial composition', () => {
+ it('keeps viewport layouts independent and saves x/y/w/h, thresholds and an internal link', async () => {
+ let savedBody: Record | undefined;
+ vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ savedBody = JSON.parse(String(init?.body));
+ return new Response(JSON.stringify({ dashboard: { revision: 2 } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }));
+ const onSaved = vi.fn();
+ const user = userEvent.setup();
+ render( undefined} onSaved={onSaved} />);
+
+ await user.click(screen.getByRole('button', { name: 'Widget naar rechts: CPU' }));
+ expect(screen.getByLabelText('Kolom (x) (Desktop)')).toHaveValue(1);
+ fireEvent.change(screen.getByLabelText('Hoogte in rijen'), { target: { value: '6' } });
+ await user.selectOptions(screen.getByLabelText('Layout'), 'tablet');
+ expect(screen.getByLabelText('Kolom (x) (Tablet)')).toHaveValue(0);
+ fireEvent.change(screen.getByLabelText('Kolom (x) (Tablet)'), { target: { value: '2' } });
+ await user.selectOptions(screen.getByLabelText('Layout'), 'desktop');
+ expect(screen.getByLabelText('Kolom (x) (Desktop)')).toHaveValue(1);
+
+ await user.click(screen.getByRole('button', { name: 'Drempel toevoegen' }));
+ const threshold = screen.getByLabelText('Drempels').querySelector('.config-threshold-row')!;
+ fireEvent.change(within(threshold).getByLabelText('Waarde'), { target: { value: '85' } });
+ fireEvent.change(within(threshold).getByLabelText('Kleur'), { target: { value: '#ff8800' } });
+ await user.type(screen.getByLabelText('Interne drilldownlink'), '/incidents');
+ await user.click(screen.getByRole('button', { name: 'Opslaan' }));
+
+ await waitFor(() => expect(onSaved).toHaveBeenCalled());
+ const widgets = savedBody?.widgets as EditorWidget[];
+ expect((widgets[0].layouts?.desktop as Record)).toMatchObject({ x: 1, y: 0, w: 6, h: 6 });
+ expect((widgets[0].layouts?.tablet as Record)).toMatchObject({ x: 2, y: 0, w: 6, h: 4 });
+ expect(widgets[0].visualization?.thresholds).toEqual([{ value: 85, color: '#ff8800', label: '' }]);
+ expect(widgets[0].behavior?.link).toBe('/incidents');
+ }, 15_000);
+
+ it('prevents every spatial control from mutating a locked widget', () => {
+ const lockedDocument = { ...document, widgets: [widget('00000000-0000-4000-8000-000000000021', 'Vergrendeld', 2, 3, true)] };
+ render( undefined} onSaved={() => undefined} />);
+ expect(screen.getByRole('button', { name: 'Widget naar links: Vergrendeld' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Widget omhoog: Vergrendeld' })).toBeDisabled();
+ expect(screen.getByRole('slider', { name: 'Breedte aanpassen: Vergrendeld' })).toBeDisabled();
+ expect(screen.getByLabelText('Kolom (x) (Desktop)')).toBeDisabled();
+ expect(screen.getByLabelText('Hoogte in rijen')).toBeDisabled();
+ });
+
+ it('rejects unsafe links, malformed thresholds and colliding persisted layouts', () => {
+ expect(validateWidget({ ...widget('x', 'Onveilig', 0, 0), visualization: { thresholds: [{ value: 80, color: 'red' }] }, behavior: { link: 'https://example.test', liveIntervalSeconds: 30 } })).toMatchObject({ 'visualization.thresholds': expect.any(String), 'behavior.link': expect.any(String) });
+ expect(validateDashboardLayouts([widget('a', 'A', 0, 0), widget('b', 'B', 0, 0)])).toBe(false);
+ });
+});
diff --git a/apps/web/tests/unit/DashboardManagerPage.test.tsx b/apps/web/tests/unit/DashboardManagerPage.test.tsx
new file mode 100644
index 0000000..26e077f
--- /dev/null
+++ b/apps/web/tests/unit/DashboardManagerPage.test.tsx
@@ -0,0 +1,104 @@
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { DashboardManagerPage } from '../../src/DashboardManagerPage';
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+function json(value: unknown, status = 200): Response {
+ return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' } });
+}
+
+const editorSession = { subject: 'editor-1', role: 'editor', environment: 'test', permissions: { view: true, operate: true, edit: true, admin: false } };
+const viewerSession = { subject: 'viewer-1', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } };
+const dashboard = { id: 'dashboard-1', slug: 'operations', name: 'Operations', description: 'Dagelijkse operatie', ownerId: 'editor-1', scope: 'shared', revision: 1, currentVersion: 2 };
+
+describe('DashboardManagerPage', () => {
+ it('creates a validated dashboard from a selected template', async () => {
+ const requests: Array<{ url: string; init?: RequestInit }> = [];
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input); requests.push({ url, init });
+ if (url === '/api/v1/session') return json(editorSession);
+ if (url.startsWith('/api/v1/dashboards?')) return json({ items: [] });
+ if (url === '/api/v1/dashboards' && init?.method === 'POST') return json({ dashboard: { ...dashboard, id: 'created-1', slug: 'nachtzicht', name: 'Nachtzicht', description: '', revision: 1, currentVersion: 1 } }, 201);
+ return json({ code: 'NOT_FOUND' }, 404);
+ }));
+
+ const user = userEvent.setup();
+ render( );
+ await user.click(await screen.findByRole('button', { name: 'Nieuw dashboard' }));
+ await user.type(screen.getByLabelText('Naam'), 'Nachtzicht');
+ await user.selectOptions(screen.getByLabelText('Template'), 'blank');
+ await user.click(screen.getByRole('button', { name: 'Dashboard maken' }));
+
+ expect(await screen.findByText('Nachtzicht')).toBeVisible();
+ const create = requests.find((request) => request.url === '/api/v1/dashboards' && request.init?.method === 'POST');
+ expect(create).toBeDefined();
+ expect(JSON.parse(String(create?.init?.body))).toMatchObject({ schemaVersion: 2, name: 'Nachtzicht', widgets: [] });
+ });
+
+ it('renames, restores and archives with the current revision', async () => {
+ const requests: Array<{ url: string; init?: RequestInit }> = [];
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input); requests.push({ url, init });
+ if (url === '/api/v1/session') return json(editorSession);
+ if (url.startsWith('/api/v1/dashboards?')) return json({ items: [dashboard] });
+ if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'PATCH') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 2 } });
+ if (url.endsWith('/versions')) return json({ items: [
+ { id: 'version-2', dashboardId: 'dashboard-1', number: 2, schemaVersion: 2, document: {}, changeSummary: 'metadata', createdBy: 'editor-1', createdAt: '2026-09-01T10:00:00Z' },
+ { id: 'version-1', dashboardId: 'dashboard-1', number: 1, schemaVersion: 2, document: {}, changeSummary: 'initial', createdBy: 'editor-1', createdAt: '2026-09-01T09:00:00Z' },
+ ] });
+ if (url.endsWith('/restore/1') && init?.method === 'POST') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 3, currentVersion: 3 } });
+ if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'DELETE') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 4, currentVersion: 3, archivedAt: '2026-09-02T00:00:00Z' } });
+ return json({ code: 'NOT_FOUND' }, 404);
+ }));
+
+ const user = userEvent.setup();
+ render( );
+ let row = (await screen.findByText('Operations')).closest('li');
+ expect(row).not.toBeNull();
+ await user.click(within(row!).getByRole('button', { name: 'Beheren' }));
+ const name = within(row!).getByLabelText('Naam');
+ await user.clear(name); await user.type(name, 'Operations EU');
+ await user.click(within(row!).getByRole('button', { name: 'Naam en beschrijving opslaan' }));
+ row = (await screen.findByText('Operations EU')).closest('li');
+ await user.click(within(row!).getByRole('button', { name: 'Beheren' }));
+ await user.click(within(row!).getByRole('button', { name: 'Versies bekijken' }));
+ await user.click(await within(row!).findByRole('button', { name: 'Herstellen als huidige versie' }));
+ await waitFor(() => expect(requests.some((request) => request.url.endsWith('/restore/1') && new Headers(request.init?.headers).get('If-Match') === '2')).toBe(true));
+ await user.click(within(row!).getByRole('button', { name: 'Archiveren' }));
+ await user.click(within(row!).getByRole('button', { name: 'Ja, archiveren' }));
+ await waitFor(() => expect(requests.some((request) => request.init?.method === 'DELETE' && new Headers(request.init.headers).get('If-Match') === '3')).toBe(true));
+ expect(await screen.findByText('Geen actieve dashboards beschikbaar.')).toBeVisible();
+ });
+
+ it('hides write controls for viewers and explains the boundary', async () => {
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input) === '/api/v1/session' ? json(viewerSession) : json({ items: [dashboard] })));
+ render( );
+ expect(await screen.findByText('Alleen bekijken')).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Nieuw dashboard' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Beheren' })).not.toBeInTheDocument();
+ });
+
+ it('reloads and gives actionable feedback after a revision conflict', async () => {
+ let lists = 0;
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url === '/api/v1/session') return json(editorSession);
+ if (url.startsWith('/api/v1/dashboards?')) { lists += 1; return json({ items: [{ ...dashboard, revision: lists }] }); }
+ if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'PATCH') return json({ code: 'REVISION_CONFLICT', detail: 'conflict' }, 409);
+ return json({}, 404);
+ }));
+ const user = userEvent.setup();
+ render( );
+ const row = (await screen.findByText('Operations')).closest('li')!;
+ await user.click(within(row).getByRole('button', { name: 'Beheren' }));
+ await user.click(within(row).getByRole('button', { name: 'Naam en beschrijving opslaan' }));
+ expect(await screen.findByText(/intussen gewijzigd/)).toBeVisible();
+ await waitFor(() => expect(lists).toBe(2));
+ });
+});
diff --git a/apps/web/tests/unit/DashboardRuntimeWidget.test.tsx b/apps/web/tests/unit/DashboardRuntimeWidget.test.tsx
new file mode 100644
index 0000000..78d1bb6
--- /dev/null
+++ b/apps/web/tests/unit/DashboardRuntimeWidget.test.tsx
@@ -0,0 +1,54 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DashboardRuntimeWidget, type RuntimeWidget } from '../../src/DashboardRuntimeWidget';
+
+afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
+
+const layout = { desktop: { x: 0, y: 0, w: 6, h: 4, visible: true } };
+function widget(type: string, sourceType: string, extra: Record = {}): RuntimeWidget {
+ return { id: type + '-1', type, title: 'Test ' + type, data: { sourceType, limit: 1000, ...extra }, behavior: { liveIntervalSeconds: 1 }, layouts: layout };
+}
+function mount(value: RuntimeWidget) {
+ return render( undefined} onFilter={() => undefined} />);
+}
+
+describe('DashboardRuntimeWidget adapters', () => {
+ it('renders bounded alerts and clamps the URL to one hundred rows', async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.startsWith('/api/v1/alerts')) return new Response(JSON.stringify({ items: [{ id: 'a1', ruleName: 'Disk warm', state: 'firing', severity: 'critical', entityName: 'disk1', reason: 'temperatuur', updatedAt: '2026-09-01T10:00:00Z' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ return new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ mount(widget('alert-summary', 'alerts'));
+ expect(await screen.findByText('Disk warm')).toBeInTheDocument();
+ expect(fetchMock.mock.calls.some(([input]) => String(input) === '/api/v1/alerts?limit=100')).toBe(true);
+ expect(screen.getAllByText('1', { selector: 'dd' })).toHaveLength(2);
+ });
+
+ it('renders incident rows and safe text without interpreting HTML', async () => {
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input).startsWith('/api/v1/incidents')
+ ? new Response(JSON.stringify({ items: [{ id: 'i1', title: 'Opslagincident', status: 'open', severity: 'warning', summary: 'Controle nodig', updatedAt: '2026-09-01T10:00:00Z' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } })
+ : new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+ const view = mount(widget('table', 'incidents'));
+ expect(await screen.findByText('Opslagincident')).toBeInTheDocument();
+ view.unmount();
+ mount(widget('text', 'text', { text: '# Runbook\n[Open incident](/incidents)\n' }));
+ expect(screen.getByRole('link', { name: 'Open incident' })).toHaveAttribute('href', '/incidents');
+ expect(screen.queryByRole('script')).not.toBeInTheDocument();
+ expect(screen.getByText('')).toBeInTheDocument();
+ });
+
+ it('contains an unknown type locally and clears its bounded polling timer', async () => {
+ vi.useFakeTimers();
+ const clear = vi.spyOn(window, 'clearInterval');
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input).startsWith('/api/v1/events')
+ ? new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })
+ : new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+ const view = mount(widget('made-up', 'events'));
+ expect(screen.getByText('Onbekend widgettype')).toBeInTheDocument();
+ expect(screen.getByText('made-up')).toBeInTheDocument();
+ view.unmount();
+ expect(clear).toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/tests/unit/ExploreMetricsPage.test.tsx b/apps/web/tests/unit/ExploreMetricsPage.test.tsx
new file mode 100644
index 0000000..33feb12
--- /dev/null
+++ b/apps/web/tests/unit/ExploreMetricsPage.test.tsx
@@ -0,0 +1,49 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ExploreMetricsPage } from '../../src/ExploreMetricsPage';
+import { resetOperatorContextForTests, setOperatorCustomRange } from '../../src/operatorContext';
+
+const metric = (name: string, maxRangeSeconds = 2_592_000) => ({ semanticName: name, description: 'Goedgekeurde metric voor operators', unit: 'percent', valueKind: 'gauge', defaultAggregation: 'avg', allowedVisualizations: ['timeseries'], limits: { maxRangeSeconds, maxSeries: 20, maxPoints: 4000, timeoutSeconds: 5 } });
+const catalog = { version: 'v1', metrics: [metric('host.cpu.utilization'), metric('host.memory.utilization', 3600), metric('storage.pool.utilization'), metric('service.availability'), metric('service.response_time')], bindings: ['host.cpu.utilization', 'host.memory.utilization', 'storage.pool.utilization', 'service.availability', 'service.response_time'].map((semanticName) => ({ semanticName, state: 'supported' })) };
+const response = { status: 'success', data: { result: [{ metric: { host: 'tower' }, values: [[1_777_000_000, '42'], [1_777_000_060, '43']] }] }, provenance: { source: 'fixture', metric: 'test', catalogVersion: 'v1', cacheKey: 'safe' }, sourceObservedAt: '2026-09-01T10:00:00Z', receivedAt: '2026-09-01T10:00:01Z', freshness: 'fresh', cacheHit: false };
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); resetOperatorContextForTests(); window.localStorage.clear(); window.history.replaceState({}, '', '/explore'); });
+
+function mockAPI() {
+ const calls: Array> = [];
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith('/api/v1/metrics/catalog')) return new Response(JSON.stringify(catalog), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ if (url.includes('/api/v1/incidents') || url.includes('/api/v1/alerts')) return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ if (url.endsWith('/api/v1/metrics/query-range')) { calls.push(JSON.parse(String(init?.body))); return new Response(JSON.stringify(response), { status: 200, headers: { 'Content-Type': 'application/json' } }); }
+ return new Response('', { status: 404 });
+ }));
+ return calls;
+}
+
+describe('ExploreMetricsPage', () => {
+ it('finds and opens a semantic metric without exposing PromQL', async () => {
+ const calls = mockAPI(); const user = userEvent.setup();
+ render( );
+ await user.type(await screen.findByPlaceholderText('CPU, opslag, responstijd…'), 'cpu');
+ await user.click(screen.getByRole('button', { name: /host.cpu.utilization/ }));
+ await waitFor(() => expect(calls).toHaveLength(1));
+ expect(calls[0]).toMatchObject({ metric: 'host.cpu.utilization', maxSeries: 20, maxPoints: 4000 });
+ expect(document.body).not.toHaveTextContent('rate(');
+ });
+
+ it('caps compare at four metrics and blocks an over-budget range locally', async () => {
+ const calls = mockAPI(); const user = userEvent.setup();
+ render( );
+ await user.click(await screen.findByRole('tab', { name: 'Vergelijken' }));
+ for (const name of ['host.cpu.utilization', 'storage.pool.utilization', 'service.availability', 'service.response_time']) await user.click(screen.getByRole('button', { name: new RegExp(name.replaceAll('.', '\\.')) }));
+ expect(screen.getByText('Maximaal vier metrics kunnen tegelijk worden vergeleken.')).toBeVisible();
+ expect(screen.getByRole('button', { name: /host.memory.utilization/ })).not.toHaveAttribute('aria-pressed', 'true');
+ await user.click(screen.getByRole('button', { name: /host.cpu.utilization/ }));
+ setOperatorCustomRange('2026-09-01T00:00', '2026-09-02T00:00');
+ await user.click(screen.getByRole('button', { name: /host.memory.utilization/ }));
+ await waitFor(() => expect(screen.getByText('Serverbudget overschreden')).toBeVisible());
+ expect(calls.some((call) => call.metric === 'host.memory.utilization')).toBe(false);
+ });
+});
diff --git a/apps/web/tests/unit/MetricWidgets.test.tsx b/apps/web/tests/unit/MetricWidgets.test.tsx
index 933dc86..4018cb2 100644
--- a/apps/web/tests/unit/MetricWidgets.test.tsx
+++ b/apps/web/tests/unit/MetricWidgets.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
@@ -64,6 +64,22 @@ describe('interactive widgets', () => {
expect(document.body).not.toHaveTextContent(rawKey);
});
+ it('supports series toggles, bounded zoom, a pointer crosshair and annotations', async () => {
+ const user = userEvent.setup();
+ const view = render( );
+ const widget = within(view.container);
+ const backup = widget.getByRole('button', { name: 'backup' });
+ expect(backup).toHaveAttribute('aria-pressed', 'true');
+ await user.click(backup);
+ expect(backup).toHaveAttribute('aria-pressed', 'false');
+ await user.click(widget.getByRole('button', { name: 'Zoom op recente helft' }));
+ expect(widget.getByRole('button', { name: 'Volledige periode' })).toHaveAttribute('aria-pressed', 'true');
+ expect(widget.getByRole('list', { name: 'Incident- en meldingsannotaties' })).toHaveTextContent('Opslagincident');
+ });
+
it('supports keyboard activation of ranked rows', async () => {
const onSelect = vi.fn();
render( );
diff --git a/apps/web/tests/unit/RouteErrorBoundary.test.tsx b/apps/web/tests/unit/RouteErrorBoundary.test.tsx
new file mode 100644
index 0000000..671fa36
--- /dev/null
+++ b/apps/web/tests/unit/RouteErrorBoundary.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { ApiProblemError } from '../../src/apiClient';
+import { RouteErrorBoundary } from '../../src/RouteErrorBoundary';
+
+function BrokenRoute(): never { throw new ApiProblemError(500, { title: 'Fout' }, 'corr-route-7'); }
+
+describe('RouteErrorBoundary', () => {
+ it('keeps the shell alive and exposes correlation context', () => {
+ render(<>Navigatie >);
+ expect(screen.getByRole('navigation', { name: 'Applicatieshell' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Deze pagina kon niet worden weergegeven' })).toBeInTheDocument();
+ expect(screen.getByText(/corr-route-7/)).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/apiClient.test.ts b/apps/web/tests/unit/apiClient.test.ts
new file mode 100644
index 0000000..984093f
--- /dev/null
+++ b/apps/web/tests/unit/apiClient.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ApiProblemError, requestJSON } from '../../src/apiClient';
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('requestJSON', () => {
+ it('preserves safe problem and correlation context', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ title: 'Begrensd', detail: 'Probeer een kortere periode.' }), { status: 422, headers: { 'content-type': 'application/problem+json', 'x-correlation-id': 'corr-123' } })));
+ await expect(requestJSON('/api/v1/example')).rejects.toMatchObject({ status: 422, correlationId: 'corr-123', message: 'Probeer een kortere periode.' } satisfies Partial);
+ });
+
+ it('retries safe GET requests only within the configured bound', async () => {
+ const fetcher = vi.fn().mockResolvedValueOnce(new Response('{}', { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }));
+ vi.stubGlobal('fetch', fetcher);
+ await expect(requestJSON<{ ok: boolean }>('/api/v1/example', { retries: 1 })).resolves.toEqual({ ok: true });
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ });
+
+ it('never retries mutations', async () => {
+ const fetcher = vi.fn().mockResolvedValue(new Response('{}', { status: 503, headers: { 'content-type': 'application/problem+json' } }));
+ vi.stubGlobal('fetch', fetcher);
+ await expect(requestJSON('/api/v1/example', { method: 'POST', retries: 2 })).rejects.toBeInstanceOf(ApiProblemError);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([401, 403])('preserves authorization status %s without retry', async (status) => {
+ const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ title: 'Geen toegang', correlationId: 'corr-auth' }), { status, headers: { 'content-type': 'application/problem+json' } }));
+ vi.stubGlobal('fetch', fetcher);
+ await expect(requestJSON('/api/v1/protected', { retries: 2 })).rejects.toMatchObject({ status, correlationId: 'corr-auth' });
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('never retries an aborted request', async () => {
+ const fetcher = vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'));
+ vi.stubGlobal('fetch', fetcher);
+ await expect(requestJSON('/api/v1/example', { retries: 2 })).rejects.toMatchObject({ name: 'AbortError' });
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/tests/unit/dashboardSpatial.test.ts b/apps/web/tests/unit/dashboardSpatial.test.ts
new file mode 100644
index 0000000..f01d301
--- /dev/null
+++ b/apps/web/tests/unit/dashboardSpatial.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { movePlacement, normalizePlacement, overlaps, resizePlacement } from '../../src/dashboardSpatial';
+
+describe('dashboard spatial layout', () => {
+ it('clamps placements to the active column budget', () => {
+ expect(normalizePlacement({ x: 17, y: -3, w: 6, h: 0 }, 'desktop')).toEqual({ x: 12, y: 0, w: 6, h: 1, visible: true });
+ expect(normalizePlacement({ x: 4, y: 3, w: 4, h: 5 }, 'mobile')).toEqual({ x: 0, y: 3, w: 1, h: 5, visible: true });
+ });
+
+ it('detects overlap and pushes a moved widget below collisions', () => {
+ const occupied = [{ id: 'a', placement: { x: 0, y: 0, w: 6, h: 4, visible: true } }];
+ expect(overlaps({ x: 0, y: 0, w: 2, h: 2 }, occupied[0].placement)).toBe(true);
+ expect(movePlacement({ x: 6, y: 0, w: 6, h: 4 }, -6, 0, occupied, 'desktop')).toMatchObject({ x: 0, y: 4, w: 6, h: 4 });
+ });
+
+ it('keeps resize within the viewport and resolves collisions', () => {
+ const occupied = [{ id: 'a', placement: { x: 8, y: 0, w: 4, h: 4, visible: true } }];
+ const resized = resizePlacement({ x: 0, y: 0, w: 6, h: 4 }, 6, 2, occupied, 'desktop');
+ expect(resized.w).toBe(12);
+ expect(resized.y).toBe(4);
+ expect(resized.h).toBe(6);
+ });
+});
diff --git a/apps/web/tests/unit/dashboardTemplates.test.ts b/apps/web/tests/unit/dashboardTemplates.test.ts
new file mode 100644
index 0000000..125f1a7
--- /dev/null
+++ b/apps/web/tests/unit/dashboardTemplates.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+import { buildDashboardDocument, dashboardSlug } from '../../src/dashboardTemplates';
+
+describe('dashboard templates', () => {
+ it('creates a schema v2 blank document with required fields', () => {
+ let index = 0;
+ const document = buildDashboardDocument({ name: 'Mijn Test', scope: 'personal', template: 'blank', idFactory: () => `00000000-0000-4000-8000-${String(++index).padStart(12, '0')}` });
+ expect(document.schemaVersion).toBe(2); expect(document.scope).toBe('personal'); expect(document.widgets).toEqual([]); expect(document.slug).toMatch(/^mijn-test-/);
+ });
+ it('only uses proven runtime sources in the operations template', () => {
+ let index = 0;
+ const document = buildDashboardDocument({ name: 'Operations', scope: 'shared', template: 'operations', idFactory: () => `10000000-0000-4000-8000-${String(++index).padStart(12, '0')}` });
+ const widgets = document.widgets as Array<{ data?: { sourceType?: string } }>;
+ expect(widgets.length).toBeGreaterThan(0); expect(new Set(widgets.map((widget) => widget.data?.sourceType))).toEqual(new Set(['inventory', 'semantic-metric', 'events']));
+ });
+ it('normalizes a unicode name to a valid bounded slug', () => { const slug = dashboardSlug('Opslag & Café / Test', 'ABC123'); expect(slug).toBe('opslag-cafe-test-abc123'); expect(slug.length).toBeLessThanOrEqual(80); });
+});
diff --git a/apps/web/tests/unit/historicalSamples.test.ts b/apps/web/tests/unit/historicalSamples.test.ts
new file mode 100644
index 0000000..7524776
--- /dev/null
+++ b/apps/web/tests/unit/historicalSamples.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import { historicalSamplesFromData } from '../../src/liveBuffer';
+
+describe('historical metric boundary', () => {
+ it('caps oversized upstream payloads before they enter chart state', () => {
+ const values = Array.from({ length: 12_000 }, (_, index) => [1_700_000_000 + index, String(index)]);
+ const result = historicalSamplesFromData({ result: Array.from({ length: 120 }, (_, index) => ({ metric: { host: `host-${index}` }, values })) });
+ expect(result).toHaveLength(10_000);
+ expect(new Set(result.map((sample) => sample.series)).size).toBe(1);
+ expect(result[0].value).toBe(2000);
+ });
+});
diff --git a/apps/web/tests/unit/metricClient.test.ts b/apps/web/tests/unit/metricClient.test.ts
new file mode 100644
index 0000000..a53d84b
--- /dev/null
+++ b/apps/web/tests/unit/metricClient.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { rangeForPreset, rangeForSelection } from '../../src/metricClient';
+
+describe('metric range selection', () => {
+ const now = new Date('2026-08-30T18:00:00.000Z');
+
+ it('supports the 30 day operator preset within the existing point budget', () => {
+ const range = rangeForPreset('30d', now);
+ expect(range.to).toBe(now.toISOString());
+ expect(Date.parse(range.to) - Date.parse(range.from)).toBe(30 * 24 * 60 * 60 * 1000);
+ expect(range.stepSeconds).toBeGreaterThanOrEqual(15);
+ });
+
+ it('turns a valid custom selection into an exact UTC range', () => {
+ const range = rangeForSelection('custom|2026-08-01T10:00:00.000Z|2026-08-02T10:00:00.000Z', now);
+ expect(range.from).toBe('2026-08-01T10:00:00.000Z');
+ expect(range.to).toBe('2026-08-02T10:00:00.000Z');
+ });
+
+ it('fails closed to one hour when a custom range is invalid', () => {
+ expect(rangeForSelection('custom|broken|2026-08-02T10:00:00.000Z', now)).toEqual(rangeForPreset('1h', now));
+ });
+
+ it('uses bounded recent history while live transport is paused', () => {
+ expect(rangeForSelection('paused', now)).toEqual(rangeForPreset('live', now));
+ });
+});
diff --git a/apps/web/tests/unit/operatorContext.test.ts b/apps/web/tests/unit/operatorContext.test.ts
new file mode 100644
index 0000000..0bcb4ab
--- /dev/null
+++ b/apps/web/tests/unit/operatorContext.test.ts
@@ -0,0 +1,40 @@
+import { afterEach, describe, expect, it } from 'vitest';
+
+import {
+ operatorCustomRangeValid,
+ operatorRangeSelection,
+ resetOperatorContextForTests,
+ setOperatorCustomRange,
+ setOperatorPaused,
+ setOperatorRangePreset,
+} from '../../src/operatorContext';
+
+afterEach(() => {
+ resetOperatorContextForTests();
+ window.localStorage.clear();
+ window.history.replaceState({}, '', '/');
+});
+
+describe('global operator context', () => {
+ it('encodes a valid custom range in the shareable URL', () => {
+ setOperatorCustomRange('2026-09-01T10:00', '2026-09-01T11:00');
+ const params = new URLSearchParams(window.location.search);
+ expect(params.get('range')).toBe('custom');
+ expect(params.get('from')).toBe(new Date('2026-09-01T10:00').toISOString());
+ expect(params.get('to')).toBe(new Date('2026-09-01T11:00').toISOString());
+ });
+
+ it('permits pause only for the live range', () => {
+ setOperatorRangePreset('live');
+ setOperatorPaused(true);
+ expect(window.localStorage.getItem('pulse.operator.paused')).toBe('true');
+ setOperatorRangePreset('1h');
+ expect(window.localStorage.getItem('pulse.operator.paused')).toBe('false');
+ });
+
+ it('fails closed to one hour when a custom range is invalid', () => {
+ const value = { range: 'custom' as const, customFrom: '2026-09-01T11:00', customTo: '2026-09-01T10:00', paused: false, refreshVersion: 0 };
+ expect(operatorCustomRangeValid(value)).toBe(false);
+ expect(operatorRangeSelection(value)).toBe('1h');
+ });
+});
diff --git a/apps/web/tests/unit/operatorWorkspace.test.ts b/apps/web/tests/unit/operatorWorkspace.test.ts
new file mode 100644
index 0000000..a98e0b8
--- /dev/null
+++ b/apps/web/tests/unit/operatorWorkspace.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ isFavorite,
+ readWorkspaceState,
+ recordRecent,
+ removeSavedView,
+ saveCurrentView,
+ toggleFavorite,
+ WORKSPACE_EVENT,
+} from '../../src/operatorWorkspace';
+
+afterEach(() => {
+ window.localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+describe('operator workspace preferences', () => {
+ it('toggles favorites without duplicating the same path', () => {
+ expect(toggleFavorite('Cache pool', '/pools/cache?range=24h')).toBe(true);
+ expect(isFavorite('/pools/cache?range=24h')).toBe(true);
+ expect(readWorkspaceState().favorites).toHaveLength(1);
+
+ expect(toggleFavorite('Cache pool', '/pools/cache?range=24h')).toBe(false);
+ expect(isFavorite('/pools/cache?range=24h')).toBe(false);
+ expect(readWorkspaceState().favorites).toHaveLength(0);
+ });
+
+ it('persists a saved view and removes it by exact route context', () => {
+ saveCurrentView('Degraded services', '/services?state=degraded&range=24h');
+ expect(readWorkspaceState().views).toEqual([
+ expect.objectContaining({ label: 'Degraded services', path: '/services?state=degraded&range=24h', kind: 'view' }),
+ ]);
+
+ removeSavedView('/services?state=degraded&range=24h');
+ expect(readWorkspaceState().views).toHaveLength(0);
+ });
+
+ it('keeps recent routes bounded, unique and excludes the 404 surface', () => {
+ recordRecent('Missing', '/404');
+ for (let index = 0; index < 15; index += 1) recordRecent('Item ' + index, '/inventory/' + index);
+ recordRecent('Item 14 updated', '/inventory/14');
+
+ const recent = readWorkspaceState().recent;
+ expect(recent).toHaveLength(12);
+ expect(recent[0]).toMatchObject({ label: 'Item 14 updated', path: '/inventory/14', kind: 'recent' });
+ expect(recent.filter((item) => item.path === '/inventory/14')).toHaveLength(1);
+ expect(recent.some((item) => item.path === '/404')).toBe(false);
+ });
+
+ it('fails closed on malformed storage and emits a workspace change event on writes', () => {
+ window.localStorage.setItem('pulse.operator.workspace.v1', '{bad json');
+ expect(readWorkspaceState()).toEqual({ favorites: [], views: [], recent: [] });
+
+ const listener = vi.fn();
+ window.addEventListener(WORKSPACE_EVENT, listener);
+ saveCurrentView('Overview', '/');
+ window.removeEventListener(WORKSPACE_EVENT, listener);
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/tests/unit/sessionClient.test.ts b/apps/web/tests/unit/sessionClient.test.ts
new file mode 100644
index 0000000..06bc910
--- /dev/null
+++ b/apps/web/tests/unit/sessionClient.test.ts
@@ -0,0 +1,38 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { readSessionCapabilities } from '../../src/sessionClient';
+
+afterEach(() => vi.unstubAllGlobals());
+
+function json(value: unknown, status = 200): Response {
+ return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
+}
+
+describe('session capability client', () => {
+ it('reads bounded identity, environment and effective permissions', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => json({
+ subject: 'operator-42', role: 'editor', environment: 'development',
+ permissions: { view: true, operate: true, edit: true, admin: false },
+ })));
+
+ await expect(readSessionCapabilities()).resolves.toEqual({
+ subject: 'operator-42', role: 'editor', environment: 'development',
+ permissions: { view: true, operate: true, edit: true, admin: false }, source: 'session',
+ });
+ });
+
+ it('falls back to the legacy capability probe when the endpoint is absent', async () => {
+ const fetch = vi.fn(async () => fetch.mock.calls.length === 1 ? json({}, 404) : json({}, 405));
+ vi.stubGlobal('fetch', fetch);
+
+ const result = await readSessionCapabilities();
+
+ expect(result).toMatchObject({ role: 'editor+', environment: '', permissions: { view: true, operate: true, edit: true, admin: false }, source: 'fallback' });
+ expect(fetch).toHaveBeenNthCalledWith(2, '/api/v1/widgets/preview', expect.objectContaining({ method: 'GET', cache: 'no-store' }));
+ });
+
+ it('preserves an explicit authentication failure', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => json({}, 401)));
+ await expect(readSessionCapabilities()).rejects.toMatchObject({ status: 401 });
+ });
+});
diff --git a/apps/web/tests/unit/widgetCatalog.test.ts b/apps/web/tests/unit/widgetCatalog.test.ts
new file mode 100644
index 0000000..1ea63c6
--- /dev/null
+++ b/apps/web/tests/unit/widgetCatalog.test.ts
@@ -0,0 +1,25 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fallbackWidgetCatalog, isWidgetType, readWidgetCatalog, widgetLabel, widgetTypes } from '../../src/widgetCatalog';
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('widget catalog client', () => {
+ it('keeps one bounded fourteen-type catalog including query inspector', () => {
+ expect(widgetTypes).toHaveLength(14);
+ expect(new Set(widgetTypes).size).toBe(14);
+ expect(fallbackWidgetCatalog).toHaveLength(14);
+ expect(widgetLabel('query-inspector')).toBe('Query-inspecteur');
+ expect(isWidgetType('made-up')).toBe(false);
+ });
+
+ it('normalizes the existing Go catalog shape and ignores unknown entries', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ items: [
+ { Type: 'stat', Capabilities: ['metrics'] }, { Type: 'made-up' }, { type: 'text', capabilities: ['safe-markdown'] },
+ ] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
+ const catalog = await readWidgetCatalog();
+ expect(catalog).toHaveLength(14);
+ expect(catalog.find((item) => item.type === 'stat')?.capabilities).toEqual(['metrics']);
+ expect(catalog.find((item) => item.type === 'text')?.capabilities).toEqual(['safe-markdown']);
+ expect(catalog.some((item) => item.type === ('made-up' as never))).toBe(false);
+ });
+});
diff --git a/cmd/api/main.go b/cmd/api/main.go
index ea428ad..3c14705 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -68,6 +68,7 @@ import (
"github.com/itworx/pulse/internal/runtimeconfig"
"github.com/itworx/pulse/internal/service"
"github.com/itworx/pulse/internal/serviceapi"
+ "github.com/itworx/pulse/internal/sessionapi"
sharedomain "github.com/itworx/pulse/internal/share"
"github.com/itworx/pulse/internal/shareapi"
"github.com/itworx/pulse/internal/systemstatus"
@@ -381,6 +382,7 @@ func run(logger *slog.Logger) error {
protectedDiagnostics := withSession(sessions, auth.Require(auth.PermissionOperate, statusHandler))
mux.Handle("/api/v1/system/status", protectedStatus)
mux.Handle("/api/v1/system/diagnostics", protectedDiagnostics)
+ mux.Handle("/api/v1/session", withSession(sessions, auth.Require(auth.PermissionView, sessionapi.Handler{Environment: string(application.Environment)})))
metricsExposition := internalMetrics.Handler()
mux.Handle("/api/v1/system/metrics", withSession(sessions, auth.Require(auth.PermissionOperate, http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
publishSourceMetrics()
diff --git a/docs/architecture/API_CONTRACT.md b/docs/architecture/API_CONTRACT.md
index 70f6d8b..4ce30f0 100644
--- a/docs/architecture/API_CONTRACT.md
+++ b/docs/architecture/API_CONTRACT.md
@@ -44,6 +44,7 @@ No stack trace or secret-bearing upstream response.
| `GET /auth/callback` | public-oidc | `internal/authapi/handler.go` |
| `GET /auth/test-login` | development-only | `cmd/api/main.go` |
| `POST /session/logout` | session | `cmd/api/main.go` |
+| `GET /api/v1/session` | view | `internal/sessionapi/handler.go` |
| `GET /api/v1/system/status` | view | `internal/systemstatusapi/handler.go` |
| `GET /api/v1/system/diagnostics` | operate | `internal/systemstatusapi/handler.go` |
| `GET /api/v1/system/metrics` | operate | `internal/observability/metrics.go` |
@@ -127,7 +128,7 @@ No stack trace or secret-bearing upstream response.
### Session/user
-OIDC login and callback are the only public authentication routes. The browser session is server-side and intentionally has no introspection or preference endpoint in v1. Logout clears the opaque session cookie. Sessions use a bounded sliding idle lifetime: a successful authenticated request renews the HttpOnly cookie after half of the idle TTL has elapsed, while a separate absolute TTL always requires a new OIDC login. The defaults are eight hours idle and seven days absolute; production rejects an absolute TTL below 24 hours because an authenticated wallboard is a supported 24-hour workload. The mock test-login route returns `404` unless both the environment is non-production and mock authentication is configured.
+OIDC login and callback are the only public authentication routes. The browser session is server-side. `GET /api/v1/session` exposes only the authenticated subject, effective role, derived permission booleans and configured environment so the operator shell can present current context; it never exposes tokens, raw claims, cookie material or preference mutations. Logout clears the opaque session cookie. Sessions use a bounded sliding idle lifetime: a successful authenticated request renews the HttpOnly cookie after half of the idle TTL has elapsed, while a separate absolute TTL always requires a new OIDC login. The defaults are eight hours idle and seven days absolute; production rejects an absolute TTL below 24 hours because an authenticated wallboard is a supported 24-hour workload. The mock test-login route returns `404` unless both the environment is non-production and mock authentication is configured.
### Health
diff --git a/docs/screenshots/overview-demo.png b/docs/screenshots/overview-demo.png
new file mode 100644
index 0000000..1e5c72c
Binary files /dev/null and b/docs/screenshots/overview-demo.png differ
diff --git a/internal/sessionapi/handler.go b/internal/sessionapi/handler.go
new file mode 100644
index 0000000..84d0cab
--- /dev/null
+++ b/internal/sessionapi/handler.go
@@ -0,0 +1,53 @@
+package sessionapi
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/itworx/pulse/internal/auth"
+ "github.com/itworx/pulse/internal/problem"
+)
+
+type Permissions struct {
+ View bool `json:"view"`
+ Operate bool `json:"operate"`
+ Edit bool `json:"edit"`
+ Admin bool `json:"admin"`
+}
+
+type Response struct {
+ Subject string `json:"subject"`
+ Role auth.Role `json:"role"`
+ Environment string `json:"environment"`
+ Permissions Permissions `json:"permissions"`
+}
+
+type Handler struct {
+ Environment string
+}
+
+func (handler Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
+ response.Header().Set("Cache-Control", "private, no-store")
+ if request.Method != http.MethodGet {
+ problem.Write(response, request, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed", "Only GET is supported for the session capability view.", nil)
+ return
+ }
+ principal, ok := auth.PrincipalFromContext(request.Context())
+ if !ok {
+ problem.Write(response, request, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required", "An authenticated Pulse session is required.", nil)
+ return
+ }
+ payload := Response{
+ Subject: principal.Subject,
+ Role: principal.Role,
+ Environment: handler.Environment,
+ Permissions: Permissions{
+ View: auth.Allows(principal.Role, auth.PermissionView),
+ Operate: auth.Allows(principal.Role, auth.PermissionOperate),
+ Edit: auth.Allows(principal.Role, auth.PermissionEdit),
+ Admin: auth.Allows(principal.Role, auth.PermissionAdmin),
+ },
+ }
+ response.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(response).Encode(payload)
+}
diff --git a/internal/sessionapi/handler_test.go b/internal/sessionapi/handler_test.go
new file mode 100644
index 0000000..2ba5d82
--- /dev/null
+++ b/internal/sessionapi/handler_test.go
@@ -0,0 +1,62 @@
+package sessionapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/itworx/pulse/internal/auth"
+)
+
+func TestHandlerReturnsCurrentCapabilities(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/session", nil)
+ request = request.WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "operator-42", Role: auth.RoleEditor}))
+ response := httptest.NewRecorder()
+
+ Handler{Environment: "development"}.ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
+ }
+ if got := response.Header().Get("Cache-Control"); got != "private, no-store" {
+ t.Fatalf("Cache-Control = %q", got)
+ }
+ var payload Response
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload.Subject != "operator-42" || payload.Role != auth.RoleEditor {
+ t.Fatalf("unexpected identity: %#v", payload)
+ }
+ if payload.Environment != "development" {
+ t.Fatalf("environment = %q, want development", payload.Environment)
+ }
+ if !payload.Permissions.View || !payload.Permissions.Operate || !payload.Permissions.Edit || payload.Permissions.Admin {
+ t.Fatalf("unexpected permissions: %#v", payload.Permissions)
+ }
+}
+
+func TestHandlerFailsClosedWithoutPrincipal(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/session", nil)
+ response := httptest.NewRecorder()
+
+ Handler{}.ServeHTTP(response, request)
+
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized)
+ }
+}
+
+func TestHandlerRejectsMutationMethods(t *testing.T) {
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/session", nil)
+ request = request.WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "admin-1", Role: auth.RoleAdministrator}))
+ response := httptest.NewRecorder()
+
+ Handler{}.ServeHTTP(response, request)
+
+ if response.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("status = %d, want %d", response.Code, http.StatusMethodNotAllowed)
+ }
+}
diff --git a/package.json b/package.json
index dc7d8f9..8810816 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "itworx-pulse",
"private": true,
- "version": "1.5.0",
+ "version": "1.6.0",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=20.19.0",
diff --git a/public-source.allowlist b/public-source.allowlist
index b2761fc..67ecf71 100644
--- a/public-source.allowlist
+++ b/public-source.allowlist
@@ -35,6 +35,7 @@ deploy/web.Dockerfile
deploy/worker.Dockerfile
docs/PUBLIC_DEPLOYMENT.md
docs/PUBLIC_SOURCE_BOUNDARY.md
+docs/screenshots/overview-demo.png
docs/REPOSITORY_BOUNDARY.md
docs/architecture/adr/0001-read-only-v1.md
docs/architecture/adr/0002-go-react-stack.md
diff --git a/scripts/integration-smoke.ps1 b/scripts/integration-smoke.ps1
index 7b0a039..bb4a753 100644
--- a/scripts/integration-smoke.ps1
+++ b/scripts/integration-smoke.ps1
@@ -9,8 +9,10 @@ function Get-FreeLoopbackPort {
}
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw 'Docker is required for the isolated integration smoke.' }
-$goBin = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin'
-if (-not (Get-Command go -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $goBin 'go.exe'))) { $env:PATH = $goBin + ';' + $env:PATH }
+if (-not (Get-Command go -ErrorAction SilentlyContinue) -and $env:LOCALAPPDATA) {
+ $goBin = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin'
+ if (Test-Path (Join-Path $goBin 'go.exe')) { $env:PATH = $goBin + [IO.Path]::PathSeparator + $env:PATH }
+}
$project = 'itworx-pulse-smoke-' + $PID
$webPort = Get-FreeLoopbackPort
@@ -19,6 +21,8 @@ $dbPort = Get-FreeLoopbackPort
$baseURL = "http://127.0.0.1:$webPort"
$compose = @('-p', $project, '-f', 'deploy/compose.yaml', '-f', 'deploy/compose.dev.yaml', '-f', 'deploy/compose.smoke.yaml')
$succeeded = $false
+$runnerContainer = [System.Net.Dns]::GetHostName()
+$runnerNetwork = $null
$env:PULSE_POSTGRES_PASSWORD = 'smoke-process-only'
$env:PULSE_DATABASE_URL = 'postgres://pulse:smoke-process-only@pulse-postgres:5432/pulse?sslmode=disable'
@@ -101,6 +105,31 @@ SET observed_at=EXCLUDED.observed_at,received_at=EXCLUDED.received_at,payload=EX
'@ | docker compose @compose exec -T pulse-postgres psql -v ON_ERROR_STOP=1 -U pulse -d pulse | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'failed to seed bounded container agent snapshot' }
docker compose @compose up -d --wait --wait-timeout 240
+ $runnerIsContainer = $false
+ try {
+ docker inspect $runnerContainer *> $null
+ $runnerIsContainer = $LASTEXITCODE -eq 0
+ } catch {
+ # A native workstation runner is expected not to be a Docker object.
+ }
+ if ($runnerIsContainer) {
+ $runnerNetwork = "${project}_pulse-edge"
+ docker network connect $runnerNetwork $runnerContainer
+ $baseURL = 'http://pulse-web:8080'
+ }
+ # Container health can become green just before the runner can reach a newly
+ # published port. Gate the browser-facing path itself to avoid that race.
+ $webReady = $false
+ for ($attempt = 0; $attempt -lt 30; $attempt++) {
+ try {
+ $health = Invoke-WebRequest -Uri "$baseURL/healthz" -TimeoutSec 2
+ if ($health.StatusCode -eq 200 -and $health.Content.Trim() -eq 'ok') { $webReady = $true; break }
+ } catch {
+ # Retry only within this bounded readiness window.
+ }
+ Start-Sleep -Seconds 1
+ }
+ if (-not $webReady) { throw "runner-reachable web endpoint did not become ready: $baseURL" }
$inventoryReady = $false
for ($attempt = 0; $attempt -lt 30; $attempt++) {
Start-Sleep -Seconds 2
@@ -170,6 +199,9 @@ SET observed_at=EXCLUDED.observed_at,received_at=EXCLUDED.received_at,payload=EX
# The project name is unique to this process. `-v` removes only the empty or
# smoke-populated volume created above; no pre-existing Docker object can
# match or be referenced by this compose project.
+ if ($runnerNetwork) {
+ try { docker network disconnect $runnerNetwork $runnerContainer 2>$null | Out-Null } catch { }
+ }
docker compose @compose down --volumes --remove-orphans 2>$null
Remove-Item Env:PULSE_POSTGRES_PASSWORD,Env:PULSE_DATABASE_URL,Env:PULSE_ENV,Env:PULSE_AUTH_MODE,Env:PULSE_SESSION_IDLE_TTL,Env:PULSE_SESSION_ABSOLUTE_TTL,Env:PULSE_DEV_WEB_PORT,Env:PULSE_DEV_API_PORT,Env:PULSE_DEV_DB_PORT,Env:PULSE_SMOKE_WEBHOOK_TOKEN,Env:PULSE_E2E_REAL_BASE_URL -ErrorAction SilentlyContinue
}
diff --git a/scripts/public-verify.ps1 b/scripts/public-verify.ps1
index 889ab98..bcf8eaf 100644
--- a/scripts/public-verify.ps1
+++ b/scripts/public-verify.ps1
@@ -22,6 +22,7 @@ Invoke-Checked 'Frontend typecheck' { pnpm typecheck }
Invoke-Checked 'Frontend lint' { pnpm lint }
Invoke-Checked 'Frontend build' { pnpm build }
Invoke-Checked 'API contract' { python tools/check_api_contract.py }
+Invoke-Checked 'Release truth' { python tools/check_release_truth.py }
Invoke-Checked 'Schema contracts' { python tools/validate_contracts.py }
Invoke-Checked 'Production wiring' { python tools/check_wiring.py }
Invoke-Checked 'Secret markers' { python tools/check_secrets.py }
diff --git a/specs/api-routes.json b/specs/api-routes.json
index 6f3c4c1..c557428 100644
--- a/specs/api-routes.json
+++ b/specs/api-routes.json
@@ -13,6 +13,7 @@
{"method":"GET","path":"/auth/test-login","registration":"/auth/test-login","access":"development-only","implementation":"cmd/api/main.go"},
{"method":"POST","path":"/session/logout","registration":"/session/logout","access":"session","implementation":"cmd/api/main.go"},
+ {"method":"GET","path":"/api/v1/session","registration":"/api/v1/session","access":"view","implementation":"internal/sessionapi/handler.go"},
{"method":"GET","path":"/api/v1/system/status","registration":"/api/v1/system/status","access":"view","implementation":"internal/systemstatusapi/handler.go"},
{"method":"GET","path":"/api/v1/system/diagnostics","registration":"/api/v1/system/diagnostics","access":"operate","implementation":"internal/systemstatusapi/handler.go"},
{"method":"GET","path":"/api/v1/system/metrics","registration":"/api/v1/system/metrics","access":"operate","implementation":"internal/observability/metrics.go"},
diff --git a/specs/widget-instance.schema.json b/specs/widget-instance.schema.json
index 68520fe..b49a957 100644
--- a/specs/widget-instance.schema.json
+++ b/specs/widget-instance.schema.json
@@ -100,6 +100,10 @@
"type": "integer",
"minimum": 1,
"maximum": 1000
+ },
+ "text": {
+ "type": "string",
+ "maxLength": 5000
}
}
},
@@ -139,7 +143,13 @@
"maxItems": 10,
"items": {
"type": "object",
- "maxProperties": 10
+ "additionalProperties": false,
+ "required": ["value", "color"],
+ "properties": {
+ "value": { "type": "number" },
+ "color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" },
+ "label": { "type": "string", "maxLength": 80 }
+ }
}
}
}
@@ -165,7 +175,8 @@
"string",
"null"
],
- "maxLength": 1000
+ "maxLength": 1000,
+ "pattern": "^/(?:[^/\\u0000-\\u001F][^\\u0000-\\u001F]*)?$"
},
"liveIntervalSeconds": {
"type": "integer",