Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eec93c269 | ||
|
|
db8093b608 | ||
|
|
a4956c2af9 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
1.5.0
|
||||
1.6.0
|
||||
@@ -1,5 +1,5 @@
|
||||
# Curated public source export
|
||||
|
||||
Generated from private canonical revision `63254607a2e701e938a09d6e8f68e5bbc8fe83ac`.
|
||||
Generated from private canonical revision `1b456e5a2e8bd0a3bb4238cdc3036b64a7690ae6`.
|
||||
|
||||
This parentless candidate excludes private operational history, evidence, planning, prompts, and machine-local agent configuration.
|
||||
+302
-87
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"sourceRevision": "63254607a2e701e938a09d6e8f68e5bbc8fe83ac",
|
||||
"sourceRevision": "1b456e5a2e8bd0a3bb4238cdc3036b64a7690ae6",
|
||||
"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,27 +3065,32 @@
|
||||
{
|
||||
"path": "PACKAGE_VERSION",
|
||||
"bytes": 6,
|
||||
"sha256": "acb57a7135b2d7d6e665f67f056e21353023b93835f54def1ae523bf76f1bfb3"
|
||||
"sha256": "cb1d9a30bc4ab8a397f39b7d786bb82aca0b816077eefc35d7c822017d75f6a5"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"bytes": 658,
|
||||
"sha256": "9675238f7793c66b5554261ad7c21efd0347bff43f675c816d2810371335824c"
|
||||
"bytes": 700,
|
||||
"sha256": "21ee00596169309b32dd58248fb971ee83912293160bf988c9b17d83b28811cb"
|
||||
},
|
||||
{
|
||||
"path": "pnpm-lock.yaml",
|
||||
"bytes": 60519,
|
||||
"sha256": "64a540c83307eab5a83bdabd5fd128ead91d166a155b3c51568318999756c1f2"
|
||||
"bytes": 60552,
|
||||
"sha256": "5dde289f3eda6d94f5c9912cadd961a0e9f1e66956af806ae53678c356074d7f"
|
||||
},
|
||||
{
|
||||
"path": "pnpm-workspace.yaml",
|
||||
"bytes": 38,
|
||||
"sha256": "6a7030c7d39d70711fd0a50f8a08d5a62bfc5e75ee513bb8529d9f153c21673c"
|
||||
},
|
||||
{
|
||||
"path": "public-source.allowlist",
|
||||
"bytes": 2493,
|
||||
"sha256": "7df95293cc01b709856145355e765eef60ed1d8ac7514b7421d7ba7faea25923"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"bytes": 4690,
|
||||
"sha256": "5dfc28281b63f0bd9c58e322115926a1aab104f33662d1aaccbf88fb428b2a0e"
|
||||
"bytes": 5328,
|
||||
"sha256": "b627aee3d71364ea9a4b5bb73165eb046f06c74c66ac2a08a16eb681edb0bdb3"
|
||||
},
|
||||
{
|
||||
"path": "requirements-dev.txt",
|
||||
@@ -2899,8 +3114,8 @@
|
||||
},
|
||||
{
|
||||
"path": "scripts/integration-smoke.ps1",
|
||||
"bytes": 10781,
|
||||
"sha256": "c7c14a2d4418fdfc12d7bec6f71288c45ecb2e370447cb356830d9c0638199d1"
|
||||
"bytes": 12073,
|
||||
"sha256": "c88c5d576f40dd92a80e7a70569f6a7b382719d209a8cb654ce880835cd66958"
|
||||
},
|
||||
{
|
||||
"path": "scripts/lint.ps1",
|
||||
@@ -2914,8 +3129,8 @@
|
||||
},
|
||||
{
|
||||
"path": "scripts/public-verify.ps1",
|
||||
"bytes": 1730,
|
||||
"sha256": "60260a455045a02b52b3f40969e9a72c6aaacf29f6f66800cd5a8718bf4cf4a0"
|
||||
"bytes": 1802,
|
||||
"sha256": "82bed68caaaa791d5c64671529a5f47bfd22da43305e7ec2a34b64bc7a6670a0"
|
||||
},
|
||||
{
|
||||
"path": "scripts/run-trivy-fs-scan.sh",
|
||||
@@ -2939,8 +3154,8 @@
|
||||
},
|
||||
{
|
||||
"path": "SECURITY.md",
|
||||
"bytes": 1706,
|
||||
"sha256": "9a0002a2fda4d245dd48c9b4483bbeebb01192b7ec0edfe47b8eb6cc4f090377"
|
||||
"bytes": 1704,
|
||||
"sha256": "3f41c5263e31f08df33d4fa2bc502cbb042c077f97b9120bbee6a829bfb28bee"
|
||||
},
|
||||
{
|
||||
"path": "specs/alert-rule-set.schema.json",
|
||||
@@ -2954,8 +3169,8 @@
|
||||
},
|
||||
{
|
||||
"path": "specs/api-routes.json",
|
||||
"bytes": 13627,
|
||||
"sha256": "a02c47d8c6c2676ef93c2e13263aee72fdf950c96ef31a7e0210141e3efe6863"
|
||||
"bytes": 13773,
|
||||
"sha256": "511107130907579e5a43aa44d147bb8176519e29b2fb1e039d9b157ca8c91e86"
|
||||
},
|
||||
{
|
||||
"path": "specs/capability.schema.json",
|
||||
@@ -3009,8 +3224,8 @@
|
||||
},
|
||||
{
|
||||
"path": "specs/widget-instance.schema.json",
|
||||
"bytes": 4943,
|
||||
"sha256": "b6987c270484bc37af508eabd3f58cb145e9879d4cde87e5dec3a430ce83ca3b"
|
||||
"bytes": 5379,
|
||||
"sha256": "fcdc757631719d43c84d5989e9bc00363e2a40deec542a527f94c2baf2fe409d"
|
||||
},
|
||||
{
|
||||
"path": "tools/analyze-wallboard-soak.mjs",
|
||||
|
||||
@@ -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
|
||||

|
||||
|
||||
```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,45 @@ 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:
|
||||
## Read a board with its evidence
|
||||
|
||||
Start with the local mock environment to learn the board and incident views. In a real installation, configure the intended telemetry sources, then inspect timestamps and coverage alongside each status. Follow an incident into its supporting observations before deciding which infrastructure action to take.
|
||||
|
||||
Unknown or stale observations need investigation; they are not a healthy measurement. A running Pulse container does not prove that its remote telemetry sources are reachable. Pulse provides read-only operational evidence: perform infrastructure changes through the appropriate operator workflow.
|
||||
|
||||
## 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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "@itworx/pulse-web",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>> 85% gedurende 5 min</small></button><button type="button" onClick={() => applyTemplate('storage')}><strong>Storage bijna vol</strong><small>> 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
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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></>;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
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 useInventoryResource(widget: RuntimeWidget): Resource {
|
||||
const [resource, setResource] = useState<Resource>({ state: 'loading' });
|
||||
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 isApplications = sourceType === 'inventory' && entityType === 'application';
|
||||
const isStorage = sourceType === 'inventory' && Array.isArray(field(scope, 'entityTypes'));
|
||||
const isEvents = sourceType === 'events';
|
||||
const interval = Math.min(300, Math.max(5, Number(field(widget.behavior, 'liveIntervalSeconds') ?? 15))) * 1000;
|
||||
const entityTypes = field<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 (!isApplications && !isStorage && !isEvents) {
|
||||
setResource({ state: 'empty' });
|
||||
return undefined;
|
||||
}
|
||||
if (sourceType === 'semantic-metric' || (sourceType === 'inventory' && entityType === 'server')) return undefined;
|
||||
if (sourceType === 'text') { setResource({ state: text.trim() ? 'usable' : 'empty', text }); return undefined; }
|
||||
if (!['inventory', 'events', 'alerts', 'incidents'].includes(sourceType)) { setResource({ state: 'capability-disabled' }); return undefined; }
|
||||
let active = true;
|
||||
let controller: AbortController | null = null;
|
||||
const read = async <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>;
|
||||
};
|
||||
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>;
|
||||
}
|
||||
@@ -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); };
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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></>;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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></>;
|
||||
}
|
||||
@@ -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} /></>;
|
||||
}
|
||||
@@ -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>}</>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -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; } }
|
||||
@@ -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; } }
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -165,4 +169,4 @@ export class LiveChartAdapter {
|
||||
get pointCount(): number { return this.store.pointCount(); }
|
||||
|
||||
clear(): void { this.store.clear(); }
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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); }
|
||||
@@ -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; } }
|
||||
@@ -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%; } }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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)!);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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",
|
||||
@@ -9,7 +9,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"nanoid@<3.3.18": "3.3.18"
|
||||
"nanoid@<3.3.18": "3.3.18",
|
||||
"undici@>=8.0.0 <8.10.2": "8.10.2"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
Generated
+5
-4
@@ -6,6 +6,7 @@ settings:
|
||||
|
||||
overrides:
|
||||
nanoid@<3.3.18: 3.3.18
|
||||
undici@>=8.0.0 <8.10.2: 8.10.2
|
||||
|
||||
importers:
|
||||
|
||||
@@ -915,8 +916,8 @@ packages:
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
undici@8.10.0:
|
||||
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
|
||||
undici@8.10.2:
|
||||
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
vite@8.2.0:
|
||||
@@ -1510,7 +1511,7 @@ snapshots:
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.2
|
||||
undici: 8.10.0
|
||||
undici: 8.10.2
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
@@ -1742,7 +1743,7 @@ snapshots:
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
undici@8.10.0: {}
|
||||
undici@8.10.2: {}
|
||||
|
||||
vite@8.2.0(@types/node@26.2.0):
|
||||
dependencies:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Exact files and directory prefixes for the parentless public source repository.
|
||||
.dockerignore
|
||||
.editorconfig
|
||||
.env.example
|
||||
.gitattributes
|
||||
.gitea/workflows/public-validation.yml
|
||||
.gitignore
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
Makefile
|
||||
PACKAGE_VERSION
|
||||
README.md
|
||||
SECURITY.md
|
||||
apps/**
|
||||
cmd/**
|
||||
config/**
|
||||
deploy/agent.Dockerfile
|
||||
deploy/api.Dockerfile
|
||||
deploy/compose.dev.yaml
|
||||
deploy/compose.prod.yaml
|
||||
deploy/compose.real-source-smoke.yaml
|
||||
deploy/compose.server-smoke.yaml
|
||||
deploy/compose.smoke.yaml
|
||||
deploy/compose.yaml
|
||||
deploy/healthcheck-heartbeat.sh
|
||||
deploy/IMAGE_DIGESTS.md
|
||||
deploy/migrate.Dockerfile
|
||||
deploy/nginx.conf
|
||||
deploy/postgres.Dockerfile
|
||||
deploy/pulse-entrypoint.sh
|
||||
deploy/smoke-fixture.Dockerfile
|
||||
deploy/verify-image-digests.sh
|
||||
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
|
||||
docs/architecture/adr/0003-prometheus-v1-history.md
|
||||
docs/architecture/adr/0004-postgresql-domain-state.md
|
||||
docs/architecture/adr/0005-docker-access-boundary.md
|
||||
docs/architecture/adr/0006-rest-websocket.md
|
||||
docs/architecture/adr/0007-localization.md
|
||||
docs/architecture/adr/0008-stale-is-unknown.md
|
||||
docs/architecture/adr/0009-upstream-dependency-baseline.md
|
||||
docs/architecture/ALERTING_AND_INCIDENTS.md
|
||||
docs/architecture/API_CONTRACT.md
|
||||
docs/architecture/DATA_MODEL.md
|
||||
docs/architecture/SECURITY_THREAT_MODEL.md
|
||||
docs/architecture/SYSTEM_ARCHITECTURE.md
|
||||
docs/architecture/TELEMETRY_AND_QUERY_ENGINE.md
|
||||
docs/engineering/**
|
||||
docs/operations/BACKUP_RESTORE.md
|
||||
docs/operations/DEVELOPMENT_SETUP.md
|
||||
docs/operations/OBSERVABILITY_OF_PULSE.md
|
||||
docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
|
||||
docs/product/**
|
||||
fixtures/**
|
||||
go.mod
|
||||
go.sum
|
||||
go.work
|
||||
go.work.sum
|
||||
internal/**
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
public-source.allowlist
|
||||
requirements-dev.txt
|
||||
scripts/bootstrap.ps1
|
||||
scripts/build.ps1
|
||||
scripts/export-public-source.mjs
|
||||
scripts/integration-smoke.ps1
|
||||
scripts/lint.ps1
|
||||
scripts/production-smoke.ps1
|
||||
scripts/public-verify.ps1
|
||||
scripts/run-trivy-fs-scan.sh
|
||||
scripts/test.ps1
|
||||
scripts/validate-public-source.mjs
|
||||
scripts/wallboard-soak.ps1
|
||||
specs/**
|
||||
tools/analyze-wallboard-soak.mjs
|
||||
tools/check_api_contract.py
|
||||
tools/check_secrets.py
|
||||
tools/check_wiring.py
|
||||
tools/deadman_check.py
|
||||
tools/integrationfixture/**
|
||||
tools/validate_contracts.py
|
||||
tools/wallboard-soak-analysis.mjs
|
||||
tools/wallboard-soak.mjs
|
||||
tools/wiring_allowlist.json
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user