Publish curated ITWorx-Pulse source from 0e0633c8ac91cb71312a26837f791035a78d81b8
Public source validation / validate (push) Failing after 2m10s

This commit is contained in:
Public Source Publisher committed 2026-09-30 01:14:48 +00:00
1 parent a4956c2af9
commit db8093b608
88 files changed
+3689 -473

No files matched your search

+5 -4
View File
@@ -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
+10
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.5.0
1.6.0
+1 -1
View File
@@ -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.
+296 -86
View File
@@ -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",
+46 -61
View File
@@ -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.
<p align="center"><img src="apps/web/public/pulse-icon.svg" width="76" alt="ITWorx Pulse icon"></p>
<p align="center"><strong>A read-only operations view for self-hosted infrastructure.</strong></p>
<p align="center"><a href="#see-pulse-in-action">Interface</a> · <a href="#capabilities">Capabilities</a> · <a href="#try-it-locally">Quick start</a> · <a href="#production-deployment">Deployment</a></p>
Pulse is operationeel **read-only**: het observeert en verklaart, maar start, stopt, verwijdert of repareert geen infrastructuur. Ontbrekende of verouderde telemetrie verschijnt als `Onbekend`, nooit als vals groen.
ITWorx Pulse brings Unraid host, container, storage, network and service telemetry into one operator-focused interface. It helps teams see what needs attention, investigate the underlying resource, and keep dashboards, alerts, incidents and wallboards in the same context.
Actuele release: **v1.5.0**.
Current release: **v1.6.0**. See the [changelog](CHANGELOG.md) for release history.
## Wat je als gebruiker krijgt
Pulse is operationally **read-only**: it observes and explains infrastructure, but does not start, stop, delete or repair workloads. Missing or stale telemetry appears as **Unknown**, never as a reassuring green status.
- een overzicht dat meteen toont wat gezond, gedegradeerd, kritisch of onbekend is en waarom;
- detailpagina's voor host, processen, containers, applicaties, array, disks, pools, shares, services en netwerk;
- versieerbare dashboards met afzonderlijke desktop-, tablet-, mobiele en wallboardlayouts;
- begrensde historische en live Prometheus-query's zonder dat gebruikers zelf PromQL moeten schrijven;
- alerts met pending/recovery, hysterese, suppressie, silences en maintenance;
- incidentgroepering, tijdlijnen, notities, ownership en audit;
- Authentik/OIDC-login met viewer-, operator-, editor- en administratorrollen;
- controleerbare backups en een clean-room herstelprocedure;
- hardened non-root containers en een minimale read-only collectorgrens.
## See Pulse in action
## Platform in één oogopslag
![ITWorx Pulse overview using isolated test telemetry](docs/screenshots/overview-demo.png)
```text
Browser
└─ HTTPS / OIDC / REST / WebSocket
└─ Pulse Web + API
├─ PostgreSQL configuratie, inventory, dashboards, alerts en audit
├─ Prometheus begrensde historische en live metrics
├─ Pulse Worker discovery, probes, alerting en notifications
└─ Pulse Agent minimale read-only Unraid- en hostobservatie
```
*This capture uses isolated validation fixtures and does not show a production host.*
De web/API-laag krijgt geen Docker-socket of host-roottoegang. Alleen de agent ontvangt de expliciet geconfigureerde read-only bronnen die nodig zijn voor observatie.
## Capabilities
## Snel lokaal proberen
| Area | What operators can do |
| --- | --- |
| Overview and search | Move from an attention item to the exact host, container, service or storage resource. |
| Infrastructure detail | Inspect hosts, processes, containers, applications, arrays, disks, pools, shares and networks. |
| Dashboards | Compose versioned desktop, tablet, mobile and wallboard layouts. |
| Metrics | Explore bounded historical and live Prometheus queries without writing PromQL. |
| Alerts and incidents | Manage pending and recovery states, suppression, maintenance, timelines, notes and ownership. |
| Access and recovery | Use Authentik/OIDC roles, audit trails, verifiable backups and clean-room restore procedures. |
Vereisten: Go 1.26.6, Node.js 24+, pnpm 10.33+, Python 3, PowerShell 7 en Docker Compose.
The global operator bar keeps time context, entity search, favorites, saved views and recent resources available while investigating. The interface supports responsive layouts and accessible keyboard navigation.
## Try it locally
Requirements: Go 1.26.6, Node.js 24+, pnpm 10.33+, Python 3, PowerShell 7 and Docker Compose.
```powershell
Copy-Item .env.example .env
@@ -42,51 +39,39 @@ pwsh -NoProfile -File scripts/bootstrap.ps1
docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up --build
```
Open daarna `http://localhost:18080`. De ontwikkelstack gebruikt expliciete mock-authenticatie en geïsoleerde lokale data; gebruik hiervoor nooit productiecredentials of productiedata.
Open <http://localhost:18080>. 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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@itworx/pulse-web",
"private": true,
"version": "1.5.0",
"version": "1.6.0",
"type": "module",
"scripts": {
"build": "vite build",
+1
View File
@@ -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',
+29 -2
View File
@@ -24,6 +24,8 @@ export function AlertOperationsPanel() {
const [message, setMessage] = useState('');
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [view, setView] = useState<AlertView>('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() {
<div className="card alert-operation-list">
<div className="card-heading"><div><p className="card-kicker">{copy.alerts.operations}</p><h2 id="alert-operations-title">{copy.alerts.alertList}</h2></div><span className="status-badge status-badge--unknown">{copy.alerts.historyPreserved}</span></div>
<p className="card-copy">{copy.alerts.operationsIntro}</p>
<label className="alert-operation-search">Zoek in meldingen<input type="search" value={query} placeholder="Regel, entiteit, reden of status" onChange={(event) => { 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() : '')); }} /></label>
{state === 'loading' ? <p className="card-copy" role="status">{copy.alerts.operationsLoading}</p> : state === 'error' ? <p className="card-copy" role="alert">{copy.alerts.operationsError}</p> : visible.length === 0 ? <p className="card-copy">{copy.alerts.noActiveAlerts}</p> : <ul className="alert-operation-list-items">{visible.map((item) => <li key={item.id}>
<span className={'status-badge status-badge--' + alertTone(item.severity)}><span className="status-icon" aria-hidden="true">{item.severity === 'critical' ? '!' : '•'}</span>{presentStatus(item.severity)}</span>
<button className="alert-operation-button" type="button" onClick={() => void choose(item)}><strong>{item.ruleName || copy.alerts.unnamed}</strong><small>{presentStatus(item.state)}{item.entityName ? ' · ' + item.entityName : ''}</small></button>
+34 -1
View File
@@ -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() {
</div>
<div className="card" aria-labelledby="alert-editor-title">
<div className="card-heading"><div><p className="card-kicker">{copy.alerts.editor}</p><h2 id="alert-editor-title">{selected ? copy.alerts.editRule : copy.alerts.createRule}</h2></div>{selected && <span className="status-badge status-badge--ready">v{selected.currentVersion}</span>}</div>
{!selected && <section className="alert-template-assistant" aria-label="Regelassistent"><div><p className="card-kicker">Snel starten</p><h3>Kies een veilige basisregel</h3><p className="card-copy">De template vult alleen de regelconfiguratie in. Controleer metric, scope en drempels vóór opslaan.</p></div><div className="alert-template-grid"><button type="button" onClick={() => applyTemplate('cpu')}><strong>CPU hoog</strong><small>&gt; 85% gedurende 5 min</small></button><button type="button" onClick={() => applyTemplate('storage')}><strong>Storage bijna vol</strong><small>&gt; 90% gedurende 10 min</small></button><button type="button" onClick={() => applyTemplate('service')}><strong>Service down</strong><small>Down gedurende 1 min</small></button><button type="button" onClick={() => applyTemplate('source')}><strong>Bron stale</strong><small>Telemetrie 2 min verouderd</small></button><button type="button" onClick={() => applyTemplate('events')}><strong>Event burst</strong><small>3+ events in 15 min</small></button></div></section>}
<ol className="alert-editor-steps" aria-label="Regelstappen"><li><strong>1</strong><span>Signaal</span></li><li><strong>2</strong><span>Voorwaarde</span></li><li><strong>3</strong><span>Timing</span></li><li><strong>4</strong><span>Veiligheid</span></li><li><strong>5</strong><span>Preview</span></li></ol>
<div className="form-grid">
<label>{copy.alerts.name}<input autoComplete="off" id="alert-rule-name" name="name" value={draft.name} onChange={(event) => updateField('name', event.target.value)} maxLength={160} /></label>
<label>{copy.alerts.severity}<select id="alert-rule-severity" name="severity" value={draft.severity} onChange={(event) => updateField('severity', event.target.value)}><option value="attention">{copy.presentation.status.attention}</option><option value="degraded">{copy.presentation.status.degraded}</option><option value="critical">{copy.presentation.status.critical}</option></select></label>
@@ -226,7 +259,7 @@ export function AlertRulesPage() {
</div>
<div className="detail-actions"><button className="button" type="button" disabled={busy || (draft.condition.inputType === 'metric' && metricState !== 'ready') || Boolean(validateDraft(draft, metrics))} onClick={() => void save()}>{copy.alerts.save}</button>{selected && <button className="button button--secondary" type="button" disabled={busy} onClick={() => void toggle(!selected.enabled)}>{selected.enabled ? copy.alerts.disable : copy.alerts.enable}</button>}</div>
{message && <p className="form-message" role="status">{message}</p>}
<div className="preview-panel"><div className="card-heading"><div><p className="card-kicker">{copy.alerts.preview}</p><h3>{copy.alerts.previewTitle}</h3></div></div><p className="card-copy">{copy.alerts.previewDetail}</p><label>{copy.alerts.sampleValue}<input autoComplete="off" id="alert-rule-preview-value" name="previewValue" type={draft.condition.inputType === 'metric' || orderedOperators.has(draft.condition.operator) ? 'number' : 'text'} value={previewValue} onChange={(event) => setPreviewValue(event.target.value)} /></label><button className="button button--secondary" type="button" disabled={busy || Boolean(validateDraft(draft, metrics))} onClick={() => void testPreview()}>{copy.alerts.runPreview}</button>{preview && <p className="preview-result" role="status">{presentStatus(preview.state)}: {presentReason(preview.reason)}</p>}</div>
<div className="preview-panel"><div className="card-heading"><div><p className="card-kicker">{copy.alerts.preview}</p><h3>{copy.alerts.previewTitle}</h3></div></div><p className="card-copy">{copy.alerts.previewDetail}</p><label>{copy.alerts.sampleValue}<input autoComplete="off" id="alert-rule-preview-value" name="previewValue" type={draft.condition.inputType === 'metric' || orderedOperators.has(draft.condition.operator) ? 'number' : 'text'} value={previewValue} onChange={(event) => setPreviewValue(event.target.value)} /></label><button className="button button--secondary" type="button" disabled={busy || Boolean(validateDraft(draft, metrics))} onClick={() => void testPreview()}>{copy.alerts.runPreview}</button>{preview && <div className={'preview-result alert-preview-outcome' + (preview.wouldFire ? ' alert-preview-outcome--fire' : '')} role="status"><strong>{preview.wouldFire ? 'Deze voorbeeldwaarde zou de regel activeren.' : 'Deze voorbeeldwaarde activeert de regel niet.'}</strong><span>{presentStatus(preview.state)} · {presentReason(preview.reason)}</span></div>}</div>
</div>
</section>}
{section === 'controls' && <AlertControlsPanel />}
+48 -112
View File
@@ -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<RoutePath>(['/', '/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 <li><a className={active ? 'nav-link nav-link--active' : 'nav-link'} aria-current={active ? 'page' : undefined} href={item.path} title={label} onClick={(event) => { event.preventDefault(); navigate(item.path); }}><span className="nav-icon" aria-hidden="true">{item.icon}</span><span>{label}</span></a></li>;
}
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<Set<string>>(() => new Set([navigationGroups[0], activeGroup]));
useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]);
return <nav className="desktop-navigation" aria-label={copy.navigation.label}>{navigationGroups.map((group) => {
const items = navigation.filter((item) => item.group === group);
return <details className="nav-group" key={group} open={openGroups.has(group)} onToggle={(event) => { 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; }); }}><summary><span>{group}</span><span aria-hidden="true">⌄</span></summary><ul className="nav-list">{items.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details>;
})}</nav>;
}
function navigate(path: string) {
window.history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function StatusBadge({ label, tone = 'unknown' }: { label: string; tone?: 'unknown' | 'ready' }) {
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : '?'}</span>{label}</span>;
}
@@ -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<OverviewResource, string> = { host: copy.navigation.host, containers: copy.overview.signalWorkloads, pools: copy.overview.signalStorage, services: copy.navigation.services, incidents: copy.overview.signalIncidents };
const resourceRoutes: Record<OverviewResource, string> = { 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() {
<article className="overview-kpi instrument-cell"><p>{copy.overview.services}</p><strong>{servicesUsable ? boundedRatio(availableServices, overview.serviceTotal, overview.servicesPartial) : '—'}</strong><small>{overview.resources.services !== 'ready' ? signalResourceLabel(overview.resources.services, serviceTone) : !serviceConfigured ? copy.overview.signalPathNotConfigured : overview.servicesPartial ? copy.overview.signalPathPartial : copy.overview.available}</small></article>
</section>
<section className="overview-layout">
<article className="card focus-panel action-queue" aria-labelledby="overview-actions-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.actionQueue}</p><h2 id="overview-actions-title">{copy.overview.problems}</h2></div><span className="queue-count">{problems.length}</span></div>{problems.length ? <ol>{problems.map((problem, index) => <li key={problem.id}><span className="queue-severity">{index === 0 ? '!' : '?'}</span><span><strong>{problem.label}</strong><small>{problem.reason}</small></span></li>)}</ol> : <p className="card-copy">{resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}</p>}<div className="overview-status-actions">{overviewNeedsAuthentication && <SignInButton />}<button className="button button--secondary" type="button" onClick={() => { refreshSystemStatus(); refreshOverview(); }}>{copy.overview.retry}</button><button className="button button--secondary" type="button" onClick={() => navigate('/status')}>{copy.overview.openStatus}</button></div></article>
<article className="card focus-panel action-queue" aria-labelledby="overview-actions-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.actionQueue}</p><h2 id="overview-actions-title">{copy.overview.problems}</h2></div><span className="queue-count">{problems.length}</span></div>{problems.length ? <ol>{problems.map((problem, index) => <li key={problem.id}><button className="problem-link" type="button" onClick={() => navigate(problem.route)}><span className="queue-severity">{index === 0 ? '!' : '?'}</span><span><strong>{problem.label}</strong><small>{problem.reason}</small></span><span className="problem-link-arrow" aria-hidden="true">→</span></button></li>)}</ol> : <p className="card-copy">{resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}</p>}<div className="overview-status-actions">{overviewNeedsAuthentication && <SignInButton />}<button className="button button--secondary" type="button" onClick={() => { refreshSystemStatus(); refreshOverview(); }}>{copy.overview.retry}</button><button className="button button--secondary" type="button" onClick={() => navigate('/status')}>{copy.overview.openStatus}</button></div></article>
<OperationalSignalPath stages={signalStages} onNavigate={navigate} />
<article className="card overview-table-card capacity-plane" aria-labelledby="pool-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.storagePools}</p><h2 id="pool-overview-title">{copy.overview.capacity}</h2></div><button className="text-action" type="button" onClick={() => navigate('/pools')}>{copy.overview.viewAll}</button></div>{overview.resources.pools !== 'ready' ? <p className="card-copy">{resourceStateDetail(overview.resources.pools)}</p> : poolSourceTone !== 'healthy' ? <p className="card-copy">{copy.overview.resourceStaleDetail}</p> : overview.pools.length ? <ul className="overview-data-list">{overview.pools.slice(0, 5).map((pool) => { const state = operationalStorageState(pool.state, pool.capacitySeverity); return <li key={pool.id}><span><strong>{pool.name}</strong><small>{presentStatus(state)} · device-health {presentStatus(pool.state).toLowerCase()}</small></span><span className="mono-value">{metric(pool.utilizationPercent)}</span></li>; })}</ul> : <p className="card-copy">{copy.overview.noPools}</p>}</article>
<article className="card overview-table-card capacity-plane" aria-labelledby="pool-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.storagePools}</p><h2 id="pool-overview-title">{copy.overview.capacity}</h2></div><button className="text-action" type="button" onClick={() => navigate('/pools')}>{copy.overview.viewAll}</button></div>{overview.resources.pools !== 'ready' ? <p className="card-copy">{resourceStateDetail(overview.resources.pools)}</p> : poolSourceTone !== 'healthy' ? <p className="card-copy">{copy.overview.resourceStaleDetail}</p> : overview.pools.length ? <ul className="overview-data-list">{overview.pools.slice(0, 5).map((pool) => { const state = operationalStorageState(pool.state, pool.capacitySeverity); return <li key={pool.id}><button className="problem-link" type="button" onClick={() => navigate('/pools/' + encodeURIComponent(pool.id))}><span><strong>{pool.name}</strong><small>{presentStatus(state)} · device-health {presentStatus(pool.state).toLowerCase()}</small></span><span className="mono-value">{metric(pool.utilizationPercent)}</span><span className="problem-link-arrow" aria-hidden="true">→</span></button></li>; })}</ul> : <p className="card-copy">{copy.overview.noPools}</p>}</article>
<article className="card context-inspector overview-table-card workload-inspector" aria-labelledby="workload-overview-title"><div className="card-heading"><div><p className="card-kicker">Nu</p><h2 id="workload-overview-title">{copy.navigation.containers}</h2></div><button className="text-action" type="button" onClick={() => navigate('/containers')}>{copy.overview.viewAll}</button></div><div className="workload-summary"><strong>{containersUsable ? `${overview.containersPartial ? '≥' : ''}${runningContainers}` : '—'}</strong><span>{copy.overview.running}</span><strong>{containersUsable && !overview.containersPartial ? Math.max(0, overview.containerTotal - runningContainers) : '—'}</strong><span>{copy.overview.other}</span></div><p className="card-copy">{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}</p><dl className="overview-now-list"><div><dt>{copy.overview.cpu}</dt><dd>{hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'}</dd></div><div><dt>{copy.overview.memory}</dt><dd>{hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'}</dd></div><div><dt>Bron</dt><dd>{hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}</dd></div></dl></article>
<article className="card overview-table-card incident-queue" aria-labelledby="incident-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.recentIncidents}</p><h2 id="incident-overview-title">{copy.navigation.incidents}</h2></div><button className="text-action" type="button" onClick={() => navigate('/incidents')}>{copy.overview.viewAll}</button></div>{overview.resources.incidents !== 'ready' ? <p className="card-copy">{resourceStateDetail(overview.resources.incidents)}</p> : orderedIncidents.length ? <ul className="overview-data-list">{orderedIncidents.slice(0, 5).map((incident) => <li key={incident.id}><span className={'incident-dot incident-dot--' + incident.severity} aria-hidden="true" /><span><strong>{incident.title}</strong><small>{formatDateTime(incident.startedAt)}</small></span></li>)}</ul> : <p className="card-copy">{copy.overview.noIncidents}</p>}</article>
<article className="card overview-table-card incident-queue" aria-labelledby="incident-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.recentIncidents}</p><h2 id="incident-overview-title">{copy.navigation.incidents}</h2></div><button className="text-action" type="button" onClick={() => navigate('/incidents')}>{copy.overview.viewAll}</button></div>{overview.resources.incidents !== 'ready' ? <p className="card-copy">{resourceStateDetail(overview.resources.incidents)}</p> : orderedIncidents.length ? <ul className="overview-data-list">{orderedIncidents.slice(0, 5).map((incident) => <li key={incident.id}><button className="problem-link" type="button" onClick={() => navigate('/incidents/' + encodeURIComponent(incident.id))}><span className={'incident-dot incident-dot--' + incident.severity} aria-hidden="true" /><span><strong>{incident.title}</strong><small>{formatDateTime(incident.startedAt)}</small></span><span className="problem-link-arrow" aria-hidden="true">→</span></button></li>)}</ul> : <p className="card-copy">{copy.overview.noIncidents}</p>}</article>
</section>
</div>;
}
@@ -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<T>(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<T>(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<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new ApiError(response.status);
return response.json() as Promise<T>;
}
function DashboardsPage() {
const [state, setState] = useState<'loading' | 'error' | 'unauthorized' | 'empty' | 'ready'>('loading');
const [items, setItems] = useState<DashboardSummary[]>([]);
@@ -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 ? <article className="widget-card widget-card--error" role="alert"><p className="card-kicker">{copy.dashboards.widgetError}</p><h3>{this.props.title}</h3><p>{copy.dashboards.widgetErrorDetail}</p></article> : this.props.children; }
}
const widgetLabels: Record<string, string> = { 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<number>(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<boolean>(behavior, 'hidden')) return null;
const activeLayout = field<ApiRecord>(widget.layouts ?? {}, viewport) ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
if (field<boolean>(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<ApiRecord>({});
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
const [editing, setEditing] = useState(false);
const [canEdit, setCanEdit] = useState(false);
const [crossFilter, setCrossFilter] = useState<CrossFilter | null>(null);
const [runtimeStates, setRuntimeStates] = useState<Record<string, RuntimeState>>({});
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<ApiRecord>(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 <section className={wallboard ? 'dashboard-view wallboard-view' : 'dashboard-view'} aria-labelledby="dashboard-view-title">{!wallboard && <button className="back-link" type="button" onClick={() => navigate('/dashboards')}>← {copy.dashboards.back}</button>}<header className="dashboard-view-header"><div><p className="eyebrow">{copy.dashboards.viewMode}</p>{wallboard ? <h2 id="dashboard-view-title">{summary.name}</h2> : <h1 id="dashboard-view-title">{summary.name}</h1>}<p className="intro">{summary.description || copy.dashboards.noDescription}</p></div><div className="dashboard-view-meta">{wallboard && <span className="wallboard-read-only">{copy.wallboard.readOnly}</span>}<StatusBadge label={systemStatus.label} tone={systemStatus.tone} /><span>{copy.dashboards.version} {summary.currentVersion}</span>{!wallboard && <button className="button button--secondary" type="button" onClick={() => setEditing(true)}>{copy.dashboards.edit}</button>}</div></header>{!wallboard && <div className="dashboard-controls" aria-label={copy.dashboards.controls}><label>{copy.dashboards.timeRange}<select value={timeRange} onChange={(event) => setTimeRange(event.target.value)}><option value="live">{copy.dashboards.live}</option><option value="15m">15 {copy.dashboards.minutes}</option><option value="1h">1 {copy.dashboards.hour}</option><option value="6h">6 {copy.dashboards.hours}</option><option value="24h">24 {copy.dashboards.hours}</option><option value="7d">7 {copy.dashboards.days}</option></select></label><label>{copy.dashboards.filter}<input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder={copy.dashboards.filterPlaceholder} /></label><span className="view-mode-note">{crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView}</span><span className="metric-query-status" role="status">{usableCount} van {shown.length} {copy.dashboards.widgetsWithData}</span>{crossFilter && <button className="button button--secondary clear-cross-filter" type="button" onClick={clearCrossFilter}>{copy.dashboards.clearFilter}</button>}</div>}{shown.length === 0 ? <div className="card dashboard-message"><h2>{copy.dashboards.noMatchingWidgets}</h2><p>{copy.dashboards.clearFilterHint}</p></div> : <><h2 className="sr-only" id="dashboard-widgets-title">{copy.dashboards.widgetCollection}</h2><div className="dashboard-grid" aria-labelledby="dashboard-widgets-title">{shown.map((widget) => <WidgetBoundary key={widget.id} title={widget.title || copy.widgets.unknown}>{['semantic-metric', 'inventory', 'events'].includes(String(field(widget.data, 'sourceType') ?? '')) ? <DashboardRuntimeWidget widget={widget} viewport={viewport} document={document} timeRange={timeRange} onState={updateRuntimeState} onFilter={() => applyCrossFilter(widget)} /> : <DashboardWidgetView widget={widget} viewport={viewport} onFilter={applyCrossFilter} />}</WidgetBoundary>)}</div></>}</section>;
const usableCount = Object.values(runtimeStates).filter((value) => value === 'usable' || value === 'partial' || value === 'stale').length;
return <section className={wallboard ? 'dashboard-view wallboard-view' : 'dashboard-view'} aria-labelledby="dashboard-view-title">{!wallboard && <button className="back-link" type="button" onClick={() => navigate('/dashboards')}>← {copy.dashboards.back}</button>}<header className="dashboard-view-header"><div><p className="eyebrow">{copy.dashboards.viewMode}</p>{wallboard ? <h2 id="dashboard-view-title">{summary.name}</h2> : <h1 id="dashboard-view-title">{summary.name}</h1>}<p className="intro">{summary.description || copy.dashboards.noDescription}</p></div><div className="dashboard-view-meta">{wallboard && <span className="wallboard-read-only">{copy.wallboard.readOnly}</span>}<StatusBadge label={systemStatus.label} tone={systemStatus.tone} /><span>{copy.dashboards.version} {summary.currentVersion}</span>{!wallboard && canEdit && <button className="button button--secondary" type="button" onClick={() => setEditing(true)}>{copy.dashboards.edit}</button>}</div></header>{!wallboard && <div className="dashboard-controls" aria-label={copy.dashboards.controls}><label>{copy.dashboards.timeRange}<select value={timeRange} onChange={(event) => setTimeRange(event.target.value)}><option value="live">{copy.dashboards.live}</option><option value="15m">15 {copy.dashboards.minutes}</option><option value="1h">1 {copy.dashboards.hour}</option><option value="6h">6 {copy.dashboards.hours}</option><option value="24h">24 {copy.dashboards.hours}</option><option value="7d">7 {copy.dashboards.days}</option></select></label><label>{copy.dashboards.filter}<input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder={copy.dashboards.filterPlaceholder} /></label><span className="view-mode-note">{crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView}</span><span className="metric-query-status" role="status">{usableCount} van {shown.length} {copy.dashboards.widgetsWithData}</span>{crossFilter && <button className="button button--secondary clear-cross-filter" type="button" onClick={clearCrossFilter}>{copy.dashboards.clearFilter}</button>}</div>}{shown.length === 0 ? <div className="card dashboard-message"><h2>{copy.dashboards.noMatchingWidgets}</h2><p>{copy.dashboards.clearFilterHint}</p></div> : <><h2 className="sr-only" id="dashboard-widgets-title">{copy.dashboards.widgetCollection}</h2><div className="dashboard-grid" aria-labelledby="dashboard-widgets-title">{shown.map((widget) => <WidgetBoundary key={widget.id} title={widget.title || copy.widgets.unknown}>{['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'].includes(String(field(widget.data, 'sourceType') ?? '')) ? <DashboardRuntimeWidget widget={widget} viewport={viewport} document={document} timeRange={timeRange} onState={updateRuntimeState} onFilter={() => applyCrossFilter(widget)} /> : <DashboardWidgetView widget={widget} viewport={viewport} onFilter={applyCrossFilter} />}</WidgetBoundary>)}</div></>}</section>;
}
type WallboardPriorityData = { serviceProblems: number; openIncidents: number; loading: boolean; unavailable: boolean };
@@ -708,31 +668,6 @@ function WallboardPage() {
</section>;
}
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 <><PageIntro eyebrow={copy.settings.eyebrow} title={copy.settings.title} intro={copy.settings.intro} />
<section className="settings-overview card" aria-labelledby="settings-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.settings.current}</p><h2 id="settings-overview-title">{copy.settings.environment}</h2></div><StatusBadge label={status.label} tone={status.tone} /></div><div className="setting-row"><span><strong>{copy.settings.source}</strong><small>{sourceDetail}</small></span><span>{connected}/{total || '—'}</span></div><div className="setting-row"><span><strong>{copy.settings.language}</strong><small>{copy.settings.languageDetail}</small></span><strong>{copy.settings.languageValue}</strong></div></section>
<section className="settings-hub" aria-label={copy.settings.management}><h2 className="sr-only">{copy.settings.management}</h2>{groups.map((group) => <article className="card settings-hub-card" key={group.title}><p className="card-kicker">{copy.settings.management}</p><h3>{group.title}</h3><p className="card-copy">{group.detail}</p><ul>{group.links.map((link) => <li key={link.href}><a href={link.href} onClick={(event) => { event.preventDefault(); navigate(link.href); }}><span><strong>{link.title}</strong><small>{link.detail}</small></span><span className="settings-access">{link.access}<span aria-hidden="true">→</span></span></a></li>)}</ul></article>)}</section>
</>;
}
function StatePage({ kind }: { kind: 'loading' | 'error' | 'unauthorized' }) {
if (kind === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.states.loading}</h1></section>;
if (kind === 'unauthorized') {
@@ -774,7 +709,8 @@ function Page({ route }: { route: RoutePath }) {
case '/storage': return <StoragePage />;
case '/capacity': return <CapacityPage />;
case '/inventory': return <InventoryPage />;
case '/dashboards': return <DashboardsPage />;
case '/dashboards': return <RouteSuspense><DashboardManagerPage /></RouteSuspense>;
case '/explore': return <RouteSuspense><ExploreMetricsPage /></RouteSuspense>;
case '/wallboard': return <WallboardPage />;
case '/alerts': return <RouteSuspense><AlertsPage /></RouteSuspense>;
case '/events': return <EventsPage />;
@@ -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 <div className="app-shell app-shell--wallboard"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><main id="main-content" className="content"><Page route={route} /></main></div>;
return <div className="app-shell"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><aside className="sidebar"><a className="brand" href="/" title={copy.brand.name} onClick={(event) => { event.preventDefault(); navigate('/'); }}><span className="brand-mark" aria-hidden="true"><span>P</span></span><span className="brand-copy"><strong>{copy.brand.name}</strong><small>{copy.brand.context}</small></span></a><DesktopNavigation route={route} /><nav className="mobile-navigation" aria-label={copy.navigation.label}><ul className="nav-list mobile-primary-list">{mobilePrimaryNavigation.map((item) => <NavigationLink key={item.path} item={item} label={item.path === '/storage' ? copy.navigation.mobileStorage : item.label} />)}</ul><details className="mobile-more" open={mobileMoreNavigation.some((item) => route === item.path)}><summary>{copy.navigation.more}</summary><ul className="nav-list">{mobileMoreNavigation.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details></nav><div className="sidebar-status" title={shellStatus.detail}><StatusBadge label={shellStatus.label} tone={shellStatus.tone} /><span>{shellStatus.detail}</span></div></aside><div className="app-workspace"><header className="context-bar"><div className="context-location"><span className="context-server"><span className="context-server-mark" aria-hidden="true">T</span><span><small>Server</small><strong>Tower</strong></span></span><span className="context-divider" aria-hidden="true" /><span className="context-product">Pulse</span><span aria-hidden="true">/</span><strong>{route === '/404' ? copy.notFound.context : currentNavigation.label}</strong></div><div className="context-actions"><span className="context-live"><span aria-hidden="true">●</span> Live verbonden</span><span className="context-read-only">Alle bronnen · alleen-lezen</span><StatusBadge label={shellStatus.label} tone={shellStatus.tone} /></div></header><main id="main-content" className="content"><AuthNoticeBanner /><Page route={route} /></main></div></div>;
if (route === '/wallboard') return <div className="app-shell app-shell--wallboard"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><main id="main-content" className="content"><RouteErrorBoundary resetKey={route}><Page route={route} /></RouteErrorBoundary></main></div>;
return <OperatorAppShell route={route} shellStatus={shellStatus} currentNavigation={currentNavigation}><AuthNoticeBanner /><RouteErrorBoundary resetKey={route}><Page route={route} /></RouteErrorBoundary></OperatorAppShell>;
}
export default App;
+162
View File
@@ -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<NavigationDefinition>;
export type NavigationItem = (typeof navigation)[number];
const sectionLabels: Record<SectionId, string> = {
command: 'Operator',
infrastructure: 'Infrastructuur',
workloads: 'Workloads',
services: 'Services',
response: 'Response',
manage: 'Beheer',
};
const primaryNavigation = navigation.filter((item) => item.primary);
const mobilePrimaryOrder = new Map<RoutePath, number>([['/', 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 <li><a className={(active ? 'nav-link nav-link--active' : 'nav-link') + (domain ? ' nav-link--domain' : '')} aria-current={active ? 'page' : undefined} href={item.path} title={label} onClick={(event) => { event.preventDefault(); navigate(item.path); }}><span className="nav-icon" aria-hidden="true">{item.icon}</span><span>{label}</span></a></li>;
}
function DesktopNavigation({ route }: { route: RoutePath }) {
const activeItem = routeDefinition(route);
const activeGroup = activeItem.group;
const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set([navigationGroups[0], activeGroup]));
useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]);
return <nav className="desktop-navigation" aria-label={copy.navigation.label}>{navigationGroups.map((group) => {
const items = primaryNavigation.filter((item) => item.group === group);
return <details className="nav-group" key={group} open={openGroups.has(group)} onToggle={(event) => { 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; }); }}><summary><span>{group}</span><span aria-hidden="true">⌄</span></summary><ul className="nav-list">{items.map((item) => <NavigationLink key={item.path} item={item} domain />)}</ul></details>;
})}</nav>;
}
function ShellStatusBadge({ label, tone }: { label: string; tone: 'unknown' | 'ready' }) {
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : '?'}</span>{label}</span>;
}
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 <div className="workspace-context" aria-label="Werkcontext">
<div className="workspace-context-main">
{backTarget && <button className="workspace-back" type="button" onClick={() => navigate(backTarget)} aria-label="Terug naar vorige context"><span aria-hidden="true">←</span><span>Terug</span></button>}
<nav className="workspace-breadcrumbs" aria-label="Breadcrumb">
<a href="/" onClick={(event) => { event.preventDefault(); navigate('/'); }}>Pulse</a><span aria-hidden="true">/</span>
<button type="button" onClick={() => navigate(sectionItems.find((entry) => entry.primary)?.path ?? '/')}>{sectionLabels[item.section]}</button><span aria-hidden="true">/</span>
<strong>{pageLabel}</strong>
</nav>
</div>
<div className="workspace-actions">
<button className={'workspace-action' + (favorite ? ' workspace-action--active' : '')} type="button" aria-pressed={favorite} onClick={() => toggleFavorite(pageLabel, currentPath)} title={favorite ? 'Verwijder uit favorieten' : 'Voeg toe aan favorieten'}><span aria-hidden="true">☆</span><span>{favorite ? 'Favoriet' : 'Favoriet'}</span></button>
<button className={'workspace-action' + (saved ? ' workspace-action--active' : '')} type="button" aria-pressed={saved} onClick={() => saved ? removeSavedView(currentPath) : saveCurrentView(pageLabel, currentPath)} title={saved ? 'Verwijder opgeslagen view' : 'Bewaar huidige filters en view'}><span aria-hidden="true">▣</span><span>{saved ? 'View bewaard' : 'Bewaar view'}</span></button>
</div>
<nav className="workspace-subnav" aria-label={sectionLabels[item.section] + ' navigatie'}>{sectionItems.map((entry) => {
const active = matchesRoute(route, entry.path);
return <a key={entry.path} href={entry.path} aria-current={active ? 'page' : undefined} onClick={(event) => { event.preventDefault(); navigate(entry.path); }}>{entry.label}</a>;
})}</nav>
</div>;
}
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 <div className="app-shell"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><aside className="sidebar"><a className="brand" href="/" title={copy.brand.name} onClick={(event) => { event.preventDefault(); navigate('/'); }}><span className="brand-mark" aria-hidden="true"><span>P</span></span><span className="brand-copy"><strong>{copy.brand.name}</strong><small>{copy.brand.context}</small></span></a><DesktopNavigation route={route} /><nav className="mobile-navigation" aria-label={copy.navigation.label}><ul className="nav-list mobile-primary-list">{mobilePrimaryNavigation.map((item) => <NavigationLink key={item.path} item={item} label={item.path === '/storage' ? copy.navigation.mobileStorage : item.label} />)}</ul><details className="mobile-more" open={mobileMoreNavigation.some((item) => matchesRoute(route, item.path))}><summary>{copy.navigation.more}</summary><ul className="nav-list">{mobileMoreNavigation.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details></nav><div className="sidebar-status" title={shellStatus.detail}><ShellStatusBadge label={shellStatus.label} tone={shellStatus.tone} /><span>{shellStatus.detail}</span></div></aside><div className="app-workspace"><OperatorCommandBar routeLabel={route === '/404' ? copy.notFound.context : currentNavigation.label} navigation={navigation} onNavigate={navigate} /><WorkspaceContext route={route} currentNavigation={resolved} /><main id="main-content" className="content">{children}</main></div></div>;
}
+14 -2
View File
@@ -16,8 +16,20 @@ function Badge({ state }: { state: string }) {
function Source({ source }: { source: ApplicationSnapshot['source'] }) {
return <SourceStatusDetails source={source ?? {}} fallbackReason={source?.freshness === 'fresh' ? copy.applications.fresh : copy.applications.stale} />;
}
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 <details className="card technical-details"><summary>{copy.applications.components} ({items.length})</summary><div className="application-components">{items.map((item) => <article className="application-component" key={item.id}><div><strong>{item.name}</strong><small>{item.kind} · {item.critical ? copy.applications.critical : copy.applications.optional}</small></div><div><Badge state={item.status} /><small>{item.reason || item.id}</small></div></article>)}</div></details>;
return <section className="card application-component-hub" aria-labelledby="application-components-title"><div className="card-heading"><div><p className="card-kicker">Workload context</p><h2 id="application-components-title">{copy.applications.components}</h2></div><span className="status-badge status-badge--unknown">{items.length}</span></div>{items.length === 0 ? <p className="card-copy">Voor deze applicatie zijn geen componentrelaties aangeleverd.</p> : <ul className="application-component-links">{items.map((item) => <li key={item.id}><button type="button" onClick={() => navigate(componentRoute(item))}><span><strong>{item.name}</strong><small>{item.kind} · {item.critical ? copy.applications.critical : copy.applications.optional}</small><small>Container: {item.containerState || '—'} · service: {item.serviceState || '—'}</small></span><span><Badge state={item.status} /><small>{item.reason || item.id}</small><span className="problem-link-arrow" aria-hidden="true">→</span></span></button></li>)}</ul>}<details className="technical-details"><summary>Technische componentidentiteiten</summary><dl className="metric-list">{items.map((item) => <div key={item.id}><dt>{item.name}</dt><dd><code>{item.id}</code></dd></div>)}</dl></details></section>;
}
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 <><header className="page-intro"><p className="eyebrow">{copy.applications.detailEyebrow}</p><h1>{app.name}</h1><p className="intro">{copy.applications.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/applications">{copy.applications.backToList}</a><a className="button button--secondary" href="/dashboards?filterKey=sourceType&filterValue=inventory">{copy.applications.openDashboard}</a></div></header><section className="card container-summary" aria-labelledby="application-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.applications.source}</p><h2 id="application-summary-title">{detail.source?.id || copy.applications.unknown}</h2><Source source={detail.source} /></div><Badge state={app.status} /></div>{app.reasons?.map((reason) => <p className="host-reason" key={reason.componentId + '-' + reason.code}>{presentReason(reason.code)}</p>)}</section><Components items={app.components} /></>;
return <><header className="page-intro"><p className="eyebrow">{copy.applications.detailEyebrow}</p><h1>{app.name}</h1><p className="intro">{copy.applications.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/applications">{copy.applications.backToList}</a><a className="button button--secondary" href="/dashboards?filterKey=sourceType&filterValue=inventory">{copy.applications.openDashboard}</a></div></header><section className="application-workload-band instrument-band" aria-label="Applicatiesamenvatting"><article className="instrument-cell"><p>Status</p><strong>{app.status}</strong><small>{app.overridden ? copy.applications.overridden : copy.applications.discovered}</small></article><article className="instrument-cell"><p>Componenten</p><strong>{app.components.length}</strong><small>{app.components.filter((item) => item.critical).length} kritisch</small></article><article className="instrument-cell"><p>Containers</p><strong>{app.components.filter((item) => item.kind.toLowerCase().includes('container')).length}</strong><small>Direct navigeerbaar</small></article><article className="instrument-cell"><p>Services</p><strong>{app.components.filter((item) => item.kind.toLowerCase().includes('service') || item.kind.toLowerCase().includes('probe') || item.kind.toLowerCase().includes('endpoint')).length}</strong><small>Healthcontext</small></article></section><section className="card container-summary" aria-labelledby="application-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.applications.source}</p><h2 id="application-summary-title">{detail.source?.id || copy.applications.unknown}</h2><Source source={detail.source} /></div><Badge state={app.status} /></div>{app.reasons?.map((reason) => <p className="host-reason" key={reason.componentId + '-' + reason.code}>{presentReason(reason.code)}</p>)}</section><Components items={app.components} /></>;
}
const snapshot = data as ApplicationSnapshot;
return <><header className="page-intro"><p className="eyebrow">{copy.applications.eyebrow}</p><h1>{copy.applications.title}</h1><p className="intro">{copy.applications.intro}</p></header><section className="card container-summary" aria-labelledby="applications-source-title"><div className="card-heading"><div><p className="card-kicker">{copy.applications.source}</p><h2 id="applications-source-title">{snapshot.source?.id || copy.applications.unknown}</h2><Source source={snapshot.source} /></div><Badge state={snapshot.source?.state || 'unknown'} /></div><p className="container-count">{snapshot.total} {copy.applications.rows}</p></section><section className="card container-panel" aria-labelledby="application-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.applications.list}</p><h2 id="application-list-title">{copy.applications.overview}</h2></div></div>{snapshot.applications.length === 0 ? <p className="card-copy">{copy.applications.empty}</p> : <ul className="inventory-list">{snapshot.applications.map((item) => <li key={item.id}><a className="entity-link" href={"/applications/" + encodeURIComponent(item.id)}><strong>{item.name}</strong><small>{item.components.length} {copy.applications.components} · {item.overridden ? copy.applications.overridden : copy.applications.discovered}</small></a><Badge state={item.status} /></li>)}</ul>}</section></>;
+104 -38
View File
@@ -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<string, unknown>;
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<string, string> = { 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<string, string> = 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<string, string> = {
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<Viewport, string> = { desktop: copy.editor.viewports.desktop, tablet: copy.editor.viewports.tablet, mobile: copy.editor.viewports.mobile, wallboard: copy.editor.viewports.wallboard };
const viewportColumns: Record<Viewport, number> = { desktop: 18, tablet: 8, mobile: 1, wallboard: 24 };
function read<T>(value: RecordValue | undefined, name: string): T | undefined {
if (!value) return undefined;
@@ -31,24 +44,24 @@ function read<T>(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<RecordValue>(layouts, 'desktop') ?? {};
return { ...fallback, ...(read<RecordValue>(layouts, viewport) ?? {}) };
return normalizePlacement({ ...fallback, ...(read<RecordValue>(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<RecordValue>(layouts, 'desktop') ?? {}) };
const tablet = { ...desktop, ...(read<RecordValue>(layouts, 'tablet') ?? {}) };
const mobile = { ...desktop, ...(read<RecordValue>(layouts, 'mobile') ?? {}), w: 1 };
const wallboard = { ...desktop, ...(read<RecordValue>(layouts, 'wallboard') ?? {}) };
const desktop = normalizePlacement({ ...base, ...(read<RecordValue>(layouts, 'desktop') ?? {}) }, 'desktop');
const tablet = normalizePlacement({ ...desktop, ...(read<RecordValue>(layouts, 'tablet') ?? {}) }, 'tablet');
const mobile = normalizePlacement({ ...desktop, ...(read<RecordValue>(layouts, 'mobile') ?? {}) }, 'mobile');
const wallboard = normalizePlacement({ ...desktop, ...(read<RecordValue>(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<Record<string, unknown>> : [];
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<unknown[]>(document, 'widgets') ?? []) as EditorWidget[];
const initialVariables = (read<unknown[]>(document, 'variables') ?? []) as DashboardVariable[];
const [draft, setDraft] = useState<EditorWidget[]>(initial.map((widget, index) => normalizeWidget(copyWidget(widget), index)));
const normalizedInitial = normalizeWidgets(initial);
const [draft, setDraft] = useState<EditorWidget[]>(normalizedInitial);
const [variables, setVariables] = useState<DashboardVariable[]>(initialVariables.map((variable) => ({ ...variable, options: variable.options ? [...variable.options] : undefined })));
const [history, setHistory] = useState<EditorWidget[][]>([]);
const [future, setFuture] = useState<EditorWidget[][]>([]);
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<string | null>(initial[0]?.id ?? null);
const [newType, setNewType] = useState(types[0]);
const [activeViewport, setActiveViewport] = useState<Viewport>(() => window.innerWidth <= 700 ? 'mobile' : 'desktop');
const [dragged, setDragged] = useState<string | null>(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<boolean>(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<HTMLElement>, 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<HTMLElement>, 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<HTMLElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>, 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<unknown[]>(nextDocument, 'widgets') ?? []) as EditorWidget[];
const importedVariables = (read<unknown[]>(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<RecordValue>(data.version, 'document') ?? {};
const serverWidgets = (read<unknown[]>(serverDocument, 'widgets') ?? []) as EditorWidget[];
const next = serverWidgets.map((widget, index) => normalizeWidget(copyWidget(widget), index));
const next = normalizeWidgets(serverWidgets);
const nextVariables = (read<unknown[]>(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 <section className="dashboard-editor" aria-labelledby="dashboard-editor-title">
<header className="editor-header"><div><p className="eyebrow">{shell.eyebrow}</p><h1 id="dashboard-editor-title">{shell.title}</h1><p className="intro">{shell.intro}</p>{dirty && <p className="editor-dirty" role="status">{shell.dirty}</p>}</div><div className="editor-actions"><button className="button button--secondary" type="button" onClick={undo} disabled={!history.length} aria-label={shell.undo}>{shell.undo}</button><button className="button button--secondary" type="button" onClick={redo} disabled={!future.length} aria-label={shell.redoLabel}>{shell.redo}</button><button className="button button--secondary" type="button" onClick={requestExit}>{shell.cancel}</button><button className="button" type="button" onClick={save} disabled={saving}>{saving ? shell.saving : shell.save}</button></div></header>
{conflict && <div className="editor-banner editor-banner--conflict" role="alert"><strong>{shell.conflictTitle}</strong><span>{shell.conflictDetail}</span><button className="button button--secondary" type="button" onClick={reloadServer}>{shell.reloadServer}</button></div>}{confirmExit && <div className="editor-banner" role="alert"><strong>{shell.confirmExitTitle}</strong><span>{shell.confirmExitDetail}</span><button className="button button--secondary" type="button" onClick={() => setConfirmExit(false)}>{shell.keepEditing}</button><button className="button button--danger" type="button" onClick={onExit}>{shell.leaveWithoutSaving}</button></div>}<details className="editor-advanced"><summary>{shell.advanced}</summary><DashboardTransfer document={{ ...document, widgets: draft, variables }} onImport={importDocument} /><DashboardVariablesEditor variables={variables} onChange={setVariables} /></details><div className="editor-toolbar" aria-label={shell.library}><label>{shell.layout}<select value={activeViewport} onChange={(event) => setActiveViewport(event.target.value as Viewport)}>{viewports.map((viewport) => <option key={viewport} value={viewport}>{viewportLabels[viewport]}</option>)}</select></label><label>{shell.addWidget}<select value={newType} onChange={(event) => setNewType(event.target.value)}>{types.map((type) => <option key={type} value={type}>{labels[type]}</option>)}</select></label><button className="button button--secondary" type="button" onClick={add}>{shell.addWidget}</button>{message && <p className="editor-message" role="alert">{message}</p>}</div>
{conflict && <div className="editor-banner editor-banner--conflict" role="alert"><strong>{shell.conflictTitle}</strong><span>{shell.conflictDetail}</span><button className="button button--secondary" type="button" onClick={reloadServer}>{shell.reloadServer}</button></div>}{confirmExit && <div className="editor-banner" role="alert"><strong>{shell.confirmExitTitle}</strong><span>{shell.confirmExitDetail}</span><button className="button button--secondary" type="button" onClick={() => setConfirmExit(false)}>{shell.keepEditing}</button><button className="button button--danger" type="button" onClick={onExit}>{shell.leaveWithoutSaving}</button></div>}<details className="editor-advanced"><summary>{shell.advanced}</summary><DashboardTransfer document={{ ...document, widgets: draft, variables }} onImport={importDocument} /><DashboardVariablesEditor variables={variables} onChange={setVariables} /></details><div className="editor-toolbar" aria-label={shell.library}><label>{shell.layout}<select value={activeViewport} onChange={(event) => setActiveViewport(event.target.value as Viewport)}>{viewports.map((viewport) => <option key={viewport} value={viewport}>{viewportLabels[viewport]}</option>)}</select></label><details className="widget-picker"><summary className="button button--secondary">{shell.addWidget}</summary><div className="widget-picker-panel" aria-label="Widgetbibliotheek">{widgetGroups.map((group) => <section key={group.id}><p className="card-kicker">{group.label}</p><div className="widget-picker-grid">{group.types.map((type) => <button type="button" key={type} onClick={(event) => { add(type); const details = event.currentTarget.closest('details'); if (details) details.open = false; }}><span className="widget-picker-glyph" aria-hidden="true">{widgetGlyphs[type] ?? '□'}</span><span><strong>{labels[type]}</strong><small>{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'}</small></span></button>)}</div></section>)}</div></details>{message && <p className="editor-message" role="alert">{message}</p>}</div>
<h2 className="sr-only" id="editor-canvas-title">{shell.canvas}</h2><div className="editor-layout"><div className="dashboard-grid dashboard-grid--edit" data-viewport={activeViewport} aria-labelledby="editor-canvas-title">
{draft.map((widget) => { const activeLayout = layout(widget, activeViewport); const locked = read<boolean>(widget.behavior, 'locked') === true; const viewportHidden = read<boolean>(activeLayout, 'visible') === false; const hidden = read<boolean>(widget.behavior, 'hidden') === true || viewportHidden; const errors = validateWidget(widget); const width = layoutWidth(widget, activeViewport); return <article key={widget.id} className={'widget-card editor-widget' + (selected === widget.id ? ' editor-widget--selected' : '') + (locked ? ' editor-widget--locked' : '') + (hidden ? ' editor-widget--hidden' : '')} style={{ '--widget-span': String(width) } as CSSProperties} data-dragging={dragged === widget.id ? 'true' : undefined} onPointerDown={(event) => onWidgetPointerDown(event, widget.id, locked)} onPointerMove={(event) => onWidgetPointerMove(event, widget.id)} onPointerUp={onWidgetPointerUp} onPointerCancel={onWidgetPointerUp} onClick={() => { setSelected(widget.id); setPreview(null); setPreviewFor(null); }}><div className="widget-card-heading"><div><p className="card-kicker">{labels[widget.type] ?? widgetTypeCopy.fallback}</p><h3>{widget.title || widgetTypeCopy.untitled}</h3></div><span className="editor-lock-state">{locked ? cardCopy.locked : cardCopy.movable}</span></div><div className="editor-widget-actions"><button type="button" aria-label={cardCopy.moveUp + widget.title} onClick={(event) => { event.stopPropagation(); move(widget.id, -1); }}>{cardCopy.moveUpVisible}</button><button type="button" aria-label={cardCopy.moveDown + widget.title} onClick={(event) => { event.stopPropagation(); move(widget.id, 1); }}>{cardCopy.moveDownVisible}</button><button type="button" aria-label={(locked ? cardCopy.unlockLabel : cardCopy.lockLabel) + widget.title} onClick={(event) => { event.stopPropagation(); update(widget.id, (item) => ({ ...item, behavior: { ...(item.behavior ?? {}), locked: !locked } })); }}>{locked ? cardCopy.unlock : cardCopy.lock}</button><button type="button" aria-label={(viewportHidden ? cardCopy.show : cardCopy.hide) + cardCopy.inViewport + viewportLabels[activeViewport]} onClick={(event) => { event.stopPropagation(); update(widget.id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), visible: viewportHidden })); }}>{viewportHidden ? cardCopy.show : cardCopy.hide}</button><button type="button" onClick={(event) => { event.stopPropagation(); duplicate(widget.id); }}>{cardCopy.duplicate}</button><button type="button" onClick={(event) => { event.stopPropagation(); remove(widget.id); }}>{cardCopy.remove}</button><button className="editor-resize-handle" type="button" role="slider" tabIndex={0} aria-label={cardCopy.resizeLabel + widget.title} aria-orientation="horizontal" aria-valuemin={1} aria-valuemax={viewportColumns[activeViewport]} aria-valuenow={width} aria-valuetext={width + ' ' + cardCopy.columns} aria-describedby={'resize-hint-' + widget.id} onKeyDown={(event) => 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}</button></div><p className="sr-only" id={'resize-hint-' + widget.id}>{cardCopy.resizeHint}</p>{Object.keys(errors).length > 0 && <p className="editor-drag-hint editor-widget-warning" role="status">{cardCopy.incompleteConfig}</p>}<p className="editor-drag-hint">{locked ? cardCopy.lockedHint : cardCopy.dragHint}</p></article>; })}
</div><div className="editor-inspector">{selectedWidget ? <WidgetConfigDrawer widget={selectedWidget} errors={selectedErrors} preview={previewFor === selected ? preview : null} previewState={previewState} previewing={previewing} previewError={previewError} viewportLabel={viewportLabels[activeViewport]} width={layoutWidth(selectedWidget, activeViewport)} maxWidth={viewportColumns[activeViewport]} onChange={updateSelected} onWidthChange={(value) => setWidgetWidth(selectedWidget.id, value)} onPreviewStateChange={setPreviewState} onPreview={previewSelected} /> : <aside className="editor-inspector card" aria-label={shell.selectedWidget}><p className="card-copy">{shell.selectWidgetHint}</p></aside>}</div></div>
{draft.map((widget) => { const activeLayout = layout(widget, activeViewport); const locked = read<boolean>(widget.behavior, 'locked') === true; const viewportHidden = activeLayout.visible === false; const hidden = read<boolean>(widget.behavior, 'hidden') === true || viewportHidden; const errors = validateWidget(widget); const width = layoutWidth(widget, activeViewport); return <article key={widget.id} className={'widget-card editor-widget' + (selected === widget.id ? ' editor-widget--selected' : '') + (locked ? ' editor-widget--locked' : '') + (hidden ? ' editor-widget--hidden' : '')} style={spatialStyle(activeLayout as unknown as RecordValue, activeViewport) as CSSProperties} data-dragging={dragged === widget.id ? 'true' : undefined} onPointerDown={(event) => onWidgetPointerDown(event, widget.id, locked)} onPointerMove={(event) => onWidgetPointerMove(event, widget.id)} onPointerUp={onWidgetPointerUp} onPointerCancel={onWidgetPointerUp} onClick={() => { setSelected(widget.id); setPreview(null); setPreviewFor(null); }}><div className="widget-card-heading"><div><p className="card-kicker">{labels[widget.type] ?? widgetTypeCopy.fallback}</p><h3>{widget.title || widgetTypeCopy.untitled}</h3></div><span className="editor-lock-state">{locked ? cardCopy.locked : cardCopy.movable}</span></div><div className="editor-widget-actions"><button type="button" disabled={locked} aria-label={cardCopy.moveUp + widget.title} onClick={(event) => { event.stopPropagation(); if (activeViewport === 'mobile') move(widget.id, -1); else mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 0, -1, others, activeViewport)); }}>{cardCopy.moveUpVisible}</button><button type="button" disabled={locked} aria-label={cardCopy.moveDown + widget.title} onClick={(event) => { event.stopPropagation(); if (activeViewport === 'mobile') move(widget.id, 1); else mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 0, 1, others, activeViewport)); }}>{cardCopy.moveDownVisible}</button>{activeViewport !== 'mobile' && <><button type="button" disabled={locked} aria-label={cardCopy.moveLeft + widget.title} onClick={(event) => { event.stopPropagation(); mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, -1, 0, others, activeViewport)); }}>{cardCopy.moveLeftVisible}</button><button type="button" disabled={locked} aria-label={cardCopy.moveRight + widget.title} onClick={(event) => { event.stopPropagation(); mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 1, 0, others, activeViewport)); }}>{cardCopy.moveRightVisible}</button></>}<button type="button" aria-label={(locked ? cardCopy.unlockLabel : cardCopy.lockLabel) + widget.title} onClick={(event) => { event.stopPropagation(); update(widget.id, (item) => ({ ...item, behavior: { ...(item.behavior ?? {}), locked: !locked } })); }}>{locked ? cardCopy.unlock : cardCopy.lock}</button><button type="button" aria-label={(viewportHidden ? cardCopy.show : cardCopy.hide) + cardCopy.inViewport + viewportLabels[activeViewport]} onClick={(event) => { event.stopPropagation(); update(widget.id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), visible: viewportHidden })); }}>{viewportHidden ? cardCopy.show : cardCopy.hide}</button><button type="button" onClick={(event) => { event.stopPropagation(); duplicate(widget.id); }}>{cardCopy.duplicate}</button><button type="button" onClick={(event) => { event.stopPropagation(); remove(widget.id); }}>{cardCopy.remove}</button><button className="editor-resize-handle" type="button" role="slider" tabIndex={0} disabled={locked} aria-label={cardCopy.resizeLabel + widget.title} aria-orientation="horizontal" aria-valuemin={1} aria-valuemax={spatialColumns[activeViewport]} aria-valuenow={width} aria-valuetext={`${width} × ${activeLayout.h} ${cardCopy.columns}`} aria-describedby={'resize-hint-' + widget.id} onKeyDown={(event) => 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}</button></div><p className="sr-only" id={'resize-hint-' + widget.id}>{cardCopy.resizeHint}</p>{Object.keys(errors).length > 0 && <p className="editor-drag-hint editor-widget-warning" role="status">{cardCopy.incompleteConfig}</p>}<p className="editor-drag-hint">{locked ? cardCopy.lockedHint : cardCopy.dragHint}</p></article>; })}
</div><div className="editor-inspector">{selectedWidget ? <WidgetConfigDrawer widget={selectedWidget} errors={selectedErrors} preview={previewFor === selected ? preview : null} previewState={previewState} previewing={previewing} previewError={previewError} viewportLabel={viewportLabels[activeViewport]} placement={layout(selectedWidget, activeViewport)} maxWidth={spatialColumns[activeViewport]} locked={read<boolean>(selectedWidget.behavior, 'locked') === true} onChange={updateSelected} onPlacementChange={(field, value) => setWidgetLayoutField(selectedWidget.id, field, value)} onPreviewStateChange={setPreviewState} onPreview={previewSelected} /> : <aside className="editor-inspector card" aria-label={shell.selectedWidget}><p className="card-copy">{shell.selectWidgetHint}</p></aside>}</div></div>
</section>;
}
+76
View File
@@ -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<LoadState>('loading');
const [items, setItems] = useState<DashboardSummary[]>([]);
const [session, setSession] = useState<SessionCapabilities | null>(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<DashboardTemplateId>('operations');
const [busy, setBusy] = useState('');
const [message, setMessage] = useState('');
const [manageId, setManageId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [confirmArchive, setConfirmArchive] = useState<string | null>(null);
const [versionDashboard, setVersionDashboard] = useState<string | null>(null);
const [versions, setVersions] = useState<DashboardVersion[]>([]);
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<void>) => {
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 <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{t.loading}</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><h1>{t.unauthorized}</h1></section>;
if (state === 'error') return <section className="state-page" role="alert"><h1>{t.error}</h1><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>{t.retry}</button></section>;
const renderDashboard = (item: DashboardSummary) => <li className="dashboard-manager-row" key={item.id}><div className="dashboard-manager-summary"><button className="dashboard-manager-open" type="button" onClick={() => navigate('/dashboards/' + encodeURIComponent(item.id))}><span><strong>{item.name}</strong><small>{item.description || item.slug}</small></span><span className="dashboard-manager-meta"><span>{item.scope === 'personal' ? t.personal : item.scope === 'shared' ? t.shared : t.system}</span><span>v{item.currentVersion} · r{item.revision}</span>{item.archivedAt && <span>{t.archivedBadge}</span>}</span></button>{canEdit && !item.archivedAt && <button className="button button--secondary dashboard-manager-manage" type="button" onClick={() => beginManage(item)}>{manageId === item.id ? t.closeManage : t.manage}</button>}</div>{canEdit && manageId === item.id && !item.archivedAt && <div className="dashboard-manager-actions"><section aria-label={t.rename}><label>{t.name}<input value={editName} maxLength={120} onChange={(event) => setEditName(event.target.value)} /></label><label>{t.description}<textarea value={editDescription} maxLength={500} rows={2} onChange={(event) => setEditDescription(event.target.value)} /></label><button className="button" type="button" disabled={busy !== ''} onClick={() => saveMetadata(item)}>{t.saveMetadata}</button></section><section className="dashboard-manager-compact-actions" aria-label={t.manage}><button className="button button--secondary" type="button" disabled={busy !== ''} onClick={() => clone(item)}>{t.clone}</button><button className="button button--secondary" type="button" disabled={busy !== ''} onClick={() => void openVersions(item)}>{t.loadVersions}</button>{item.scope === 'system' ? <p className="dashboard-manager-note">{t.systemProtected}</p> : <button className="button button--danger" type="button" disabled={busy !== ''} onClick={() => setConfirmArchive(item.id)}>{t.archive}</button>}</section>{confirmArchive === item.id && <div className="dashboard-manager-confirm" role="alert"><strong>{t.archiveConfirm}</strong><p>{t.archiveConfirmDetail}</p><button className="button button--secondary" type="button" onClick={() => setConfirmArchive(null)}>{t.cancel}</button><button className="button button--danger" type="button" disabled={busy !== ''} onClick={() => archive(item)}>{t.confirmArchive}</button></div>}{versionDashboard === item.id && <section className="dashboard-version-panel" aria-label={t.versions}>{versionsLoading ? <p role="status">{t.loading}</p> : versions.length === 0 ? <p>{t.noVersions}</p> : <ol>{versions.map((version) => <li key={version.id}><span><strong>v{version.number}{version.number === item.currentVersion ? ` · ${t.currentVersion}` : ''}</strong><small>{version.createdAt ? formatDateTime(version.createdAt) : ''} · {version.changeSummary || '—'} · {t.changedBy} {version.createdBy || t.unknownActor}</small></span>{version.number !== item.currentVersion && <button className="button button--secondary" type="button" disabled={busy !== ''} onClick={() => restore(item, version.number)}>{t.restore}</button>}</li>)}</ol>}</section>}</div>}</li>;
return <section className="dashboard-manager" aria-labelledby="dashboard-manager-title"><header className="page-intro dashboard-manager-header"><div><p className="eyebrow">{t.eyebrow}</p><h1 id="dashboard-manager-title">{t.title}</h1><p className="intro">{t.intro}</p></div>{canEdit && <button className="button" type="button" onClick={() => setCreateOpen((value) => !value)}>{createOpen ? t.cancel : t.create}</button>}</header>{!canEdit && <div className="card dashboard-manager-viewer" role="status"><strong>{t.viewerTitle}</strong><p>{t.viewerDetail}</p></div>}{canEdit && createOpen && <form className="card dashboard-create-panel" onSubmit={(event) => { event.preventDefault(); create(); }}><h2>{t.createTitle}</h2><div className="dashboard-create-grid"><label>{t.name}<input autoFocus value={name} maxLength={120} onChange={(event) => setName(event.target.value)} /></label><label>{t.scope}<select value={scope} onChange={(event) => setScope(event.target.value as 'personal' | 'shared')}><option value="personal">{t.personal}</option><option value="shared">{t.shared}</option></select></label><label>{t.template}<select value={template} onChange={(event) => setTemplate(event.target.value as DashboardTemplateId)}><option value="operations">{t.templateOperations}</option><option value="host">Host overview</option><option value="storage">{t.templateStorage}</option><option value="workloads">Workloads</option><option value="services">Services</option><option value="incidents">Incident response</option><option value="capacity">Capacity planning</option><option value="wallboard">NOC wallboard</option><option value="blank">{t.templateBlank}</option></select></label><label className="dashboard-create-description">{t.description}<textarea value={description} maxLength={500} rows={3} onChange={(event) => setDescription(event.target.value)} /></label></div><button className="button" type="submit" disabled={busy !== ''}>{busy === 'create' ? t.creating : t.createAction}</button></form>}{message && <p className="editor-message dashboard-manager-message" role="status">{message}</p>}<section className="card dashboard-manager-list" aria-labelledby="dashboard-active-title"><div className="card-heading"><div><p className="card-kicker">{t.active}</p><h2 id="dashboard-active-title">{active.length} {t.active.toLowerCase()}</h2></div>{archived.length > 0 && <button className="button button--secondary" type="button" onClick={() => setShowArchived((value) => !value)}>{showArchived ? t.hideArchived : `${t.showArchived} (${archived.length})`}</button>}</div>{active.length ? <ul>{active.map(renderDashboard)}</ul> : <p className="card-copy">{t.noActive}</p>}</section>{showArchived && <section className="card dashboard-manager-list dashboard-manager-list--archived" aria-labelledby="dashboard-archived-title"><div className="card-heading"><div><p className="card-kicker">{t.archived}</p><h2 id="dashboard-archived-title">{archived.length} {t.archived.toLowerCase()}</h2></div></div>{archived.length ? <ul>{archived.map(renderDashboard)}</ul> : <p className="card-copy">{t.noArchived}</p>}</section>}</section>;
}
+148 -69
View File
@@ -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<string, unknown>;
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<T>(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 <span className={'status-badge status-badge--' + (usable ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{usable ? '✓' : '?'}</span>{label}</span>;
const labels: Record<RuntimeState, string> = {
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 <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{labels[state]}</span>;
}
type Resource = { state: RuntimeState; items?: StatusGridItem[]; storage?: StorageData; events?: EventItem[]; error?: string };
type EventItem = { id: string; type: string; severity: string; summary: string; occurredAt: string; sourceId?: string };
function useInventoryResource(widget: RuntimeWidget): Resource {
const [resource, setResource] = useState<Resource>({ state: 'loading' });
const sourceType = String(field(widget.data, 'sourceType') ?? '');
const scope = field<RecordValue>(widget.data, 'scope') ?? {};
const entityType = String(field(scope, 'entityType') ?? '');
const isApplications = sourceType === 'inventory' && entityType === 'application';
const isStorage = sourceType === 'inventory' && Array.isArray(field(scope, 'entityTypes'));
const isEvents = sourceType === 'events';
const interval = Math.min(300, Math.max(5, Number(field(widget.behavior, 'liveIntervalSeconds') ?? 15))) * 1000;
useEffect(() => {
if (!isApplications && !isStorage && !isEvents) {
setResource({ state: 'empty' });
return undefined;
}
let active = true;
let controller: AbortController | null = null;
const read = async <T,>(url: string, signal: AbortSignal): Promise<T> => {
async function readJSON<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
};
}
function useBoundedResource(widget: RuntimeWidget): Resource {
const sourceType = String(field(widget.data, 'sourceType') ?? '');
const scope = field<RecordValue>(widget.data, 'scope') ?? {};
const entityType = String(field(scope, 'entityType') ?? '');
const entityTypes = field<unknown[]>(scope, 'entityTypes');
const limit = safeLimit(widget);
const interval = safeInterval(widget);
const rawText = field<unknown>(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<Resource>(() => sourceType === 'text' ? { state: text.trim() ? 'usable' : 'empty', text } : { state: 'loading' });
useEffect(() => {
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 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<StorageData['array']>('/api/v1/array', current.signal),
read<StorageData['disks']>('/api/v1/disks?limit=100', current.signal),
read<StorageData['pools']>('/api/v1/pools?limit=100', current.signal),
readJSON<StorageData['array']>('/api/v1/array', current.signal),
readJSON<StorageData['disks']>('/api/v1/disks?limit=' + limit, current.signal),
readJSON<StorageData['pools']>('/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<RecordValue> }>('/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<Array<RecordValue>>(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<RecordValue>; 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<number>(item, 'responseTimeMs') == null ? '—' : `${field<number>(item, 'responseTimeMs')} ms`, detail: field<number>(item, 'availabilityPercent') == null ? stringValue(item.reason) : `${field<number>(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<RecordValue>; 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<number>(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<RecordValue> }>('/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<RecordValue> }>('/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 <div className={'dashboard-runtime-state dashboard-runtime-state--' + state} role={state === 'error' ? 'alert' : 'status'}><strong>{title}</strong><span>{detail}</span></div>;
}
function RuntimeTable({ rows, service = false }: { rows: RuntimeRow[]; service?: boolean }) {
return <div className="dashboard-runtime-table-wrap" tabIndex={0} aria-label={`${rows.length} ${copy.dashboards.runtime.rows}`}><table className="dashboard-runtime-table"><caption className="sr-only">{rows.length} {copy.dashboards.runtime.rows}</caption><thead><tr><th scope="col">Naam</th><th scope="col">Status</th><th scope="col">{service ? 'Respons / beschikbaarheid' : 'Waarde'}</th></tr></thead><tbody>{rows.slice(0, 100).map((row) => <tr key={row.id}><th scope="row">{row.label}<small>{row.detail}</small></th><td>{presentStatus(row.status)}</td><td>{row.value || '—'}</td></tr>)}</tbody></table></div>;
}
function AlertSummary({ rows }: { rows: RuntimeRow[] }) {
const count = (severity: string) => rows.filter((row) => row.severity === severity && row.status !== 'resolved').length;
return <div className="dashboard-alert-summary"><dl><div><dt>Kritiek</dt><dd>{count('critical')}</dd></div><div><dt>Waarschuwing</dt><dd>{count('warning')}</dd></div><div><dt>Actief</dt><dd>{rows.filter((row) => row.status !== 'resolved').length}</dd></div><div><dt>Bevestigd</dt><dd>{rows.filter((row) => row.acknowledged).length}</dd></div></dl><RuntimeTable rows={rows.slice(0, 10)} /></div>;
}
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(<a key={match.index} href={match[2]} rel={match[2].startsWith('https://') ? 'noreferrer' : undefined}>{match[1]}</a>); cursor = match.index + match[0].length; }
if (cursor < value.length) parts.push(value.slice(cursor));
return parts;
}
function TextWidget({ text }: { text: string }) { return <div className="dashboard-text-widget">{text.slice(0, 5000).split(/\r?\n/).slice(0, 100).filter(Boolean).map((line, index) => <p key={index}>{safeInline(line.replace(/^#{1,3}\s+/, '').replace(/^[-*]\s+/, '• '))}</p>)}</div>; }
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<MetricQueryRequest | null>(() => {
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<RecordValue>(widget.data, 'scope') ?? {}, field<unknown[]>(document, 'variables') ?? []);
return { metric, scope, range, aggregation: String(field(widget.data, 'aggregation') ?? 'avg') };
}, [semantic, widget.data, document, range]);
return { metric, scope: resolveDashboardScope(field<RecordValue>(widget.data, 'scope') ?? {}, field<unknown[]>(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<RecordValue>(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<RecordValue>(widget.layouts, viewport) ?? field<RecordValue>(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 = <MetricWidget {...metric} />;
} else if (systemWidget && system.state === 'ready') {
content = <div className="dashboard-runtime-stat"><strong>{systemStatus.label}</strong><span>{systemStatus.detail}</span><small>{system.status?.components.length ?? 0} {copy.dashboards.runtime.checkedComponents}</small></div>;
} else if (inventory.items?.length) {
content = <StatusGridWidget items={inventory.items} onSelect={onFilter} />;
} else if (inventory.storage) {
content = <StorageMapWidget nodes={buildStorageNodes(inventory.storage)} title={widget.title} description={widget.description ?? ''} idPrefix={'dashboard-storage-' + widget.id} />;
} else if (inventory.events?.length) {
content = <ol className="dashboard-event-list">{inventory.events.slice(0, viewport === 'mobile' ? 6 : 12).map((event) => <li key={event.id}><span className={'event-severity event-severity--' + event.severity} aria-hidden="true" /><span><strong>{presentEventSummary(event.type, event.summary)}</strong><small>{presentEventType(event.type)} · {formatDateTime(event.occurredAt)}</small></span></li>)}</ol>;
} else {
content = <div className={'dashboard-runtime-state dashboard-runtime-state--' + runtime} role={runtime === 'error' ? 'alert' : 'status'}><strong>{runtime === 'error' ? copy.dashboards.runtime.sourceUnavailable : runtime === 'loading' ? copy.dashboards.runtime.telemetryLoading : copy.dashboards.runtime.noCurrentData}</strong><span>{runtime === 'error' ? copy.dashboards.runtime.unavailableDetail : copy.dashboards.runtime.retryDetail}</span></div>;
}
return <article className="widget-card widget-card--runtime" data-runtime-state={runtime} data-viewport={viewport} style={layoutStyle} aria-labelledby={'widget-' + widget.id}><div className="widget-card-heading"><div><p className="card-kicker">{sourceType === 'semantic-metric' ? copy.dashboards.runtime.semanticMetric : sourceType === 'events' ? copy.dashboards.runtime.events : copy.dashboards.runtime.inventory}</p><h3 id={'widget-' + widget.id}>{widget.title}</h3></div><Badge state={runtime} /></div>{widget.description && <p className="widget-description">{widget.description}</p>}{content}</article>;
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 = <div className="dashboard-runtime-state dashboard-runtime-state--error" role="alert"><strong>{copy.dashboards.runtime.unknownType}</strong><span>{widget.type.slice(0, 80)}</span></div>;
else if (semantic && metricTypes.has(widget.type)) content = <MetricWidget kind={widget.type as MetricWidgetProps['kind']} series={timeRange === 'live' ? live.series : historicalSeries} freshness={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} />;
else if (systemWidget && system.state === 'ready') content = <div className="dashboard-runtime-stat"><strong>{systemStatus.label}</strong><span>{systemStatus.detail}</span><small>{system.status?.components.length ?? 0} {copy.dashboards.runtime.checkedComponents}</small></div>;
else if (resource.storage && widget.type === 'storage-map') content = <StorageMapWidget nodes={buildStorageNodes(resource.storage)} title={widget.title} description={widget.description ?? ''} idPrefix={'dashboard-storage-' + widget.id} />;
else if (widget.type === 'text' && resource.text?.trim()) content = <TextWidget text={resource.text} />;
else if (widget.type === 'alert-summary' && rows.length) content = <AlertSummary rows={rows} />;
else if (widget.type === 'service-matrix' && rows.length) content = <RuntimeTable rows={rows} service />;
else if (widget.type === 'table' && rows.length) content = <RuntimeTable rows={rows} />;
else if (widget.type === 'ranked-list' && rows.length) content = <RankedListWidget items={rows.map((row): RankedListItem => ({ 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 = <StatusGridWidget items={rows.map((row): StatusGridItem => ({ id: row.id, label: row.label, status: row.status, reason: row.detail }))} onSelect={onFilter} />;
else if (widget.type === 'stat' && rows.length) content = <div className="dashboard-runtime-stat"><strong>{rows.length}</strong><span>{sourceLabel(sourceType)}</span><small>{rows.filter((row) => !['healthy', 'up', 'operational', 'resolved'].includes(row.status)).length} vragen aandacht</small></div>;
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 = <ol className="dashboard-event-list">{events.slice(0, viewport === 'mobile' ? 6 : 12).map((event) => <li key={event.id}><span className={'event-severity event-severity--' + event.severity} aria-hidden="true" /><span><strong>{sourceType === 'events' ? presentEventSummary(event.type, event.summary) : event.summary}</strong><small>{sourceType === 'events' ? presentEventType(event.type) : sourceLabel(sourceType)}{event.occurredAt ? ' · ' + formatDateTime(event.occurredAt) : ''}</small></span></li>)}</ol>; }
else content = <RuntimeNotice state={runtime === 'usable' || runtime === 'partial' || runtime === 'stale' ? 'capability-disabled' : runtime} />;
return <article className="widget-card widget-card--runtime" data-runtime-state={runtime} data-viewport={viewport} style={layoutStyle} aria-labelledby={'widget-' + widget.id}><div className="widget-card-heading"><div><p className="card-kicker">{sourceLabel(sourceType)} · {widgetLabel(widget.type)}</p><h3 id={'widget-' + widget.id}>{widget.title}</h3></div><Badge state={runtime} /></div>{widget.description && <p className="widget-description">{widget.description}</p>}{content}</article>;
}
+1 -1
View File
@@ -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); };
+130
View File
@@ -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<CatalogResponse | null>(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<string[]>(() => initialExploreMetrics());
const [results, setResults] = useState<Result[]>([]);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
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<Record<string, unknown>> }>) => {
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 <section className="explore-page" aria-labelledby="explore-title">
<header className="page-intro"><p className="eyebrow">{copy.explore.eyebrow}</p><h1 id="explore-title">{copy.explore.title}</h1><p className="intro">{copy.explore.intro}</p></header>
<div className="explore-mode-tabs" role="tablist" aria-label={copy.explore.workspaces}>{(['explore', 'compare', 'history'] as const).map((item) => <button key={item} type="button" role="tab" aria-selected={mode === item} onClick={() => changeMode(item)}>{copy.explore[item]}</button>)}</div>
<div className="explore-layout">
<aside className="card explore-catalog" aria-labelledby="metric-catalog-title"><div className="card-heading"><div><p className="card-kicker">{copy.explore.catalog}</p><h2 id="metric-catalog-title">{copy.explore.findMetric}</h2></div><span>{visible.length}/{catalog?.metrics.length ?? 0}</span></div><label>{copy.explore.search}<input value={search} maxLength={160} placeholder={copy.explore.searchPlaceholder} onChange={(event) => setSearch(event.target.value)} /></label>{catalogState === 'loading' ? <p role="status">{copy.explore.loading}</p> : catalogState === 'error' ? <p role="alert">{copy.explore.catalogError}</p> : visible.length === 0 ? <p>{copy.explore.empty}</p> : <ul className="explore-metric-list">{visible.map((metric) => { const state = bindings.get(metric.semanticName) ?? 'unavailable'; const active = selected.includes(metric.semanticName); return <li key={metric.semanticName}><button type="button" aria-pressed={active} disabled={state !== 'supported'} onClick={() => choose(metric)}><span><strong>{metric.semanticName}</strong><small>{metric.description}</small></span><span>{state === 'supported' ? metric.unit || metric.valueKind : copy.explore.unavailable}</span></button></li>; })}</ul>}</aside>
<main className="explore-workspace"><section className="card explore-range" aria-labelledby="explore-range-title"><div><p className="card-kicker">{copy.explore.range}</p><h2 id="explore-range-title">{mode === 'history' ? copy.explore.historyRange : copy.explore.queryRange}</h2></div><label>{copy.explore.range}<select value={operator.range} onChange={(event) => setOperatorRangePreset(event.target.value as OperatorRangePreset)}>{ranges.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>{operator.range === 'custom' && <div className="explore-custom-range"><label>{copy.operator.customFrom}<input type="datetime-local" value={operator.customFrom} onChange={(event) => setOperatorCustomRange(event.target.value, operator.customTo)} /></label><label>{copy.operator.customTo}<input type="datetime-local" value={operator.customTo} onChange={(event) => setOperatorCustomRange(operator.customFrom, event.target.value)} /></label></div>}</section>
{invalidCustom && <div className="editor-banner" role="alert"><strong>{copy.explore.invalidRange}</strong><span>{copy.operator.customInvalid}</span></div>}
{overBudget && <div className="editor-banner" role="alert"><strong>{copy.explore.budgetExceeded}</strong><span>{copy.explore.budgetDetail.replace('{metric}', overBudget.semanticName).replace('{days}', Math.floor(overBudget.limits.maxRangeSeconds / 86400).toString())}</span></div>}
{mode === 'compare' && selected.length >= 4 && <p className="metric-notice metric-notice--warning" role="status">{copy.explore.compareLimit}</p>}
{selected.length === 0 ? <section className="card dashboard-message"><h2>{copy.explore.selectTitle}</h2><p>{copy.explore.selectDetail}</p></section> : <div className="explore-results" aria-live="polite">{results.map((result) => <article className="card explore-result" key={result.metric.semanticName}><div className="widget-card-heading"><div><p className="card-kicker">{result.metric.unit || result.metric.valueKind}</p><h2>{result.metric.semanticName}</h2></div><button type="button" className="text-action" onClick={() => setSelected((current) => current.filter((name) => name !== result.metric.semanticName))}>{copy.explore.closeMetric}</button></div><p className="card-copy">{result.metric.description}</p><MetricWidget kind="timeseries" series={result.series} freshness={result.freshness} expectedStepSeconds={range.stepSeconds} availability={result.status} error={result.error} visualization={{ unit: result.metric.unit, legend: true }} metricName={result.metric.semanticName} sourceObservedAt={result.response?.sourceObservedAt} receivedAt={result.response?.receivedAt} warnings={result.response?.warnings} inspector={result.response?.inspector} annotations={annotations} /></article>)}</div>}</main>
</div>
</section>;
}
+88 -8
View File
@@ -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<AlertDetail[]> {
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<Incident[]>([]);
const [incident, setIncident] = useState<Incident | null>(null);
const [alerts, setAlerts] = useState<Record<string, AlertDetail>>({});
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 <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>Incidenten laden…</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><h1>Geen toegang tot incidenten</h1><p>Je hebt geen rechten om incidentgegevens te bekijken.</p></section>;
if (state === 'error') return <section className="state-page" role="alert"><h1>Incidenten niet beschikbaar</h1><p>De incidentgegevens konden niet veilig worden geladen.</p><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>Opnieuw laden</button></section>;
if (!id && state === 'empty') return <><header className="page-intro"><p className="eyebrow">{copy.incidents.listEyebrow}</p><h1>{copy.incidents.listEyebrow}</h1><p className="intro">{copy.incidents.listIntro}</p></header><section className="card empty-state" aria-live="polite"><span className="empty-state-icon" aria-hidden="true">✓</span><h2>Geen open incidenten</h2><p>Er zijn momenteel geen open incidenten geregistreerd.</p></section></>;
if (!id) return <><header className="page-intro"><p className="eyebrow">{copy.incidents.listEyebrow}</p><h1>{copy.incidents.listEyebrow}</h1><p className="intro">{copy.incidents.listIntro}</p></header><section className="card incident-list-panel" aria-labelledby="incident-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.openIncidents}</p><h2 id="incident-list-title">{copy.incidents.listTitle}</h2></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">?</span>{items.length} incidenten</span></div><ul className="incident-list">{items.map((item) => <li className={'incident-list-row incident-list-row--' + item.severity} key={item.id}><button className="incident-list-item" type="button" onClick={() => navigate('/incidents/' + encodeURIComponent(item.id))}><span><strong>{item.title}</strong><small>{severityLabel(item.severity)} · {item.summary}</small></span><span className="incident-list-meta"><span>{statusLabel(item.status)}</span><span>{copy.incidents.confidence}: {Math.round(item.confidence * 100)}%</span></span></button></li>)}</ul></section></>;
if (!incident) return null;
return <><button className="back-link" type="button" onClick={() => navigate('/incidents')}>← Terug naar incidenten</button><header className="page-intro incident-page-intro"><p className="eyebrow">{copy.incidents.detailEyebrow}</p><h1>{incident.title}</h1><p className="intro">{incident.summary}</p></header><section className={'incident-command-strip incident-command-strip--' + incident.severity} aria-label={copy.incidents.statusAndConfidence}><span><small>Ernst</small><strong>{severityLabel(incident.severity)}</strong></span><span><small>Status</small><strong>{statusLabel(incident.status)}</strong></span><span><small>{copy.incidents.started}</small><strong>{formatTime(incident.startedAt)}</strong></span><span><small>{copy.incidents.confidence}</small><strong>{Math.round(incident.confidence * 100)}%</strong></span></section><section className="incident-detail-grid"><article className="card incident-rationale"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.statusAndConfidence}</p><h2>{statusLabel(incident.status)}</h2></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">?</span>{severityLabel(incident.severity)}</span></div><dl className="incident-facts"><div><dt>{copy.incidents.started}</dt><dd>{formatTime(incident.startedAt)}</dd></div><div><dt>{copy.incidents.correlationMethod}</dt><dd>{incident.correlationMethod}</dd></div><div><dt>{copy.incidents.confidence}</dt><dd>{Math.round(incident.confidence * 100)}%</dd></div><div><dt>{copy.incidents.revision}</dt><dd>{incident.revision}</dd></div></dl><p className="uncertainty-note">Correlatie is een onderbouwde aanwijzing, geen bewezen causaliteit. Betrouwbaarheid beschrijft de correlatieregel, niet de zekerheid van de oorzaak.</p></article><article className="card incident-follow-up"><p className="card-kicker">{copy.incidents.ownership}</p><h2>{copy.incidents.followUp}</h2><label className="incident-field">{copy.incidents.ownerLabel}<input id="incident-owner" name="ownerUserId" value={owner} onChange={(event) => setOwner(event.target.value)} placeholder={copy.incidents.ownerPlaceholder} aria-label={copy.incidents.ownerLabel} /></label><button className="button" type="button" onClick={saveOwner}>{copy.incidents.saveOwner}</button><p className="card-copy">{copy.incidents.ownerNote}</p></article></section><section className="card incident-timeline-panel" aria-labelledby="incident-timeline-title"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.timeline}</p><h2 id="incident-timeline-title">{copy.incidents.signalsAndNotes}</h2></div><span className="card-copy">{timeline.length} {copy.incidents.timelineItems}</span></div>{timeline.length === 0 ? <p className="card-copy">{copy.incidents.noTimeline}</p> : <ol className="incident-timeline">{timeline.map((entry) => <li key={entry.id}><time dateTime={entry.at}>{formatTime(entry.at)}</time><div><strong>{entry.label}</strong><p>{entry.detail}</p></div></li>)}</ol>}</section><section className="card incident-notes-panel" aria-labelledby="incident-notes-title"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.notes}</p><h2 id="incident-notes-title">{copy.incidents.operatorContext}</h2></div></div><label className="incident-field">{copy.incidents.newNote}<textarea id="incident-note" name="note" value={note} onChange={(event) => setNote(event.target.value)} maxLength={2000} rows={4} aria-describedby="incident-note-help" /></label><p id="incident-note-help" className="card-copy">{copy.incidents.noteHelp}</p><button className="button" type="button" onClick={submitNote} disabled={!note.trim()}>{copy.incidents.addNote}</button>{message && <p className="editor-message" role="status">{message}</p>}</section><section className="card incident-workflow-placeholder" aria-labelledby="incident-workflow-title"><p className="card-kicker">{copy.incidents.externalWorkflow}</p><h2 id="incident-workflow-title">{copy.incidents.workflowReady}</h2><p className="card-copy">{copy.incidents.workflowIntro}</p><input aria-label={copy.incidents.workflowPlaceholder} placeholder={copy.incidents.workflowPlaceholder} disabled /></section></>;
return <><button className="back-link" type="button" onClick={() => navigate('/incidents')}>← Terug naar incidenten</button><header className="page-intro incident-page-intro"><p className="eyebrow">{copy.incidents.detailEyebrow}</p><h1>{incident.title}</h1><p className="intro">{incident.summary}</p></header><section className={'incident-command-strip incident-command-strip--' + incident.severity} aria-label={copy.incidents.statusAndConfidence}><span><small>Ernst</small><strong>{severityLabel(incident.severity)}</strong></span><span><small>Status</small><strong>{statusLabel(incident.status)}</strong></span><span><small>{copy.incidents.started}</small><strong>{formatDateTime(incident.startedAt)}</strong></span><span><small>{copy.incidents.confidence}</small><strong>{Math.round(incident.confidence * 100)}%</strong></span><div className="incident-command-actions">{canOperate && sessionSubject && incident.ownerUserId !== sessionSubject && <button className="button button--secondary" type="button" onClick={assignToMe}>Aan mij toewijzen</button>}{canOperate && Object.values(alerts).some((alert) => !alert.acknowledgedBy && alert.state !== 'acknowledged' && alert.state !== 'resolved') && <button className="button button--secondary" type="button" disabled={Boolean(actingAlert)} onClick={() => void acknowledgeAll()}>{actingAlert === 'all' ? 'Meldingen erkennen…' : 'Alle meldingen erkennen'}</button>}<button className="button button--secondary" type="button" onClick={openHistory}>Onderzoek in Explore</button></div></section><section className="incident-detail-grid"><article className="card incident-rationale"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.statusAndConfidence}</p><h2>{statusLabel(incident.status)}</h2></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">?</span>{severityLabel(incident.severity)}</span></div><dl className="incident-facts"><div><dt>{copy.incidents.started}</dt><dd>{formatDateTime(incident.startedAt)}</dd></div><div><dt>{copy.incidents.correlationMethod}</dt><dd>{incident.correlationMethod}</dd></div><div><dt>{copy.incidents.confidence}</dt><dd>{Math.round(incident.confidence * 100)}%</dd></div><div><dt>{copy.incidents.revision}</dt><dd>{incident.revision}</dd></div></dl><p className="uncertainty-note">Correlatie is een onderbouwde aanwijzing, geen bewezen causaliteit. Betrouwbaarheid beschrijft de correlatieregel, niet de zekerheid van de oorzaak.</p></article><article className="card incident-follow-up"><p className="card-kicker">{copy.incidents.ownership}</p><h2>{copy.incidents.followUp}</h2><label className="incident-field">{copy.incidents.ownerLabel}<input id="incident-owner" name="ownerUserId" value={owner} onChange={(event) => setOwner(event.target.value)} placeholder={copy.incidents.ownerPlaceholder} aria-label={copy.incidents.ownerLabel} /></label><button className="button" type="button" onClick={() => void saveOwner()} disabled={!canOperate}>{copy.incidents.saveOwner}</button><p className="card-copy">{canOperate ? copy.incidents.ownerNote : 'Je rol kan de opvolgcontext alleen bekijken.'}</p></article></section>
<section className="card incident-alerts-panel" aria-labelledby="incident-alerts-title"><div className="card-heading"><div><p className="card-kicker">Bijdragende signalen</p><h2 id="incident-alerts-title">Meldingen in dit incident</h2></div><span className="card-copy">{incident.alerts?.length ?? 0} meldingen</span></div>{!incident.alerts?.length ? <p className="card-copy">Geen gekoppelde meldingen.</p> : <ul className="incident-alert-list">{incident.alerts.slice(0, 100).map((association) => { const alert = alerts[association.alertId]; const actionable = alert && !alert.acknowledgedBy && alert.state !== 'acknowledged' && alert.state !== 'resolved'; return <li key={association.alertId}><div><strong>{alert?.ruleName || association.alertId}</strong><small>{alert ? `${presentStatus(alert.state)} · ${alert.reason}` : association.rationale}</small><small>Relatie: {association.rationale} · {Math.round(association.confidence * 100)}%</small></div>{alert && <div className="incident-alert-action"><span className="status-badge status-badge--unknown">{presentStatus(alert.state)}</span>{actionable && canOperate && <button className="button button--secondary" type="button" disabled={actingAlert === alert.id} onClick={() => void acknowledge(alert)}>{actingAlert === alert.id ? 'Erkennen…' : 'Melding erkennen'}</button>}</div>}</li>; })}</ul>}<p className="card-copy incident-read-only-boundary">Erkennen bevestigt alleen de operationele opvolging. Pulse start, stopt of repareert geen Unraidresource.</p></section>
<section className="card incident-timeline-panel" aria-labelledby="incident-timeline-title"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.timeline}</p><h2 id="incident-timeline-title">{copy.incidents.signalsAndNotes}</h2></div><span className="card-copy">{timeline.length} {copy.incidents.timelineItems}</span></div>{timeline.length === 0 ? <p className="card-copy">{copy.incidents.noTimeline}</p> : <ol className="incident-timeline">{timeline.map((entry) => <li key={entry.id}><time dateTime={entry.at}>{formatDateTime(entry.at)}</time><div><strong>{entry.label}</strong><p>{entry.detail}</p></div></li>)}</ol>}</section><section className="card incident-notes-panel" aria-labelledby="incident-notes-title"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.notes}</p><h2 id="incident-notes-title">{copy.incidents.operatorContext}</h2></div></div><label className="incident-field">{copy.incidents.newNote}<textarea id="incident-note" name="note" value={note} onChange={(event) => setNote(event.target.value)} maxLength={2000} rows={4} aria-describedby="incident-note-help" /></label><p id="incident-note-help" className="card-copy">{copy.incidents.noteHelp}</p><button className="button" type="button" onClick={submitNote} disabled={!note.trim() || !canOperate}>{copy.incidents.addNote}</button>{message && <p className="editor-message" role="status">{message}</p>}</section><section className="card incident-workflow-placeholder" aria-labelledby="incident-workflow-title"><p className="card-kicker">{copy.incidents.externalWorkflow}</p><h2 id="incident-workflow-title">{copy.incidents.workflowReady}</h2><p className="card-copy">{copy.incidents.workflowIntro}</p><input aria-label={copy.incidents.workflowPlaceholder} placeholder={copy.incidents.workflowPlaceholder} disabled /></section></>;
}
+41 -6
View File
@@ -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<string, unknown>): 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<string, unknown>, 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' ? <StatWidget props={props} series={series} /> : props.kind === 'gauge' ? <GaugeWidget props={props} series={series} /> : props.kind === 'query-inspector' ? <QueryInspectorWidget props={props} /> : <TimeSeriesWidget props={props} series={series} />;
return <div className="metric-widget" data-widget-kind={props.kind}>{props.warnings && props.warnings.slice(0, 10).map((warning) => <p className="metric-notice metric-notice--warning" role="status" key={warning}>{copy.metrics.sourceWarning}: {warning.slice(0, 240)}</p>)}{content}{gap && props.kind !== 'timeseries' && <MetricNotice tone="warning">{copy.metrics.gapNotice}</MetricNotice>}{(props.freshness === 'stale' || props.freshness === 'unavailable') && <MetricNotice tone="warning">{copy.metrics.sourceStatus}: {freshnessLabel(props.freshness)}. {copy.metrics.notHealthy}</MetricNotice>}</div>;
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const [zoomed, setZoomed] = useState(false);
const [cursorRatio, setCursorRatio] = useState<number | null>(null);
const cursorFrame = useRef<number | null>(null);
const pendingCursor = useRef<number | null>(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<HTMLDivElement>) => {
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' ? <StatWidget props={props} series={displaySeries} /> : props.kind === 'gauge' ? <GaugeWidget props={props} series={displaySeries} /> : props.kind === 'query-inspector' ? <QueryInspectorWidget props={props} /> : <TimeSeriesWidget props={props} series={displaySeries} />;
return <div className="metric-widget" data-widget-kind={props.kind}>{props.warnings && props.warnings.slice(0, 10).map((warning) => <p className="metric-notice metric-notice--warning" role="status" key={warning}>{copy.metrics.sourceWarning}: {warning.slice(0, 240)}</p>)}{(props.metricName || (props.kind === 'timeseries' && series.length > 0)) && <div className="metric-interaction-toolbar">{props.kind === 'timeseries' && series.length > 0 && <div className="metric-series-toggles" aria-label={copy.metrics.seriesVisibility}>{series.map((item) => <button type="button" key={item.key} aria-pressed={!hidden.has(item.key)} onClick={() => 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)}</button>)}</div>}<div className="metric-toolbar-actions">{props.kind === 'timeseries' && series.length > 0 && <button className="button button--secondary" type="button" aria-pressed={zoomed} onClick={() => setZoomed((value) => !value)}>{zoomed ? copy.metrics.resetZoom : copy.metrics.zoomRecent}</button>}{props.metricName && <button className="button button--secondary" type="button" onClick={() => openMetricInExplore(props.metricName!)}>Open in Explore</button>}</div></div>}<div className={props.kind === 'timeseries' ? 'metric-interactive-plane' : undefined} style={cursorRatio == null ? undefined : { '--metric-crosshair': `${cursorRatio * 100}%` } as CSSProperties} onPointerMove={props.kind === 'timeseries' ? onPointerMove : undefined} onPointerLeave={() => setCursorRatio(null)}>{content}{cursor && <output className="metric-crosshair-output">{new Date(cursor.timestamp).toLocaleString('nl-BE')} · {cursor.value == null ? '—' : cursor.value.toLocaleString('nl-BE')}</output>}</div>{props.kind === 'timeseries' && props.annotations && props.annotations.length > 0 && <ol className="metric-annotations" aria-label={copy.metrics.annotations}>{props.annotations.slice(0, 40).map((annotation) => <li key={annotation.id} data-tone={annotation.tone}><time dateTime={new Date(annotation.timestamp).toISOString()}>{new Date(annotation.timestamp).toLocaleString('nl-BE')}</time><span>{annotation.label.slice(0, 160)}</span></li>)}</ol>}{gap && props.kind !== 'timeseries' && <MetricNotice tone="warning">{copy.metrics.gapNotice}</MetricNotice>}{(props.freshness === 'stale' || props.freshness === 'unavailable') && <MetricNotice tone="warning">{copy.metrics.sourceStatus}: {freshnessLabel(props.freshness)}. {copy.metrics.notHealthy}</MetricNotice>}</div>;
}
export type RankedListItem = { id: string; label: string; value: string; detail?: string };
export type StatusGridItem = { id: string; label: string; status: string; reason?: string };
+229
View File
@@ -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<ShellData>({ alerts: 0, incidents: 0, loading: true, unavailable: false });
useEffect(() => {
const controller = new AbortController();
setData((current) => ({ ...current, loading: true }));
const read = async <T,>(url: string): Promise<T> => {
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<T>;
};
Promise.all([
read<HostSnapshot>('/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<HTMLInputElement>(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 <header className="operator-command-bar" aria-label={copy.operator.commandBar}>
<div className="operator-identity"><span className="operator-server-mark" aria-hidden="true">P</span><span className="operator-server-copy"><small>{copy.operator.server}{shell.environment ? ` · ${shell.environment}` : ''}</small><strong>{serverName}</strong></span><span className="operator-divider" aria-hidden="true" /><span className="operator-route"><small>{copy.operator.currentPage}</small><strong>{routeLabel}</strong></span></div>
<div className="operator-truth" aria-label={copy.operator.operationalTruth}><span className={'operator-truth-item ' + (transportConnected ? 'operator-truth-item--ready' : '')}><span aria-hidden="true">●</span><span><small>{copy.operator.transport}</small><strong>{transportLabel}</strong></span></span><span className="operator-truth-item"><span aria-hidden="true">◉</span><span><small>{copy.operator.sources}</small><strong>{snapshot.state === 'ready' && sourceTotal > 0 ? `${healthySources}/${sourceTotal} ${copy.operator.sourcesCurrent}` : copy.operator.sourcesUnknown}</strong></span></span></div>
<div className="operator-controls">
<label className="operator-range"><span>{copy.operator.range}</span><select value={operator.range} onChange={(event) => selectRange(event.target.value as OperatorRangePreset)}>{rangeOptions.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
{operator.range === 'custom' && customOpen && <div className="operator-custom-range" role="group" aria-label={copy.operator.rangeCustom}><label><span>{copy.operator.customFrom}</span><input type="datetime-local" value={operator.customFrom} onChange={(event) => setOperatorCustomRange(event.target.value, operator.customTo)} /></label><label><span>{copy.operator.customTo}</span><input type="datetime-local" value={operator.customTo} onChange={(event) => setOperatorCustomRange(operator.customFrom, event.target.value)} /></label>{!operatorCustomRangeValid(operator) && <p role="alert">{copy.operator.customInvalid}</p>}<button type="button" className="operator-icon-button" onClick={() => setCustomOpen(false)} aria-label={copy.operator.close}>×</button></div>}
<button type="button" className={'operator-icon-button' + (operator.paused ? ' operator-icon-button--active' : '')} onClick={() => 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}><span aria-hidden="true">{operator.paused ? '▶' : 'Ⅱ'}</span></button>
<button type="button" className="operator-icon-button" onClick={refreshAll} aria-label={copy.operator.refresh} title={copy.operator.refresh}>↻</button>
<button type="button" className="operator-search-trigger" onClick={() => { setSearchOpen(true); window.setTimeout(() => searchInput.current?.focus(), 0); }} aria-label={copy.operator.search}><span aria-hidden="true">⌕</span><span>{copy.operator.search}</span><kbd>{copy.operator.searchHint}</kbd></button>
<button type="button" className="operator-counter" onClick={() => onNavigate('/alerts')} aria-label={`${copy.operator.alerts}: ${shell.unavailable ? copy.systemStatus.unknown : shell.alerts}`}><small>{copy.operator.alerts}</small><strong>{shell.unavailable ? '—' : boundedCount(shell.alerts)}</strong></button>
<button type="button" className="operator-counter" onClick={() => onNavigate('/incidents')} aria-label={`${copy.operator.incidents}: ${shell.unavailable ? copy.systemStatus.unknown : shell.incidents}`}><small>{copy.operator.incidents}</small><strong>{shell.unavailable ? '—' : boundedCount(shell.incidents)}</strong></button>
<span className="operator-filter-state" title={activeFilters ? `${activeFilters} ${copy.operator.filters.toLocaleLowerCase('nl-BE')}` : copy.operator.noFilters}><small>{copy.operator.filters}</small><strong>{activeFilters || '—'}</strong></span>
<details className="operator-session"><summary><span aria-hidden="true">◎</span><span className="sr-only">{copy.operator.session}</span></summary><div className="operator-session-popover"><strong>{sessionLabel}</strong>{shell.subject && <small>{copy.operator.user}: {shell.subject}</small>}{shell.role && <small>{copy.operator.role}: {shell.role}</small>}{shell.environment && <small>{copy.operator.environment}: {shell.environment}</small>}{release && <small>{copy.operator.release} {release}</small>}{shell.updatedAt && <small>{copy.overview.generated}: {formatDateTime(shell.updatedAt)}</small>}<button type="button" onClick={() => onNavigate('/settings')}>{copy.operator.openSettings}</button>{snapshot.state === 'unauthorized' && <SignInButton />}</div></details>
<span className="sr-only" aria-live="polite">{aggregate.label}</span>
</div>
{searchOpen && <div className="operator-search-panel" role="dialog" aria-modal="false" aria-label={copy.operator.search}>
<div className="operator-search-input"><span aria-hidden="true">⌕</span><input ref={searchInput} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={copy.operator.searchPlaceholder} aria-label={copy.operator.search} /><button type="button" onClick={() => { setSearchOpen(false); setQuery(''); }} aria-label={copy.operator.close}>×</button></div>
<div className="operator-search-results" role="listbox" aria-label={copy.operator.searchResults}>
{!query.trim() && workspace.favorites.length > 0 && <><p className="operator-search-section-title">Favorieten</p>{workspace.favorites.map((item) => <button className="operator-workspace-row" type="button" role="option" aria-selected="false" key={item.id} onClick={() => openResult(item.path)}><span><small className="operator-workspace-kind">Favoriet</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}</>}
{!query.trim() && workspace.views.length > 0 && <><p className="operator-search-section-title">Opgeslagen views</p>{workspace.views.map((item) => <button className="operator-workspace-row" type="button" role="option" aria-selected="false" key={item.id} onClick={() => openResult(item.path)}><span><small className="operator-workspace-kind">View</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}</>}
{!query.trim() && workspace.recent.length > 0 && <><p className="operator-search-section-title">Recent</p>{workspace.recent.slice(0, 8).map((item) => <button className="operator-workspace-row" type="button" role="option" aria-selected="false" key={item.id} onClick={() => openResult(item.path)}><span><small className="operator-workspace-kind">Recent</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}</>}
{staticResults.map((item) => <button type="button" role="option" aria-selected="false" key={'route-' + item.path} onClick={() => openResult(item.path)}><span><small>{copy.operator.navigationResult}</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}
{search.items.map((item) => <button type="button" role="option" aria-selected="false" key={'entity-' + item.id} onClick={() => openResult(entityRoute(item))}><span><small>{copy.operator.entityResult}{item.entityType ? ` · ${item.entityType}` : ''}</small><strong>{entityLabel(item)}</strong></span><span aria-hidden="true">→</span></button>)}
{operationalSearch.items.map((item) => <button type="button" role="option" aria-selected="false" key={item.id} onClick={() => openResult(item.path)}><span><small>{item.kind === 'incident' ? 'Incident' : 'Melding'} · {item.detail}</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}
{(search.loading || operationalSearch.loading) && <p role="status">{copy.operator.searchLoading}</p>}
{!search.loading && !operationalSearch.loading && query.trim().length >= 2 && staticResults.length === 0 && search.items.length === 0 && operationalSearch.items.length === 0 && <p>{copy.operator.noSearchResults}</p>}
{!query.trim() && workspace.favorites.length === 0 && workspace.views.length === 0 && workspace.recent.length === 0 && <p>Open een pagina om recente context op te bouwen, of bewaar een favoriete view vanuit de contextbalk.</p>}
</div>
</div>}
</header>;
}
+18
View File
@@ -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<Props, State> {
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 <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.states.boundaryTitle}</h1><p>{copy.states.boundaryDetail}</p>{correlationId && <p className="inline-technical">{copy.states.correlationReference}: <code>{correlationId}</code></p>}<button className="button" type="button" onClick={() => window.location.reload()}>{copy.states.reloadPage}</button></section>;
}
}
+32
View File
@@ -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<string, unknown>;
const state = String(record.state ?? (record.status as Record<string, unknown> | undefined)?.state ?? (record.capability as Record<string, unknown> | 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<HostCapabilities>({ hardware: 'not-configured', ups: 'not-configured' });
useEffect(() => {
const controller = new AbortController();
requestJSON<Record<string, unknown>>('/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 <section className="settings-capabilities" aria-label="Capabilitystatus">
<article className="card settings-capability-card"><p className="card-kicker">Databronnen</p><h2>Bronstatus</h2>{snapshot.status?.sourceLag.length ? <ul className="compact-list">{snapshot.status.sourceLag.slice(0, 50).map((source) => <li key={source.sourceId}><strong>{presentComponent(source.sourceId)}</strong><span>{presentStatus(source.state)} · {presentReason(source.reason)}</span></li>)}</ul> : <p className="card-copy">Geen bronnen geconfigureerd of bronstatus niet beschikbaar.</p>}</article>
<article className="card settings-capability-card"><p className="card-kicker">Back-ups</p><h2>{presentStatus(backup.state)}</h2><p className="card-copy">{presentReason(backup.reason)}</p><a className="button button--secondary" href="/status">Open systeemstatus en back-up</a></article>
<article className="card settings-capability-card"><p className="card-kicker">Hardware-capabilities</p><h2>Host en UPS</h2><dl className="settings-capability-list"><div><dt>Hardwaretelemetrie</dt><dd>{capabilityLabel(host.hardware)}</dd></div><div><dt>UPS</dt><dd>{capabilityLabel(host.ups)}</dd></div></dl><p className="card-copy">Ontbrekende hardwaredata wordt niet als gezond geïnterpreteerd. Configuratie blijft in onboarding en de veilige collectorgrens.</p></article>
</section>;
}
+31
View File
@@ -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 <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : '?'}</span>{label}</span>; }
export function SettingsPage() {
const snapshot = useSystemStatus();
const [session, setSession] = useState<SessionCapabilities | null>(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 <><header className="page-intro"><p className="eyebrow">{copy.settings.eyebrow}</p><h1>{copy.settings.title}</h1><p className="intro">{copy.settings.intro}</p></header><section className="settings-overview card" aria-labelledby="settings-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.settings.current}</p><h2 id="settings-overview-title">{copy.settings.environment}</h2></div><StatusBadge label={status.label} tone={status.tone} /></div><div className="settings-facts-grid"><div className="setting-row"><span><strong>{copy.settings.source}</strong><small>{sourceDetail}</small></span><span>{connected}/{total || '—'}</span></div><div className="setting-row"><span><strong>Release</strong><small>{release?.commit ? release.commit.slice(0, 12) : 'Commit niet beschikbaar'}</small></span><strong>{release?.version || snapshot.status?.version || '—'}</strong></div><div className="setting-row"><span><strong>Sessie</strong><small>{session?.subject || 'Gebruiker niet beschikbaar'}</small></span><strong>{session?.role || 'Onbekend'}</strong></div><div className="setting-row"><span><strong>{copy.settings.language}</strong><small>{copy.settings.languageDetail}</small></span><strong>{copy.settings.languageValue}</strong></div></div></section><section className="card settings-access-panel" aria-labelledby="settings-access-title"><div className="card-heading"><div><p className="card-kicker">Access & RBAC</p><h2 id="settings-access-title">Jouw operationele rechten</h2></div><span className="status-badge status-badge--unknown">{session?.source === 'session' ? 'Session API' : 'Fallback'}</span></div><div className="settings-permission-grid"><span data-enabled={permissions?.view === true}>View <strong>{permissions?.view ? 'Ja' : 'Nee'}</strong></span><span data-enabled={permissions?.operate === true}>Operate <strong>{permissions?.operate ? 'Ja' : 'Nee'}</strong></span><span data-enabled={permissions?.edit === true}>Edit <strong>{permissions?.edit ? 'Ja' : 'Nee'}</strong></span><span data-enabled={permissions?.admin === true}>Admin <strong>{permissions?.admin ? 'Ja' : 'Nee'}</strong></span></div><p className="card-copy">Niet-beschikbare write-controls worden niet als werkende acties gepresenteerd. De Unraid-infrastructuurgrens blijft read-only.</p></section><section className="card settings-policy-panel" aria-labelledby="settings-policy-title"><div className="card-heading"><div><p className="card-kicker">Monitoring policy</p><h2 id="settings-policy-title">Serverbeheerde instellingen</h2></div></div><dl className="settings-capability-list"><div><dt>Retention</dt><dd>Serverbeheerd · geen veilige write-API</dd></div><div><dt>Collectorgrens</dt><dd>Read-only</dd></div><div><dt>Historische query's</dt><dd>Bounded semantic metrics</dd></div></dl></section><SettingsCapabilityPanel /><section className="settings-hub" aria-label={copy.settings.management}><h2 className="sr-only">{copy.settings.management}</h2>{groups.map((group) => <article className="card settings-hub-card" key={group.title}><p className="card-kicker">{copy.settings.management}</p><h3>{group.title}</h3><p className="card-copy">{group.detail}</p><ul>{group.links.map((link) => <li key={link.href}><a href={link.href} onClick={(event) => { event.preventDefault(); navigate(link.href); }}><span><strong>{link.title}</strong><small>{link.detail}</small></span><span className="settings-access">{link.access}<span aria-hidden="true">→</span></span></a></li>)}</ul></article>)}</section></>;
}
+14 -3
View File
@@ -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 | undefined>): 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<StorageData | null>(null);
useEffect(() => { const controller = new AbortController(); Promise.all([fetch('/api/v1/array', { signal: controller.signal }).then((response) => response.json() as Promise<ArraySnapshot>), fetch('/api/v1/disks?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise<DiskSnapshot>), fetch('/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise<PoolSnapshot>)]).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<ArraySnapshot>; }),
fetch('/api/v1/disks?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('disks'); return response.json() as Promise<DiskSnapshot>; }),
fetch('/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('pools'); return response.json() as Promise<PoolSnapshot>; }),
fetch('/api/v1/forecasts', { signal: controller.signal }).then((response) => response.ok ? response.json() as Promise<ForecastSnapshot> : 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 <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.storage.loading}</h1></section>;
if (state === 'error' || !data) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.storage.errorTitle}</h1><p>{copy.storage.errorDetail}</p></section>;
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 <><header className="page-intro"><p className="eyebrow">{copy.storage.eyebrow}</p><h1>{copy.storage.title}</h1><p className="intro">{copy.storage.intro}</p></header><section className="card container-summary" aria-labelledby="storage-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.storage.source}</p><h2 id="storage-summary-title">{copy.storage.sourceTitle}</h2><p className="card-copy">{copy.storage.accessible}</p></div></div><div className="storage-source-grid"><article><h3>{copy.storage.arraySource}</h3><SourceStatusDetails source={{ ...data.array.source, id: data.array.source?.id || 'array' }} /></article><article><h3>{copy.storage.diskSource}</h3><SourceStatusDetails source={{ ...data.disks.source, id: data.disks.source?.id || 'disks' }} /></article><article><h3>{copy.storage.poolSource}</h3><SourceStatusDetails source={{ ...data.pools.source, id: data.pools.source?.id || 'pools' }} /></article></div><p className="container-provenance">{nodes.length} {copy.storage.mapNodes} · {heatmap.length} {copy.storage.heatmapPoints}</p></section><StorageMapWidget nodes={nodes} title={copy.storage.mapTitle} description={copy.storage.mapDescription} /><TemperatureHeatmap points={heatmap} title={copy.storage.heatmapTitle} description={copy.storage.heatmapDescription} /></>;
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 <><header className="page-intro"><p className="eyebrow">{copy.storage.eyebrow}</p><h1>{copy.storage.title}</h1><p className="intro">{copy.storage.intro}</p></header><section className="storage-cockpit instrument-band" aria-label="Storage cockpit"><a className="instrument-cell" href="/array"><p>Array</p><strong>{signalLabel(data.array.state)}</strong><small>{data.array.members.length} members</small></a><a className="instrument-cell" href="/disks"><p>Disks met aandacht</p><strong>{diskProblems.length}</strong><small>{data.disks.disks.length} disks totaal</small></a><a className="instrument-cell" href={maxPool ? '/pools/' + encodeURIComponent(maxPool.id) : '/pools'}><p>Hoogste poolvulling</p><strong>{maxPool ? maxPool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'}</strong><small>{maxPool?.name ?? 'Geen pooldata'}</small></a><a className="instrument-cell" href="/capacity"><p>Capaciteitsprognose</p><strong>{earliest?.daysToCapacity !== undefined ? Math.max(0, Math.round(earliest.daysToCapacity)) + ' d' : '—'}</strong><small>{earliest ? earliest.name + ' · ' + earliest.confidence : 'Onvoldoende gekwalificeerde historie'}</small></a></section><nav className="storage-domain-actions" aria-label="Storage domeinen"><a href="/array">Array</a><a href="/disks">Disks</a><a href="/pools">Pools</a><a href="/shares">Shares</a><a href="/capacity">Capacity</a></nav><section className="card container-summary" aria-labelledby="storage-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.storage.source}</p><h2 id="storage-summary-title">{copy.storage.sourceTitle}</h2><p className="card-copy">{copy.storage.accessible}</p></div></div><div className="storage-source-grid"><article><h3>{copy.storage.arraySource}</h3><SourceStatusDetails source={{ ...data.array.source, id: data.array.source?.id || 'array' }} /></article><article><h3>{copy.storage.diskSource}</h3><SourceStatusDetails source={{ ...data.disks.source, id: data.disks.source?.id || 'disks' }} /></article><article><h3>{copy.storage.poolSource}</h3><SourceStatusDetails source={{ ...data.pools.source, id: data.pools.source?.id || 'pools' }} /></article></div><p className="container-provenance">{nodes.length} {copy.storage.mapNodes} · {heatmap.length} {copy.storage.heatmapPoints}</p></section><StorageMapWidget nodes={nodes} title={copy.storage.mapTitle} description={copy.storage.mapDescription} /><TemperatureHeatmap points={heatmap} title={copy.storage.heatmapTitle} description={copy.storage.heatmapDescription} /></>;
}
+23 -3
View File
@@ -34,7 +34,23 @@ function ServiceLink({ node }: { node: TopologyNode }) {
return <a className={nodeClass(node)} href={'/services/' + encodeURIComponent(node.id)}><strong>{node.label}</strong><small>{statusLabel(node.state)} · {node.id}</small></a>;
}
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
<div className="topology-layout">
<section aria-labelledby={compact ? 'topology-widget-nodes-compact' : 'topology-widget-nodes'}>
<h3 id={compact ? 'topology-widget-nodes-compact' : 'topology-widget-nodes'}>{copy.topology.nodesTitle}</h3>
{nodes.length === 0 ? <p className="card-copy">{copy.topology.noNodes}</p> : <ul className="topology-node-list">{nodes.map((node) => <li key={node.id}><ServiceLink node={node} /></li>)}</ul>}
{nodes.length === 0 ? <p className="card-copy">{copy.topology.noNodes}</p> : <ul className="topology-node-list">{nodes.map((node) => <li key={node.id}>{onSelectNode ? <button type="button" className={nodeClass(node) + (selectedId === node.id ? ' topology-node--selected' : '')} aria-pressed={selectedId === node.id} onClick={() => onSelectNode(node)}><strong>{node.label}</strong><small>{node.known ? statusLabel(node.state) + ' · ' + node.id : copy.topology.nodeUnknown}</small></button> : <ServiceLink node={node} />}</li>)}</ul>}
</section>
<section aria-labelledby={compact ? 'topology-widget-edges-compact' : 'topology-widget-edges'}>
<h3 id={compact ? 'topology-widget-edges-compact' : 'topology-widget-edges'}>{copy.topology.edgesTitle}</h3>
@@ -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 <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.topology.loading}</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">!</span><h1>{copy.topology.unauthorizedTitle}</h1><p>{copy.topology.unauthorizedDetail}</p></section>;
if (state === 'error' || !topology || !filtered) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.topology.errorTitle}</h1><p>{copy.topology.errorDetail}</p><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>{copy.topology.retry}</button></section>;
@@ -96,5 +116,5 @@ export function TopologyPage() {
const unavailable = topology.capabilityState === 'unavailable';
return <><header className="page-intro"><p className="eyebrow">{copy.topology.eyebrow}</p><h1>{copy.topology.title}</h1><p className="intro">{copy.topology.intro}</p></header><section className="card empty-state" aria-labelledby="topology-empty-title"><h2 id="topology-empty-title">{unavailable ? copy.topology.unavailable : copy.topology.notConfigured}</h2><p className="card-copy">{unavailable ? copy.topology.unavailableDetail : copy.topology.notConfiguredDetail}</p>{unavailable ? <button className="button button--secondary" type="button" onClick={() => setReload((value) => value + 1)}>{copy.topology.retry}</button> : <a className="button" href="/onboarding">{copy.topology.configure}</a>}</section></>;
}
return <><header className="page-intro"><p className="eyebrow">{copy.topology.eyebrow}</p><h1>{copy.topology.title}</h1><p className="intro">{copy.topology.intro}</p></header><section className="card topology-controls" aria-label={copy.topology.filters}><label>{copy.topology.search}<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={copy.topology.searchPlaceholder} /></label><label>{copy.topology.stateFilter}<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}><option value="all">{copy.topology.allStates}</option><option value="up">{copy.services.up}</option><option value="degraded">{copy.services.degraded}</option><option value="down">{copy.services.down}</option><option value="unknown">{copy.services.unknown}</option></select></label><label>{copy.topology.relationFilter}<select value={relationFilter} onChange={(event) => setRelationFilter(event.target.value)}><option value="all">{copy.topology.allRelations}</option><option value="depends_on">{copy.services.dependsOn}</option><option value="backs">{copy.services.backs}</option><option value="exposes">{copy.services.exposes}</option></select></label><span className="topology-filter-count" role="status">{filtered.nodes.length} {copy.topology.nodes} · {filtered.edges.length} {copy.topology.edges}</span></section><TopologyWidget topology={filtered} />{topology.truncated && <p className="topology-limit-note" role="status">{copy.topology.truncated}</p>}</>;
return <><header className="page-intro"><p className="eyebrow">{copy.topology.eyebrow}</p><h1>{copy.topology.title}</h1><p className="intro">{copy.topology.intro}</p></header><section className="card topology-controls" aria-label={copy.topology.filters}><label>{copy.topology.search}<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={copy.topology.searchPlaceholder} /></label><label>{copy.topology.stateFilter}<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}><option value="all">{copy.topology.allStates}</option><option value="up">{copy.services.up}</option><option value="degraded">{copy.services.degraded}</option><option value="down">{copy.services.down}</option><option value="unknown">{copy.services.unknown}</option></select></label><label>{copy.topology.relationFilter}<select value={relationFilter} onChange={(event) => setRelationFilter(event.target.value)}><option value="all">{copy.topology.allRelations}</option><option value="depends_on">{copy.services.dependsOn}</option><option value="backs">{copy.services.backs}</option><option value="exposes">{copy.services.exposes}</option></select></label><span className="topology-filter-count" role="status">{filtered.nodes.length} {copy.topology.nodes} · {filtered.edges.length} {copy.topology.edges}</span></section><div className="topology-investigation-layout"><TopologyWidget topology={filtered} selectedId={selectedId} onSelectNode={(node) => setSelectedId((current) => current === node.id ? '' : node.id)} />{selected && <aside className="card topology-inspector" aria-labelledby="topology-inspector-title"><div className="card-heading"><div><p className="card-kicker">Context inspector</p><h2 id="topology-inspector-title">{selected.label}</h2></div><span className={'status-badge status-badge--' + (selected.state === 'up' ? 'ready' : 'unknown')}>{statusLabel(selected.state)}</span></div><dl className="incident-facts"><div><dt>Type</dt><dd>{selected.kind || 'Onbekend'}</dd></div><div><dt>Bron</dt><dd>{selected.sourceId || copy.topology.sourceUnknown}</dd></div><div><dt>Identiteit</dt><dd>{selected.id}</dd></div><div><dt>Statusreden</dt><dd>{selected.reason || 'Geen extra reden aangeleverd'}</dd></div></dl><div className="topology-inspector-actions"><button className="button" type="button" onClick={() => navigate(nodeRoute(selected))}>Open detail</button><button className="button button--secondary" type="button" onClick={() => navigate('/inventory/' + encodeURIComponent(selected.id))}>Open inventory</button></div><section><p className="card-kicker">Dependencies</p><h3>Deze node gebruikt</h3>{selectedDependencies.length ? <ul className="topology-inspector-list">{selectedDependencies.map(({ edge, node }) => <li key={edge.id}><button type="button" onClick={() => setSelectedId(node.id)}><span><strong>{node.label}</strong><small>{relationLabel(edge.relationType)} · {Math.round(edge.confidence * 100)}%</small></span><span aria-hidden="true">→</span></button></li>)}</ul> : <p className="card-copy">Geen uitgaande dependencyrelaties in de huidige dataset.</p>}</section><section><p className="card-kicker">Impact context</p><h3>Mogelijk geraakt</h3><p className="card-copy">Dit is relationele context uit de topologie en bewijst geen causaliteit.</p>{selectedImpacted.length ? <ul className="topology-inspector-list">{selectedImpacted.map(({ edge, node }) => <li key={edge.id}><button type="button" onClick={() => setSelectedId(node.id)}><span><strong>{node.label}</strong><small>{relationLabel(edge.relationType)} · {Math.round(edge.confidence * 100)}%</small></span><span aria-hidden="true">→</span></button></li>)}</ul> : <p className="card-copy">Geen inkomende relaties gevonden.</p>}</section></aside>}</div>{topology.truncated && <p className="topology-limit-note" role="status">{copy.topology.truncated}</p>}</>;
}
+18 -12
View File
@@ -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<Record<string, unknown>> : [];
const error = (name: string) => errors[name] ? <p className="field-error" id={fieldId(name) + '-error'}>{errors[name]}</p> : 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 <aside className="editor-config-drawer card" aria-labelledby="widget-config-heading">
<p className="card-kicker">{configCopy.kicker}</p>
@@ -70,7 +73,8 @@ export function WidgetConfigDrawer({ widget, errors, preview, previewState, prev
<h3>{configCopy.data}</h3>
<label htmlFor={fieldId('data-sourceType')}>{configCopy.source}<select id={fieldId('data-sourceType')} value={sourceType} onChange={(event) => update('data', 'sourceType', event.target.value)}><option value="semantic-metric">{configCopy.sourceSemanticMetric}</option><option value="inventory">{configCopy.sourceInventory}</option><option value="events">{configCopy.sourceEvents}</option><option value="alerts">{configCopy.sourceAlerts}</option><option value="incidents">{configCopy.sourceIncidents}</option><option value="text">{configCopy.sourceText}</option></select></label>
{error('data.sourceType')}
{sourceType === 'semantic-metric' && <><label htmlFor={fieldId('data-metric')}>{configCopy.metric}<input id={fieldId('data-metric')} value={text(data.metric)} placeholder={configCopy.metricPlaceholder} aria-invalid={Boolean(errors['data.metric'])} aria-describedby={errors['data.metric'] ? fieldId('data-metric') + '-error' : undefined} onChange={(event) => update('data', 'metric', event.target.value)} /></label>{error('data.metric')}<div className="config-fields"><label htmlFor={fieldId('data-range')}>{configCopy.range}<select id={fieldId('data-range')} value={text(data.range, '1h')} onChange={(event) => update('data', 'range', event.target.value)}><option value="live">{configCopy.rangeLive}</option><option value="15m">{configCopy.range15m}</option><option value="1h">{configCopy.range1h}</option><option value="6h">{configCopy.range6h}</option><option value="24h">{configCopy.range24h}</option><option value="7d">{configCopy.range7d}</option></select></label><label htmlFor={fieldId('data-aggregation')}>{configCopy.aggregation}<select id={fieldId('data-aggregation')} value={text(data.aggregation, 'avg')} onChange={(event) => update('data', 'aggregation', event.target.value)}><option value="avg">{configCopy.aggregationAvg}</option><option value="min">{configCopy.aggregationMin}</option><option value="max">{configCopy.aggregationMax}</option><option value="sum">{configCopy.aggregationSum}</option><option value="last">{configCopy.aggregationLast}</option></select></label></div>{error('data.range')}{error('data.aggregation')}</>}
{sourceType === 'semantic-metric' && <><label htmlFor={fieldId('data-metric')}>{configCopy.metric}<input id={fieldId('data-metric')} value={text(data.metric)} placeholder={configCopy.metricPlaceholder} aria-invalid={Boolean(errors['data.metric'])} aria-describedby={errors['data.metric'] ? fieldId('data-metric') + '-error' : undefined} onChange={(event) => update('data', 'metric', event.target.value)} /></label>{error('data.metric')}<div className="config-fields"><label htmlFor={fieldId('data-range')}>{configCopy.range}<select id={fieldId('data-range')} value={text(data.range, '1h')} onChange={(event) => update('data', 'range', event.target.value)}><option value="live">{configCopy.rangeLive}</option><option value="15m">{configCopy.range15m}</option><option value="1h">{configCopy.range1h}</option><option value="6h">{configCopy.range6h}</option><option value="24h">{configCopy.range24h}</option><option value="7d">{configCopy.range7d}</option><option value="30d">30 dagen</option></select></label><label htmlFor={fieldId('data-aggregation')}>{configCopy.aggregation}<select id={fieldId('data-aggregation')} value={text(data.aggregation, 'avg')} onChange={(event) => update('data', 'aggregation', event.target.value)}><option value="avg">{configCopy.aggregationAvg}</option><option value="min">{configCopy.aggregationMin}</option><option value="max">{configCopy.aggregationMax}</option><option value="sum">{configCopy.aggregationSum}</option><option value="last">{configCopy.aggregationLast}</option></select></label></div>{error('data.range')}{error('data.aggregation')}</>}
{sourceType === 'text' && <label htmlFor={fieldId('data-text')}>{configCopy.sourceText}<textarea id={fieldId('data-text')} value={text(data.text)} maxLength={5000} rows={8} onChange={(event) => update('data', 'text', event.target.value)} /></label>}
<label htmlFor={fieldId('data-limit')}>{configCopy.limit}<input id={fieldId('data-limit')} type="number" min={1} max={1000} step={1} value={number(data.limit, '100')} aria-invalid={Boolean(errors['data.limit'])} aria-describedby={errors['data.limit'] ? fieldId('data-limit') + '-error' : undefined} onChange={(event) => update('data', 'limit', Number(event.target.value))} /></label>
{error('data.limit')}
</div>
@@ -79,18 +83,20 @@ export function WidgetConfigDrawer({ widget, errors, preview, previewState, prev
<h3>{configCopy.visualization}</h3>
<div className="config-fields"><label htmlFor={fieldId('visualization-unit')}>{configCopy.unit}<input id={fieldId('visualization-unit')} value={text(visualization.unit)} maxLength={40} onChange={(event) => update('visualization', 'unit', event.target.value)} /></label><label htmlFor={fieldId('visualization-decimals')}>{configCopy.decimals}<input id={fieldId('visualization-decimals')} type="number" min={0} max={6} step={1} value={number(visualization.decimals, '0')} aria-invalid={Boolean(errors['visualization.decimals'])} onChange={(event) => update('visualization', 'decimals', Number(event.target.value))} /></label></div>{error('visualization.decimals')}
<div className="config-fields"><label htmlFor={fieldId('visualization-min')}>{configCopy.minimum}<input id={fieldId('visualization-min')} type="number" value={number(visualization.min)} onChange={(event) => update('visualization', 'min', event.target.value === '' ? null : Number(event.target.value))} /></label><label htmlFor={fieldId('visualization-max')}>{configCopy.maximum}<input id={fieldId('visualization-max')} type="number" value={number(visualization.max)} aria-invalid={Boolean(errors['visualization.max'])} onChange={(event) => update('visualization', 'max', event.target.value === '' ? null : Number(event.target.value))} /></label></div>{error('visualization.max')}
<div className="config-thresholds" aria-label={configCopy.thresholds}><span>{configCopy.thresholds}</span>{thresholds.map((threshold, index) => <div className="config-threshold-row" key={index}><label>{configCopy.thresholdValue}<input type="number" value={number(threshold.value)} aria-invalid={Boolean(errors['visualization.thresholds'])} onChange={(event) => updateThreshold(index, 'value', Number(event.target.value))} /></label><label>{configCopy.thresholdColor}<input type="text" value={text(threshold.color, '#78a7ff')} pattern="#[0-9A-Fa-f]{6}" maxLength={7} aria-invalid={Boolean(errors['visualization.thresholds'])} onChange={(event) => updateThreshold(index, 'color', event.target.value)} /></label><label>{configCopy.thresholdLabel}<input value={text(threshold.label)} maxLength={80} onChange={(event) => updateThreshold(index, 'label', event.target.value)} /></label><button className="button button--secondary" type="button" onClick={() => update('visualization', 'thresholds', thresholds.filter((_, thresholdIndex) => thresholdIndex !== index))}>{configCopy.removeThreshold}</button></div>)}<button className="button button--secondary" type="button" disabled={thresholds.length >= 10} onClick={() => update('visualization', 'thresholds', [...thresholds, { value: 0, color: '#78a7ff', label: '' }])}>{configCopy.addThreshold}</button>{error('visualization.thresholds')}</div>
</div>
<div className="config-section">
<h3>{configCopy.layout}</h3>
<label htmlFor={fieldId('layout-width')}>{configCopy.width} ({viewportLabel})<input id={fieldId('layout-width')} type="number" min={1} max={maxWidth} step={1} value={String(width)} onChange={(event) => onWidthChange(Number(event.target.value))} /></label>
<details className="config-section config-section--advanced">
<summary><span>{configCopy.layout}</span><small>Positie en formaat per viewport</small></summary>
<div className="config-fields"><label htmlFor={fieldId('layout-x')}>{configCopy.positionX} ({viewportLabel})<input id={fieldId('layout-x')} type="number" min={0} max={Math.max(0, maxWidth - placement.w)} step={1} value={String(placement.x)} disabled={locked} onChange={(event) => onPlacementChange('x', Number(event.target.value))} /></label><label htmlFor={fieldId('layout-y')}>{configCopy.positionY}<input id={fieldId('layout-y')} type="number" min={0} step={1} value={String(placement.y)} disabled={locked} onChange={(event) => onPlacementChange('y', Number(event.target.value))} /></label><label htmlFor={fieldId('layout-width')}>{configCopy.width}<input id={fieldId('layout-width')} type="number" min={1} max={maxWidth} step={1} value={String(placement.w)} disabled={locked} onChange={(event) => onPlacementChange('w', Number(event.target.value))} /></label><label htmlFor={fieldId('layout-height')}>{configCopy.height}<input id={fieldId('layout-height')} type="number" min={1} max={24} step={1} value={String(placement.h)} disabled={locked} onChange={(event) => onPlacementChange('h', Number(event.target.value))} /></label></div>
<p className="card-copy">{configCopy.widthHint}</p>
</div>
</details>
<div className="config-section">
<h3>{configCopy.behavior}</h3>
<div className="config-fields"><label htmlFor={fieldId('behavior-liveIntervalSeconds')}>{configCopy.refresh}<input id={fieldId('behavior-liveIntervalSeconds')} type="number" min={1} max={300} step={1} value={number(behavior.liveIntervalSeconds, '30')} aria-invalid={Boolean(errors['behavior.liveIntervalSeconds'])} onChange={(event) => update('behavior', 'liveIntervalSeconds', Number(event.target.value))} /></label><label className="checkbox-field" htmlFor={fieldId('behavior-hideWhenEmpty')}><input id={fieldId('behavior-hideWhenEmpty')} type="checkbox" checked={behavior.hideWhenEmpty === true} onChange={(event) => update('behavior', 'hideWhenEmpty', event.target.checked)} /> {configCopy.hideWhenEmpty}</label></div>{error('behavior.liveIntervalSeconds')}
</div>
<details className="config-section config-section--advanced">
<summary><span>{configCopy.behavior}</span><small>Refresh, visibility en drilldown</small></summary>
<div className="config-fields"><label htmlFor={fieldId('behavior-liveIntervalSeconds')}>{configCopy.refresh}<input id={fieldId('behavior-liveIntervalSeconds')} type="number" min={1} max={300} step={1} value={number(behavior.liveIntervalSeconds, '30')} aria-invalid={Boolean(errors['behavior.liveIntervalSeconds'])} onChange={(event) => update('behavior', 'liveIntervalSeconds', Number(event.target.value))} /></label><label className="checkbox-field" htmlFor={fieldId('behavior-hideWhenEmpty')}><input id={fieldId('behavior-hideWhenEmpty')} type="checkbox" checked={behavior.hideWhenEmpty === true} onChange={(event) => update('behavior', 'hideWhenEmpty', event.target.checked)} /> {configCopy.hideWhenEmpty}</label><label className="checkbox-field" htmlFor={fieldId('behavior-showOnlyOnProblem')}><input id={fieldId('behavior-showOnlyOnProblem')} type="checkbox" checked={behavior.showOnlyOnProblem === true} onChange={(event) => update('behavior', 'showOnlyOnProblem', event.target.checked)} /> {configCopy.showOnlyOnProblem}</label></div>{error('behavior.liveIntervalSeconds')}
<label htmlFor={fieldId('behavior-link')}>{configCopy.link}<input id={fieldId('behavior-link')} value={text(behavior.link)} maxLength={1000} placeholder="/incidents" aria-invalid={Boolean(errors['behavior.link'])} aria-describedby={errors['behavior.link'] ? fieldId('behavior-link') + '-error' : undefined} onChange={(event) => update('behavior', 'link', event.target.value || null)} /></label>{error('behavior.link')}
</details>
<div className="config-preview">
<h3>{configCopy.preview}</h3>
+48
View File
@@ -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<ApiProblem> {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('json')) return {};
try {
const value = await response.json() as Record<string, unknown>;
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<T>(url: string, options: RequestOptions = {}): Promise<T> {
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<T>;
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';
}
+93 -2
View File
@@ -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';
+39
View File
@@ -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; } }
@@ -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; } }
+7
View File
@@ -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; } }
+107
View File
@@ -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<string, unknown>;
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<string, unknown>;
function field<T>(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<unknown>(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<ApiRecord>(record, 'document') ?? {},
changeSummary: String(field(record, 'changeSummary') ?? ''),
createdBy: String(field(record, 'createdBy') ?? ''),
createdAt: String(field(record, 'createdAt') ?? ''),
};
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
try { return await requestJSON<T>(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<DashboardSummary[]> {
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<string, unknown>): Promise<DashboardSummary> {
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<DashboardSummary> {
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<DashboardSummary> {
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<DashboardSummary> {
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<DashboardVersion[]> {
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<DashboardSummary> {
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';
+11
View File
@@ -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;
+3
View File
@@ -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;
+61
View File
@@ -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<SpatialViewport, number> = { 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<string, unknown> | 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<string, unknown>, viewport), x: 0, w: 1 };
let next = normalizePlacement(candidate as unknown as Record<string, unknown>, 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<string, unknown> | undefined, viewport: SpatialViewport): Record<string, string> {
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}`,
};
}
+120
View File
@@ -0,0 +1,120 @@
export type DashboardTemplateId = 'blank' | 'operations' | 'host' | 'storage' | 'workloads' | 'services' | 'incidents' | 'capacity' | 'wallboard';
export type DashboardDocument = Record<string, unknown>;
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 },
};
}
+6 -2
View File
@@ -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<string, string>; 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;
+3 -1
View File
@@ -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(
<StrictMode>
<App />
+18 -2
View File
@@ -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<string, string>; 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<MetricRangePreset, number> = { live: 300, '15m': 900, '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800 };
const seconds: Record<MetricRangePreset, number> = { 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); }
+34
View File
@@ -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; } }
+57
View File
@@ -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%; } }
+148
View File
@@ -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<OperatorRangePreset>(['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<OperatorContextState, 'customFrom' | 'customTo'>): 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;
}
+97
View File
@@ -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<WorkspaceItem>;
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<WorkspaceState>;
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);
}
+1 -1
View File
@@ -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';
};
+33
View File
@@ -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<SessionCapabilities> {
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<SessionCapabilities> {
const response = await fetch('/api/v1/session', { method: 'GET', cache: 'no-store', signal });
if (response.ok) {
const value = await response.json() as Partial<SessionCapabilities> & { permissions?: Partial<SessionPermissions> };
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' };
}
+84 -12
View File
@@ -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; }
+40
View File
@@ -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<WidgetType, string> = {
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<string, unknown>, 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<WidgetCatalogItem[]> {
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<string, unknown>;
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)!);
}
+21 -8
View File
@@ -19,6 +19,14 @@ async function mockAPI(page: Page): Promise<void> {
});
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);
});
@@ -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();
@@ -21,7 +21,9 @@ const widgets = [
async function mockDashboard(page: Page): Promise<void> {
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);
});
@@ -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<void> {
await route.fulfill({ status, contentType: status >= 400 ? 'application/problem+json' : 'application/json', body: JSON.stringify(body) });
}
async function mockShell(page: Page, session: unknown): Promise<void> {
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);
});
@@ -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<void> { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); }
async function shell(page: Page): Promise<void> {
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<script>alert(1)</script>' }, 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('<script>alert(1)</script>')).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([]);
});
+3 -1
View File
@@ -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();
@@ -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<void> { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); }
async function shell(page: Page): Promise<void> {
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<Record<string, unknown>> = [];
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<string, unknown>; 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([]);
});
+5 -4
View File
@@ -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<string>();
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();
@@ -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);
+25 -5
View File
@@ -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 });
});
+4 -2
View File
@@ -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<HTMLElement>('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);
+11 -6
View File
@@ -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(<App />);
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(<App />);
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();
+3 -3
View File
@@ -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(<App />);
render(<DashboardManagerPage />);
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');
});
@@ -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<string, unknown> | 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(<DashboardEditor dashboardId="dashboard-1" revision={1} document={document} onExit={() => 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<HTMLElement>('.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<string, unknown>)).toMatchObject({ x: 1, y: 0, w: 6, h: 6 });
expect((widgets[0].layouts?.tablet as Record<string, unknown>)).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(<DashboardEditor dashboardId="dashboard-1" revision={1} document={lockedDocument} onExit={() => 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);
});
});
@@ -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(<DashboardManagerPage />);
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(<DashboardManagerPage />);
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(<DashboardManagerPage />);
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(<DashboardManagerPage />);
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));
});
});
@@ -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<string, unknown> = {}): 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(<DashboardRuntimeWidget widget={value} viewport="desktop" document={{}} timeRange="1h" onState={() => 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<script>alert(1)</script>' }));
expect(screen.getByRole('link', { name: 'Open incident' })).toHaveAttribute('href', '/incidents');
expect(screen.queryByRole('script')).not.toBeInTheDocument();
expect(screen.getByText('<script>alert(1)</script>')).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();
});
});
@@ -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<Record<string, unknown>> = [];
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(<ExploreMetricsPage />);
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(<ExploreMetricsPage />);
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);
});
});
+17 -1
View File
@@ -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(<MetricWidget {...props({ kind: 'timeseries', series: [
{ key: 'tower', points: [{ ...freshPoint, timestamp: now - 30_000 }, freshPoint] },
{ key: 'backup', points: [{ ...freshPoint, timestamp: now - 30_000, labels: { host: 'backup' } }, { ...freshPoint, labels: { host: 'backup' } }] },
], annotations: [{ id: 'incident-1', label: 'Opslagincident', timestamp: now, tone: 'critical' }] })} />);
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(<RankedListWidget items={[{ id: 'disk-1', label: 'Disk 1', value: '78%' }]} onSelect={onSelect} />);
@@ -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(<><nav aria-label="Applicatieshell">Navigatie</nav><RouteErrorBoundary resetKey="/broken"><BrokenRoute /></RouteErrorBoundary></>);
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();
});
});
+39
View File
@@ -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<ApiProblemError>);
});
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);
});
});
@@ -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);
});
});
@@ -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); });
});
@@ -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);
});
});
+27
View File
@@ -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));
});
});
@@ -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');
});
});
@@ -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);
});
});
+38
View File
@@ -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 });
});
});
+25
View File
@@ -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);
});
});
+2
View File
@@ -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()
+2 -1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+53
View File
@@ -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)
}
+62
View File
@@ -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)
}
}
+1 -1
View File
@@ -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",
+1
View File
@@ -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
+34 -2
View File
@@ -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
}
+1
View File
@@ -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 }
+1
View File
@@ -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"},
+13 -2
View File
@@ -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",