Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.codex
.agents
node_modules
**/node_modules
dist
**/dist
bin
tmp
coverage
artifacts/evidence
backups
.env
.env.*
!.env.example
*.log
*.sqlite
*.db
+19
View File
@@ -0,0 +1,19 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.go]
indent_style = tab
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+88
View File
@@ -0,0 +1,88 @@
# General
PULSE_ENV=development
PULSE_TIMEZONE=Europe/Brussels
PULSE_DEFAULT_LOCALE=nl-BE
PULSE_LOG_LEVEL=info
PULSE_AUTH_MODE=mock
# Public URL — select only after M0 conflict/discovery checks
PULSE_PUBLIC_URL=http://localhost:8080
# PostgreSQL
PULSE_DATABASE_URL=postgres://pulse:pulse-dev-only@postgres:5432/pulse?sslmode=disable
# Metrics source
PULSE_PROMETHEUS_URL=http://prometheus:9090
PULSE_PROMETHEUS_TIMEOUT=10s
# Unraid — do not commit real tokens
PULSE_UNRAID_URL=https://unraid.example.invalid
PULSE_UNRAID_API_TOKEN=
# Production mounts this public certificate read-only; never put a private key here.
PULSE_UNRAID_CA_FILE_HOST=/path/to/unraid-ca.pem
# DNS name and address of the same Unraid host as used by PULSE_UNRAID_URL.
# Required by deploy/compose.prod.yaml; discover them instead of assuming host-gateway.
PULSE_UNRAID_HOST_NAME=unraid.example.test
PULSE_UNRAID_HOST_GATEWAY=192.0.2.10
# OIDC / Authentik
# Required when PULSE_AUTH_MODE=oidc; development may use the explicit mock mode above.
PULSE_OIDC_ISSUER=https://auth.example.invalid/application/o/pulse/
PULSE_OIDC_CLIENT_ID=pulse
PULSE_OIDC_CLIENT_SECRET=
PULSE_OIDC_REDIRECT_URL=http://localhost:8080/auth/callback
# ID token claim carrying the group memberships used for role mapping.
PULSE_OIDC_GROUPS_CLAIM=groups
# Maps identity provider group claim values onto Pulse roles. Required in production:
# without it no identity can be granted a role and nobody can sign in.
# Roles: viewer, operator, editor, administrator.
PULSE_OIDC_ROLE_MAPPING=pulse-viewer=viewer,pulse-operator=operator,pulse-editor=editor,pulse-admin=administrator
# Break-glass account must be disabled unless explicitly initialized securely
PULSE_BREAK_GLASS_ENABLED=false
# pulse-agent — read-only host collector
# Identifies the agent in every snapshot and in the protocol hello.
PULSE_AGENT_ID=pulse-agent
# How often a full collection pass runs (1s5m). The scheduling loop ticks faster when
# this is larger, so the heartbeat stays inside the healthcheck window.
PULSE_AGENT_COLLECT_INTERVAL=10s
# Read-only mounts of the host's kernel interfaces; compose bind mounts /proc and /sys.
PULSE_AGENT_PROC_ROOT=/host/proc
PULSE_AGENT_SYS_ROOT=/host/sys
# The host name as it should appear in Pulse. Inside a container the kernel reports the
# container's own name, so set this explicitly (for example: unraid-host).
PULSE_AGENT_HOST_NAME=
# Filesystem capacity and inode collection is off unless a host root is mounted
# read-only and named here (for example /host/root together with "- /:/host/root:ro").
# Without it the agent reports no filesystems rather than measuring its own overlay.
PULSE_AGENT_FS_ROOT=
# Optional cap on the process inventory (15000); empty uses the domain default of 1000.
PULSE_AGENT_MAX_PROCESSES=
# Liveness heartbeat file written by pulse-worker and pulse-agent after every completed
# loop iteration. See docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md.
PULSE_HEARTBEAT_FILE=/tmp/healthy
# pulse-worker — background runtime
# data_sources UUID container discovery is attributed to. Discovery stays Disabled
# until a source is registered, so inventory is never written against an unknown origin.
PULSE_CONTAINER_SOURCE_ID=
# Private/loopback CIDRs service probes may reach, comma separated (max 32).
# Empty keeps all private space blocked. Link-local, multicast and cloud metadata
# addresses stay blocked regardless of this value.
PULSE_PROBE_ALLOWED_NETWORKS=
# Optional real notification receiver. The URL is stored as non-secret channel
# configuration; the bearer token remains runtime-only and is never persisted.
# Production requires HTTPS. The receiver should deduplicate by Idempotency-Key.
PULSE_NOTIFICATION_WEBHOOK_URL=
PULSE_NOTIFICATION_WEBHOOK_TOKEN=
PULSE_NOTIFICATION_WEBHOOK_TIMEOUT=10s
# Production deployment (deploy/compose.prod.yaml)
# Host port pulse-web is published on. See ADR-0011.
PULSE_HOST_PORT=1238
# Bind address for that port. Use 127.0.0.1 only if Nginx Proxy Manager reaches
# Pulse over a shared Docker network rather than over the host.
PULSE_PUBLISH_ADDRESS=0.0.0.0
+16
View File
@@ -0,0 +1,16 @@
* text=auto eol=lf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.zip binary
# Keep release/source archives focused on product source and public documentation.
/.agents export-ignore
/.codex export-ignore
/MASTER_PROMPT.txt export-ignore
/planning export-ignore
/artifacts/evidence export-ignore
/AUDIT.md export-ignore
/PACKAGE_REPORT.md export-ignore
+52
View File
@@ -0,0 +1,52 @@
name: Public source validation
on:
push:
pull_request:
permissions:
contents: read
concurrency:
group: public-validation-${{ gitea.repository }}-${{ gitea.event_name }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
validate:
if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '24'
cache: pnpm
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: '10.33.0'
- run: go test ./... && go vet ./...
- run: pnpm install --frozen-lockfile
- run: pnpm test && pnpm typecheck && pnpm lint && pnpm build
- name: Python contract and repository checks
shell: bash
run: |
set -euo pipefail
python3 -m venv "$RUNNER_TEMP/pulse-validation"
validation_python="$RUNNER_TEMP/pulse-validation/bin/python"
"$validation_python" -m pip install --disable-pip-version-check -r requirements-dev.txt
"$validation_python" tools/check_api_contract.py
"$validation_python" tools/validate_contracts.py
"$validation_python" tools/check_wiring.py
"$validation_python" tools/check_secrets.py
- run: bash deploy/verify-image-digests.sh
- name: Export reviewed public source
run: node scripts/export-public-source.mjs --output "$RUNNER_TEMP/pulse-public"
- name: Validate exported source manifest
run: cd "$RUNNER_TEMP/pulse-public" && node scripts/validate-public-source.mjs
- name: Dependency scan
run: sh scripts/run-trivy-fs-scan.sh "$RUNNER_TEMP/pulse-public"
+77
View File
@@ -0,0 +1,77 @@
# Secrets and local configuration
.env
.env.*
!.env.example
*.pem
*.key
*.pfx
*.p12
*.crt
secrets/
credentials/
# Machine-local agent execution policy (tracked agent docs/skills remain intentional)
.codex/config.toml
.codex/config.local.toml
.claude/
# IDE / OS
.vscode/
.idea/
.vs/
.DS_Store
Thumbs.db
*.swp
# Node / frontend
node_modules/
coverage/
playwright-report/
test-results/
.next/
dist/
.npm/
.pnpm-store/
# Go
bin/
*.test
*.out
vendor/
# Python
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
# Databases / runtime
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
tmp/
.cache/
data/
backups/local/
# Generated heavy evidence
artifacts/evidence/**/*.mp4
artifacts/evidence/**/*.webm
artifacts/evidence/**/*.zip
artifacts/evidence/**/*.tar
artifacts/evidence/**/*.gz
# Keep intentional evidence summaries, structured output and reviewed images
!artifacts/evidence/**/summary.md
!artifacts/evidence/**/*.json
!artifacts/evidence/**/*.txt
!artifacts/evidence/**/*.png
!artifacts/evidence/**/*.svg
# Local scratch
_to_delete/
artifacts/_audit_tmp/
.playwright-mcp/
+20
View File
@@ -0,0 +1,20 @@
# Changelog
All notable user-facing changes are recorded here. Private deployment evidence and environment-specific rollout details are deliberately excluded.
## 1.5.0
- Added a mature Dutch operator interface with responsive overview, inventory, storage, service, alert, incident, settings, and wallboard flows.
- Expanded read-only Unraid and host collection for containers, processes, array, disks, pools, shares, capacity, and topology.
- Added bounded semantic metrics, historical queries, live WebSocket subscriptions, freshness handling, and explicit `Unknown` states.
- Added versioned dashboards, alert lifecycle, silences, maintenance, incident grouping, audit, and notification delivery controls.
- Added Authentik/OIDC, fail-closed role mapping, finite revocable sessions, and hardened production containers.
- Added portable checksummed backups, clean-room restore verification, production smoke checks, and deterministic release gates.
## Public-readiness changes
- Added a curated parentless public-source exporter and manifest validator.
- Added public deployment, security, and contribution guidance.
- Removed environment-specific defaults from portable configuration.
- Pinned third-party CI actions and added explicit read-only workflow permissions.
- Fixed personal-dashboard read authorization and bounded retained browser sessions.
+22
View File
@@ -0,0 +1,22 @@
# Contributing
Contributions must preserve Pulse's read-only observability and evidence boundaries.
Start with a focused issue or proposal for material behavior changes. Keep pull requests small enough to review, explain the user-visible outcome, and include tests for the changed boundary.
- use synthetic telemetry and isolated test databases;
- never commit production dashboards, host inventories, credentials, backups or alert payloads;
- keep application changes separate from agent/planning/evidence updates;
- document new data collection, retention, authorization and network behaviour;
- run the repository's managed validation and the relevant Go, frontend, integration and Compose checks;
- retain provenance for screenshots and evidence summaries, and keep generated heavy artifacts outside Git.
Changes that alter the read-only promise, OIDC/RBAC policy, backup format, agent privileges or deployment topology require explicit security review. Report sensitive findings through `SECURITY.md`.
For a public source checkout, run:
```powershell
pwsh -NoProfile -File scripts/public-verify.ps1
```
Go code must be formatted with `gofmt`; frontend changes must pass tests, typecheck, lint, and build. Do not weaken a failing gate or replace a real integration boundary with a mock merely to obtain a green result.
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+43
View File
@@ -0,0 +1,43 @@
.PHONY: validate state next sync test-harness contracts docs checksums wiring bootstrap build test lint verify
validate:
python tools/projectctl.py validate
state:
python tools/projectctl.py summary
next:
python tools/projectctl.py next
sync:
python tools/projectctl.py sync-md
test-harness:
python -m unittest discover -s tools/tests -p "test_*.py" -v
contracts:
python tools/validate_contracts.py
docs:
python tools/check_docs.py
checksums:
python tools/generate_checksums.py
wiring:
python tools/check_wiring.py
bootstrap:
pwsh -NoProfile -File scripts/bootstrap.ps1
build:
pwsh -NoProfile -File scripts/build.ps1
test:
pwsh -NoProfile -File scripts/test.ps1
lint:
pwsh -NoProfile -File scripts/lint.ps1
verify:
pwsh -NoProfile -File scripts/verify.ps1
+1
View File
@@ -0,0 +1 @@
1.5.0
+5
View File
@@ -0,0 +1,5 @@
# Curated public source export
Generated from private canonical revision `63254607a2e701e938a09d6e8f68e5bbc8fe83ac`.
This parentless candidate excludes private operational history, evidence, planning, prompts, and machine-local agent configuration.
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
# ITWorx Pulse
ITWorx Pulse is een self-hosted observabilityplatform voor Unraid. Het brengt host-, container-, storage-, netwerk- en servicetelemetrie samen in één operatorgerichte interface, met configureerbare dashboards, alerts, incidenten en wallboards.
Pulse is operationeel **read-only**: het observeert en verklaart, maar start, stopt, verwijdert of repareert geen infrastructuur. Ontbrekende of verouderde telemetrie verschijnt als `Onbekend`, nooit als vals groen.
Actuele release: **v1.5.0**.
## Wat je als gebruiker krijgt
- een overzicht dat meteen toont wat gezond, gedegradeerd, kritisch of onbekend is en waarom;
- detailpagina's voor host, processen, containers, applicaties, array, disks, pools, shares, services en netwerk;
- versieerbare dashboards met afzonderlijke desktop-, tablet-, mobiele en wallboardlayouts;
- begrensde historische en live Prometheus-query's zonder dat gebruikers zelf PromQL moeten schrijven;
- alerts met pending/recovery, hysterese, suppressie, silences en maintenance;
- incidentgroepering, tijdlijnen, notities, ownership en audit;
- Authentik/OIDC-login met viewer-, operator-, editor- en administratorrollen;
- controleerbare backups en een clean-room herstelprocedure;
- hardened non-root containers en een minimale read-only collectorgrens.
## Platform in één oogopslag
```text
Browser
└─ HTTPS / OIDC / REST / WebSocket
└─ Pulse Web + API
├─ PostgreSQL configuratie, inventory, dashboards, alerts en audit
├─ Prometheus begrensde historische en live metrics
├─ Pulse Worker discovery, probes, alerting en notifications
└─ Pulse Agent minimale read-only Unraid- en hostobservatie
```
De web/API-laag krijgt geen Docker-socket of host-roottoegang. Alleen de agent ontvangt de expliciet geconfigureerde read-only bronnen die nodig zijn voor observatie.
## Snel lokaal proberen
Vereisten: Go 1.26.6, Node.js 24+, pnpm 10.33+, Python 3, PowerShell 7 en Docker Compose.
```powershell
Copy-Item .env.example .env
pwsh -NoProfile -File scripts/bootstrap.ps1
docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up --build
```
Open daarna `http://localhost:18080`. De ontwikkelstack gebruikt expliciete mock-authenticatie en geïsoleerde lokale data; gebruik hiervoor nooit productiecredentials of productiedata.
Stop en verwijder alleen deze lokale stack met:
```powershell
docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml down --volumes
```
## Valideren
De private engineeringrepository gebruikt een uitgebreidere evidencegate. Een publieke source-export valideert de productcode met:
```powershell
pwsh -NoProfile -File scripts/public-verify.ps1
```
Deze gate controleert Go-tests/vet, frontendtests/typecheck/build, contracten, wiring, secretmarkers, image-digests en de publieke source boundary. De optionele Docker-integratiesmoke staat in `scripts/integration-smoke.ps1`.
## Productie
Begin bij [`docs/PUBLIC_DEPLOYMENT.md`](docs/PUBLIC_DEPLOYMENT.md). Productie vereist onder meer:
- HTTPS en een Authentik/OIDC-provider;
- een private PostgreSQL-database en externe secrets;
- een expliciete Prometheusbron;
- een least-privilege Unraid API-token en gecontroleerde CA-mount wanneer de agent de Unraid API gebruikt;
- een operator-owned backupdirectory;
- validatie van poorten, netwerken, mounts en rollback vóór de eerste start.
De voorbeeldconfiguratie faalt bewust dicht wanneer verplichte productie-instellingen ontbreken.
## Documentatie
| Onderwerp | Document |
|---|---|
| Productscope en gebruikersflows | [`docs/product/PRODUCT_REQUIREMENTS.md`](docs/product/PRODUCT_REQUIREMENTS.md) |
| Architectuur | [`docs/architecture/SYSTEM_ARCHITECTURE.md`](docs/architecture/SYSTEM_ARCHITECTURE.md) |
| API en WebSocketcontract | [`docs/architecture/API_CONTRACT.md`](docs/architecture/API_CONTRACT.md) |
| Securitymodel | [`docs/architecture/SECURITY_THREAT_MODEL.md`](docs/architecture/SECURITY_THREAT_MODEL.md) |
| Lokale ontwikkeling | [`docs/operations/DEVELOPMENT_SETUP.md`](docs/operations/DEVELOPMENT_SETUP.md) |
| Backup en herstel | [`docs/operations/BACKUP_RESTORE.md`](docs/operations/BACKUP_RESTORE.md) |
| Publicatiegrens | [`docs/PUBLIC_SOURCE_BOUNDARY.md`](docs/PUBLIC_SOURCE_BOUNDARY.md) |
## Bijdragen en security
Lees [`CONTRIBUTING.md`](CONTRIBUTING.md) voordat je een wijziging indient. Meld kwetsbaarheden niet in een publieke issue; volg [`SECURITY.md`](SECURITY.md).
First-party broncode in deze repository is gelicentieerd onder **AGPL-3.0-or-later**; zie [`LICENSE`](LICENSE). Componenten en assets van derden behouden hun eigen licentievoorwaarden.
+19
View File
@@ -0,0 +1,19 @@
# Security Policy
## Supported code
Security fixes target the current release line on `master`. Older releases may receive a fix when the same issue is still relevant and a safe backport is practical.
## Reporting vulnerabilities
Report suspected vulnerabilities privately. Do not open a public issue containing access tokens, OIDC secrets, session material, private dashboards, host inventories, alert payloads, infrastructure topology, backup contents, database credentials, production telemetry, or exploit-sensitive evidence.
Include the affected release or commit, component, minimal reproduction conditions using synthetic telemetry where possible, expected and observed behaviour, and impact. Call out effects on authentication/RBAC, query bounds, WebSocket subscriptions, agent isolation, backup/restore, deployment, secret handling, or the read-only product boundary.
Email reports to `security@itworx.tech`. This monitored mailbox is the permanent private reporting channel for the project.
## Security boundary
Pulse is an observability product. Contributions must not silently introduce mutation of monitored Unraid, storage, container, service, or network resources. Unknown or stale telemetry remains explicit, authentication fails closed, and runtime containers retain their documented least-privilege boundaries.
Never commit live `.env` files, production credentials, private backups, unredacted production data, or operator-specific infrastructure evidence. Agent instructions and engineering evidence retained in the canonical private repository are development context, not executable production authority; public source archives exclude that context through `.gitattributes`.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="nl-BE">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/pulse-icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#0b1220" />
<title>ITWorx Pulse</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@itworx/pulse-web",
"private": true,
"version": "1.5.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@types/node": "^26.2.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.0.5",
"@vitest/coverage-v8": "^4.1.10",
"jsdom": "^30.0.1",
"typescript": "7.0.2",
"vite": "8.2.0",
"vitest": "^4.1.10"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
const realStackBaseURL = process.env.PULSE_E2E_REAL_BASE_URL;
const localWebPort = process.env.PULSE_E2E_WEB_PORT || '4173';
const localBaseURL = `http://127.0.0.1:${localWebPort}`;
export default defineConfig({
testDir: './tests/e2e',
outputDir: '../../test-results/playwright',
reporter: [['list'], ['html', { outputFolder: '../../playwright-report', open: 'never' }]],
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
use: {
baseURL: realStackBaseURL || localBaseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: realStackBaseURL ? undefined : {
command: `pnpm dev --host 127.0.0.1 --port ${localWebPort} --strictPort`,
url: `${localBaseURL}/healthz`,
reuseExistingServer: false,
timeout: 120_000,
},
projects: [
{ name: 'desktop-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
{ name: 'tablet-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1024, height: 768 } } },
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'], viewport: { width: 390, height: 844 } } },
{ name: 'wallboard-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } } },
],
});
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
<title id="title">ITWorx Pulse</title>
<desc id="description">Een blauwe Pulse-letter P met een operationele signaalgolf.</desc>
<defs>
<linearGradient id="surface" x1="64" y1="48" x2="448" y2="464" gradientUnits="userSpaceOnUse">
<stop stop-color="#13263f"/>
<stop offset="1" stop-color="#07111f"/>
</linearGradient>
<linearGradient id="accent" x1="118" y1="128" x2="398" y2="382" gradientUnits="userSpaceOnUse">
<stop stop-color="#79b8ff"/>
<stop offset="1" stop-color="#408cff"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="24" flood-color="#020817" flood-opacity=".55"/>
</filter>
</defs>
<rect x="24" y="24" width="464" height="464" rx="112" fill="url(#surface)" stroke="#263b57" stroke-width="8" filter="url(#shadow)"/>
<path d="M154 374V138h108c76 0 126 39 126 104 0 67-50 108-126 108h-42v24h-66Zm66-82h39c40 0 62-17 62-49 0-30-22-47-62-47h-39v96Z" fill="url(#accent)"/>
<path d="M100 326h48l24-54 34 92 34-74 24 36h148" fill="none" stroke="#d9ebff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="412" cy="326" r="12" fill="#79b8ff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
type Matcher = { ruleIds?: string[]; entityIds?: string[]; entityTypes?: string[]; severities?: string[]; labels?: Record<string, string> };
type Silence = { id: string; name: string; reason: string; owner: string; matchers: Matcher; startsAt: string; expiresAt: string; state: string; revision: number };
type Maintenance = { id: string; name: string; reason: string; selector: Matcher; startsAt: string; endsAt: string; state: string; revision: number };
type ControlForm = { name: string; reason: string; matcher: string; startsAt: string; expiresAt: string };
function localDate(offsetHours: number) { const date = new Date(Date.now() + offsetHours * 3600000); const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); return local.toISOString().slice(0, 16); }
function toUTC(value: string) { return new Date(value).toISOString(); }
function matcherFor(value: string, key: 'severity' | 'entityType'): Matcher { const values = value.split(',').map((item) => item.trim()).filter(Boolean); return key === 'severity' ? { severities: values } : { entityTypes: values }; }
function stateLabel(value: string) { return value === 'active' ? copy.alerts.controlActive : value === 'scheduled' ? copy.alerts.controlScheduled : value === 'expired' ? copy.alerts.controlExpired : copy.alerts.controlRevoked; }
export function AlertControlsPanel() {
const [silences, setSilences] = useState<Silence[]>([]);
const [maintenance, setMaintenance] = useState<Maintenance[]>([]);
const [silenceForm, setSilenceForm] = useState<ControlForm>({ name: '', reason: '', matcher: 'critical', startsAt: localDate(0), expiresAt: localDate(1) });
const [maintenanceForm, setMaintenanceForm] = useState<ControlForm>({ name: '', reason: '', matcher: 'host', startsAt: localDate(0), expiresAt: localDate(1) });
const [preview, setPreview] = useState('');
const [message, setMessage] = useState('');
async function load() {
const [silenceResponse, maintenanceResponse] = await Promise.all([fetch('/api/v1/alert-silences?limit=100'), fetch('/api/v1/maintenance-windows?limit=100')]);
if (silenceResponse.ok) setSilences(((await silenceResponse.json()) as { items?: Silence[] }).items ?? []);
if (maintenanceResponse.ok) setMaintenance(((await maintenanceResponse.json()) as { items?: Maintenance[] }).items ?? []);
}
useEffect(() => { void load(); }, []);
async function createSilence() {
setMessage('');
const response = await fetch('/api/v1/alert-silences', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: silenceForm.name, reason: silenceForm.reason, owner: 'current-user', matchers: matcherFor(silenceForm.matcher, 'severity'), startsAt: toUTC(silenceForm.startsAt), expiresAt: toUTC(silenceForm.expiresAt) }) });
if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
setSilenceForm({ ...silenceForm, name: '', reason: '' }); setMessage(copy.alerts.controlSaved); await load();
}
async function createMaintenance() {
setMessage('');
const response = await fetch('/api/v1/maintenance-windows', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: maintenanceForm.name, reason: maintenanceForm.reason, selector: matcherFor(maintenanceForm.matcher, 'entityType'), startsAt: toUTC(maintenanceForm.startsAt), endsAt: toUTC(maintenanceForm.expiresAt) }) });
if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
setMaintenanceForm({ ...maintenanceForm, name: '', reason: '' }); setMessage(copy.alerts.controlSaved); await load();
}
async function revoke(kind: 'silence' | 'maintenance', id: string, revision: number) {
if (!window.confirm(copy.alerts.confirmRevoke)) return;
const endpoint = kind === 'silence' ? '/api/v1/alert-silences/' : '/api/v1/maintenance-windows/';
const response = await fetch(endpoint + encodeURIComponent(id) + '/revoke?revision=' + revision, { method: 'POST' });
if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
await load();
}
async function previewMatcher() {
const response = await fetch('/api/v1/alert-silences/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ matcher: matcherFor(silenceForm.matcher, 'severity'), signals: [{ instanceId: 'preview-signal', severity: silenceForm.matcher.split(',')[0].trim() }] }) });
if (!response.ok) { setPreview(copy.alerts.controlPreviewError); return; }
const data = await response.json() as { preview?: { matchedCount?: number } };
setPreview(copy.alerts.controlPreview + ': ' + String(data.preview?.matchedCount ?? 0));
}
return <section className="card alert-controls" aria-labelledby="alert-controls-title">
<div className="card-heading"><div><p className="card-kicker">{copy.alerts.controls}</p><h2 id="alert-controls-title">{copy.alerts.controlsTitle}</h2></div><span className="status-badge status-badge--unknown">{copy.alerts.controlHistory}</span></div>
<p className="card-copy">{copy.alerts.controlsIntro}</p>
<div className="alert-control-grid">
<form className="control-form" onSubmit={(event) => { event.preventDefault(); void createSilence(); }}><h3>{copy.alerts.silenceTitle}</h3><label>{copy.alerts.controlName}<input required id="silence-name" name="silenceName" maxLength={160} value={silenceForm.name} onChange={(event) => setSilenceForm({ ...silenceForm, name: event.target.value })} /></label><label>{copy.alerts.controlReason}<input required id="silence-reason" name="silenceReason" maxLength={500} value={silenceForm.reason} onChange={(event) => setSilenceForm({ ...silenceForm, reason: event.target.value })} /></label><label>{copy.alerts.silenceSeverity}<select required id="silence-matcher" name="silenceMatcher" value={silenceForm.matcher} onChange={(event) => setSilenceForm({ ...silenceForm, matcher: 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><small>{copy.alerts.matcherHelp}</small></label><label>{copy.alerts.startsAt}<input required id="silence-starts-at" name="silenceStartsAt" type="datetime-local" value={silenceForm.startsAt} onChange={(event) => setSilenceForm({ ...silenceForm, startsAt: event.target.value })} /></label><label>{copy.alerts.expiresAt}<input required id="silence-expires-at" name="silenceExpiresAt" type="datetime-local" value={silenceForm.expiresAt} onChange={(event) => setSilenceForm({ ...silenceForm, expiresAt: event.target.value })} /></label><div className="detail-actions"><button className="button" type="submit">{copy.alerts.createControl}</button><button className="button button--secondary" type="button" onClick={() => void previewMatcher()}>{copy.alerts.previewMatcher}</button></div>{preview && <small role="status">{preview}</small>}</form>
<form className="control-form" onSubmit={(event) => { event.preventDefault(); void createMaintenance(); }}><h3>{copy.alerts.maintenanceTitle}</h3><label>{copy.alerts.controlName}<input required id="maintenance-name" name="maintenanceName" maxLength={160} value={maintenanceForm.name} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, name: event.target.value })} /></label><label>{copy.alerts.controlReason}<input required id="maintenance-reason" name="maintenanceReason" maxLength={500} value={maintenanceForm.reason} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, reason: event.target.value })} /></label><label>{copy.alerts.entityType}<select required id="maintenance-selector" name="maintenanceSelector" value={maintenanceForm.matcher} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, matcher: event.target.value })}><option value="host">{copy.alerts.entityHost}</option><option value="container">{copy.alerts.entityContainer}</option><option value="service">{copy.alerts.entityService}</option><option value="disk">{copy.alerts.entityDisk}</option><option value="pool">{copy.alerts.entityPool}</option></select><small>{copy.alerts.selectorHelp}</small></label><label>{copy.alerts.startsAt}<input required id="maintenance-starts-at" name="maintenanceStartsAt" type="datetime-local" value={maintenanceForm.startsAt} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, startsAt: event.target.value })} /></label><label>{copy.alerts.endsAt}<input required id="maintenance-ends-at" name="maintenanceEndsAt" type="datetime-local" value={maintenanceForm.expiresAt} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, expiresAt: event.target.value })} /></label><button className="button" type="submit">{copy.alerts.createControl}</button></form>
</div>
{message && <p className="form-message" role="status">{message}</p>}
<div className="alert-control-lists"><div><h3>{copy.alerts.silenceHistory}</h3>{silences.length === 0 ? <p className="card-copy">{copy.alerts.noControls}</p> : <ul className="inventory-list">{silences.map((item) => <li key={item.id}><span><strong>{item.name}</strong><small>{stateLabel(item.state)} · {item.reason}</small></span>{item.state === 'active' && <button className="button button--secondary" type="button" onClick={() => void revoke('silence', item.id, item.revision)}>{copy.alerts.revoke}</button>}</li>)}</ul>}</div><div><h3>{copy.alerts.maintenanceHistory}</h3>{maintenance.length === 0 ? <p className="card-copy">{copy.alerts.noControls}</p> : <ul className="inventory-list">{maintenance.map((item) => <li key={item.id}><span><strong>{item.name}</strong><small>{stateLabel(item.state)} · {item.reason}</small></span>{item.state === 'active' && <button className="button button--secondary" type="button" onClick={() => void revoke('maintenance', item.id, item.revision)}>{copy.alerts.revoke}</button>}</li>)}</ul>}</div></div>
</section>;
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { plural, presentReason, presentStatus } from './presentation';
type AlertItem = { id: string; state: string; retainedState: string; ruleName: string; severity: string; entityName?: string; reason: string; revision: number; acknowledgedBy?: string; updatedAt?: string; occurrences?: Array<{ eventType: string; from: string; to: string; observedAt: string; reason: string }> };
type AlertView = 'active' | 'critical' | 'acknowledged';
const stateOrder: Record<string, number> = { firing: 0, pending: 1, acknowledged: 2, silenced: 3, suppressed: 4, resolved: 5 };
const severityOrder: Record<string, number> = { critical: 0, degraded: 1, attention: 2, warning: 3, info: 4, unknown: 5 };
function alertTone(severity: string): 'critical' | 'attention' | 'ready' | 'unknown' {
if (severity === 'critical') return 'critical';
if (severity === 'degraded' || severity === 'attention' || severity === 'warning') return 'attention';
if (severity === 'info') return 'ready';
return 'unknown';
}
function isActive(item: AlertItem): boolean { return item.state !== 'resolved'; }
export function AlertOperationsPanel() {
const [items, setItems] = useState<AlertItem[]>([]);
const [selected, setSelected] = useState<AlertItem | null>(null);
const [message, setMessage] = useState('');
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [view, setView] = useState<AlertView>('active');
async function load(signal?: AbortSignal) {
try {
const response = await fetch('/api/v1/alerts?limit=100', { signal });
if (!response.ok) throw new Error('alerts');
setItems(((await response.json()) as { items?: AlertItem[] }).items ?? []);
setState('ready');
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState('error');
}
}
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, []);
const ordered = useMemo(() => items.slice().sort((left, right) =>
(severityOrder[left.severity] ?? 9) - (severityOrder[right.severity] ?? 9)
|| (stateOrder[left.state] ?? 9) - (stateOrder[right.state] ?? 9)
|| (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '')
|| left.ruleName.localeCompare(right.ruleName, 'nl-BE')
|| left.id.localeCompare(right.id)), [items]);
const activeCount = items.filter(isActive).length;
const criticalCount = items.filter((item) => isActive(item) && item.severity === 'critical').length;
const acknowledgedCount = items.filter((item) => item.state === 'acknowledged').length;
const filtered = ordered.filter((item) => view === 'critical' ? isActive(item) && item.severity === 'critical' : view === 'acknowledged' ? item.state === 'acknowledged' : isActive(item));
const visible = filtered.slice(0, 20);
async function choose(item: AlertItem) {
const response = await fetch('/api/v1/alerts/' + encodeURIComponent(item.id) + '?occurrenceLimit=50');
if (response.ok) setSelected(((await response.json()) as { alert: AlertItem }).alert);
}
async function operate(item: AlertItem, acknowledge: boolean) {
if (!window.confirm(acknowledge ? copy.alerts.confirmAcknowledge : copy.alerts.confirmUnacknowledge)) return;
setMessage('');
const response = await fetch('/api/v1/alerts/' + encodeURIComponent(item.id) + '/' + (acknowledge ? 'acknowledge' : 'unacknowledge'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'If-Match': String(item.revision), 'Idempotency-Key': (globalThis.crypto?.randomUUID?.() ?? String(Date.now())) },
body: '{}',
});
if (!response.ok) { setMessage(copy.alerts.operationError); return; }
setMessage(copy.alerts.operationSaved);
await load();
if (selected?.id === item.id) await choose(item);
}
return <section className="alert-operations" aria-labelledby="alert-operations-title">
<div className="alert-operation-summary" aria-label={copy.alerts.operationSummary}>
<button type="button" aria-pressed={view === 'active'} onClick={() => setView('active')}><span>{copy.alerts.activeAlerts}</span><strong>{activeCount}</strong><small>{copy.alerts.activeAlertsDetail}</small></button>
<button className="alert-summary-critical" type="button" aria-pressed={view === 'critical'} onClick={() => setView('critical')}><span>{copy.alerts.criticalAlerts}</span><strong>{criticalCount}</strong><small>{copy.alerts.criticalAlertsDetail}</small></button>
<button type="button" aria-pressed={view === 'acknowledged'} onClick={() => setView('acknowledged')}><span>{copy.alerts.acknowledgedAlerts}</span><strong>{acknowledgedCount}</strong><small>{copy.alerts.acknowledgedAlertsDetail}</small></button>
</div>
<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>
{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>
<button className="button button--secondary alert-operation-action" type="button" onClick={() => void operate(item, item.state !== 'acknowledged')}>{item.state === 'acknowledged' ? copy.alerts.unacknowledge : copy.alerts.acknowledge}</button>
</li>)}</ul>}
{filtered.length > visible.length && <p className="card-copy">{copy.alerts.resultLimit.replace('{count}', String(visible.length)).replace('{total}', String(filtered.length))}</p>}
{selected && <div className="alert-detail" aria-live="polite"><h3>{selected.ruleName}</h3><p>{presentStatus(selected.state)} · {presentReason(selected.reason)}</p><small>{selected.occurrences?.length ?? 0} {plural(selected.occurrences?.length ?? 0, copy.alerts.occurrence, copy.alerts.occurrences)} · {copy.alerts.revision}: {selected.revision}</small></div>}
{message && <p className="form-message" role="status">{message}</p>}
</div>
</section>;
}
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { AlertControlsPanel } from './AlertControlsPanel';
import { AlertOperationsPanel } from './AlertOperationsPanel';
import { queryValue, replaceListQuery } from './listQuery';
import { presentMetric, presentReason, presentStatus, presentUnit } from './presentation';
type InputType = 'metric' | 'entity-status' | 'event' | 'datasource-health';
type Condition = { inputType: InputType; metric?: string; operator: string; threshold: number | string | null; recoveryThreshold?: number | null; aggregation?: string; windowSeconds?: number };
type Rule = {
id: string; schemaVersion: number; name: string; enabled: boolean; severity: string; scope: Record<string, unknown>;
condition: Condition; evaluationIntervalSeconds: number; pendingSeconds: number; resolveSeconds: number; cooldownSeconds?: number;
unknownBehavior: string; groupBy: string[]; suppressWhen: string[]; message: { titleKey: string; bodyKey: string };
revision: number; currentVersion: number; updatedAt?: string;
};
type DraftRule = Omit<Rule, 'revision' | 'currentVersion' | 'updatedAt'>;
type Preview = { wouldFire: boolean; state: string; reason: string };
type MetricDefinition = { semanticName: string; unit: string; defaultAggregation?: string };
const causes = [
{ value: 'host.unreachable', label: copy.alerts.causeHost },
{ value: 'dns.failure', label: copy.alerts.causeDns },
{ value: 'source.unavailable', label: copy.alerts.causeSource },
];
const orderedOperators = new Set(['>', '>=', '<', '<=']);
const supportedOperators = new Set(['>', '>=', '<', '<=', '==', '!=', 'matches', 'absent']);
const alertSections = ['operations', 'rules', 'controls'] as const;
type AlertSection = typeof alertSections[number];
function newRule(): DraftRule {
return {
id: globalThis.crypto?.randomUUID?.() ?? '00000000-0000-4000-8000-000000000000',
schemaVersion: 1, name: '', enabled: false, severity: 'attention', scope: {},
condition: { inputType: 'metric', metric: '', operator: '>', threshold: 80, aggregation: 'avg', windowSeconds: 60 },
evaluationIntervalSeconds: 30, pendingSeconds: 60, resolveSeconds: 120, cooldownSeconds: 300,
unknownBehavior: 'retain-firing-as-unknown', groupBy: [], suppressWhen: [],
message: { titleKey: 'alerts.rule.title', bodyKey: 'alerts.rule.body' },
};
}
export function AlertRulesPage() {
const [section, setSection] = useState<AlertSection>(() => queryValue('section', alertSections, 'operations') as AlertSection);
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<DraftRule>(newRule);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'error' | 'unauthorized'>('loading');
const [message, setMessage] = useState('');
const [preview, setPreview] = useState<Preview | null>(null);
const [previewValue, setPreviewValue] = useState('90');
const [busy, setBusy] = useState(false);
const [metrics, setMetrics] = useState<MetricDefinition[]>([]);
const [metricState, setMetricState] = useState<'loading' | 'ready' | 'error'>('loading');
const selected = useMemo(() => rules.find((rule) => rule.id === selectedId), [rules, selectedId]);
useEffect(() => {
const controller = new AbortController();
fetch('/api/v1/alert-rules?limit=100', { signal: controller.signal })
.then((response) => {
if (response.status === 401 || response.status === 403) { setState('unauthorized'); throw new Error('unauthorized'); }
if (!response.ok) throw new Error('alert-rules');
return response.json() as Promise<{ items?: Rule[] }>;
})
.then((data) => {
const items = data.items ?? [];
setRules(items);
if (items[0]) { setSelectedId(items[0].id); setDraft(toDraft(items[0])); }
setState('ready');
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (error instanceof Error && error.message === 'unauthorized') return;
setState('error');
});
return () => controller.abort();
}, []);
useEffect(() => {
replaceListQuery({ section: section === 'operations' ? '' : section });
}, [section]);
useEffect(() => {
const controller = new AbortController();
fetch('/api/v1/metrics/catalog', { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error('metric-catalog');
return response.json() as Promise<{ metrics?: MetricDefinition[] }>;
})
.then((data) => {
setMetrics((data.metrics ?? []).slice().sort((a, b) => presentMetric(a.semanticName).localeCompare(presentMetric(b.semanticName), 'nl-BE')));
setMetricState('ready');
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setMetricState('error');
});
return () => controller.abort();
}, []);
function chooseRule(rule: Rule) {
setSelectedId(rule.id);
setDraft(toDraft(rule));
setPreview(null);
setMessage('');
}
function updateField<K extends keyof DraftRule>(key: K, value: DraftRule[K]) {
setDraft((current) => ({ ...current, [key]: value }));
}
function updateCondition<K extends keyof Condition>(key: K, value: Condition[K]) {
setDraft((current) => ({ ...current, condition: { ...current.condition, [key]: value } }));
}
function chooseInputType(inputType: InputType) {
const presets: Record<InputType, Condition> = {
metric: { inputType, metric: '', operator: '>', threshold: 80, recoveryThreshold: null, aggregation: 'avg', windowSeconds: 60 },
event: { inputType, operator: '>=', threshold: 3, recoveryThreshold: null, aggregation: 'count', windowSeconds: 900 },
'entity-status': { inputType, operator: '==', threshold: 'degraded', recoveryThreshold: null, aggregation: 'none', windowSeconds: 60 },
'datasource-health': { inputType, operator: '==', threshold: 'stale', recoveryThreshold: null, aggregation: 'none', windowSeconds: 120 },
};
setDraft((current) => ({ ...current, condition: presets[inputType] }));
}
function chooseOperator(operator: string) {
setDraft((current) => ({
...current,
condition: {
...current.condition,
operator,
threshold: operator === 'absent' ? null : operator === 'matches' ? String(current.condition.threshold ?? '') : current.condition.threshold,
recoveryThreshold: orderedOperators.has(operator) ? current.condition.recoveryThreshold : null,
},
}));
}
async function save() {
const validation = validateDraft(draft, metrics);
if (validation) { setMessage(validation); return; }
setBusy(true); setMessage('');
try {
const selectedRule = selectedId ? rules.find((rule) => rule.id === selectedId) : undefined;
const response = await fetch(selectedRule ? '/api/v1/alert-rules/' + encodeURIComponent(selectedRule.id) : '/api/v1/alert-rules', {
method: selectedRule ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json', ...(selectedRule ? { 'If-Match': String(selectedRule.revision) } : {}) },
body: JSON.stringify(draft),
});
if (!response.ok) throw new Error(response.status === 409 ? copy.alerts.conflict : copy.alerts.saveError);
const data = await response.json() as { rule: Rule };
setRules((current) => selectedRule ? current.map((rule) => rule.id === data.rule.id ? data.rule : rule) : [data.rule, ...current]);
setSelectedId(data.rule.id);
setDraft(toDraft(data.rule));
setMessage(copy.alerts.saved);
} catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.saveError); }
finally { setBusy(false); }
}
function toggleCause(value: string, checked: boolean) {
updateField('suppressWhen', checked
? [...new Set([...draft.suppressWhen, value])]
: draft.suppressWhen.filter((cause) => cause !== value));
}
async function toggle(enabled: boolean) {
if (!selected) return;
if (!window.confirm(enabled ? copy.alerts.confirmRuleEnable : copy.alerts.confirmRuleDisable)) return;
setBusy(true); setMessage('');
try {
const response = await fetch('/api/v1/alert-rules/' + encodeURIComponent(selected.id) + '/' + (enabled ? 'enable' : 'disable'), {
method: 'POST', headers: { 'If-Match': String(selected.revision) },
});
if (!response.ok) throw new Error(response.status === 409 ? copy.alerts.conflict : copy.alerts.toggleError);
const data = await response.json() as { rule: Rule };
setRules((current) => current.map((rule) => rule.id === data.rule.id ? data.rule : rule));
setDraft(toDraft(data.rule));
setMessage(copy.alerts.stateSaved);
} catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.toggleError); }
finally { setBusy(false); }
}
async function testPreview() {
setBusy(true); setMessage('');
try {
const response = await fetch('/api/v1/alert-rules/' + encodeURIComponent(draft.id) + '/test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rule: draft, value: parsePreviewValue(previewValue) }),
});
if (!response.ok) throw new Error(copy.alerts.previewError);
const data = await response.json() as { preview: Preview };
setPreview(data.preview);
} catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.previewError); }
finally { setBusy(false); }
}
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.alerts.loading}</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">!</span><h1>{copy.alerts.unauthorizedTitle}</h1><p>{copy.alerts.unauthorizedDetail}</p></section>;
if (state === 'error') return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.alerts.errorTitle}</h1><p>{copy.alerts.errorDetail}</p></section>;
return <><header className="page-intro"><p className="eyebrow">{copy.alerts.eyebrow}</p><h1>{copy.alerts.title}</h1><p className="intro">{copy.alerts.intro}</p></header>
<nav className="alert-section-nav" aria-label={copy.alerts.sectionNavigation}>
<button type="button" aria-current={section === 'operations' ? 'page' : undefined} onClick={() => setSection('operations')}><span>{copy.alerts.sectionOperations}</span><small>{copy.alerts.sectionOperationsDetail}</small></button>
<button type="button" aria-current={section === 'rules' ? 'page' : undefined} onClick={() => setSection('rules')}><span>{copy.alerts.sectionRules}</span><small>{copy.alerts.sectionRulesDetail}</small></button>
<button type="button" aria-current={section === 'controls' ? 'page' : undefined} onClick={() => setSection('controls')}><span>{copy.alerts.sectionControls}</span><small>{copy.alerts.sectionControlsDetail}</small></button>
</nav>
{section === 'operations' && <AlertOperationsPanel />}
{section === 'rules' && <section className="alert-layout">
<div className="card" aria-labelledby="alert-rules-title">
<div className="card-heading"><div><p className="card-kicker">{copy.alerts.rules}</p><h2 id="alert-rules-title">{copy.alerts.ruleList}</h2></div><button className="button button--secondary" type="button" onClick={() => { setSelectedId(null); setDraft(newRule()); setPreview(null); }}>{copy.alerts.newRule}</button></div>
{rules.length === 0 ? <p className="card-copy">{copy.alerts.noRules}</p> : <ul className="inventory-list">{rules.map((rule) => <li key={rule.id}><button className="alert-rule-button" type="button" onClick={() => chooseRule(rule)}><strong>{rule.name || copy.alerts.unnamed}</strong><small>{presentStatus(rule.severity)} · v{rule.currentVersion} · {rule.enabled ? copy.alerts.enabled : copy.alerts.disabled}</small></button><span className={rule.enabled ? 'rule-state rule-state--enabled' : 'rule-state'}>{rule.enabled ? copy.alerts.enabled : copy.alerts.disabled}</span></li>)}</ul>}
</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>
<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>
<label className="form-grid__wide">{copy.alerts.inputType}<select id="alert-rule-input-type" name="inputType" value={draft.condition.inputType} onChange={(event) => chooseInputType(event.target.value as InputType)}><option value="metric">{copy.alerts.inputMetric}</option><option value="event">{copy.alerts.inputEvent}</option><option value="entity-status">{copy.alerts.inputEntityStatus}</option><option value="datasource-health">{copy.alerts.inputDatasourceHealth}</option></select><small>{copy.alerts.inputTypeHelp}</small></label>
{draft.condition.inputType === 'metric' && <label className="form-grid__wide">{copy.alerts.metric}<select required id="alert-rule-metric" name="metric" value={draft.condition.metric ?? ''} disabled={metricState !== 'ready'} onChange={(event) => { const metric = metrics.find((item) => item.semanticName === event.target.value); updateCondition('metric', event.target.value); if (metric?.defaultAggregation) updateCondition('aggregation', metric.defaultAggregation); }}><option value="">{metricState === 'loading' ? copy.alerts.metricLoading : metricState === 'error' ? copy.alerts.metricUnavailable : copy.alerts.chooseMetric}</option>{metrics.map((metric) => <option key={metric.semanticName} value={metric.semanticName}>{presentMetric(metric.semanticName)} ({presentUnit(metric.unit)})</option>)}</select><small>{copy.alerts.metricHelp}</small></label>}
<label>{copy.alerts.operator}<select id="alert-rule-operator" name="operator" value={draft.condition.operator} onChange={(event) => chooseOperator(event.target.value)}><option value=">">{copy.alerts.greaterThan}</option><option value=">=">{copy.alerts.greaterThanOrEqual}</option><option value="<">{copy.alerts.lessThan}</option><option value="<=">{copy.alerts.lessThanOrEqual}</option><option value="==">{copy.alerts.equalTo}</option><option value="!=">{copy.alerts.notEqualTo}</option><option value="matches">{copy.alerts.matches}</option><option value="absent">{copy.alerts.absent}</option></select></label>
<label>{copy.alerts.threshold}<input autoComplete="off" id="alert-rule-threshold" name="threshold" type={draft.condition.inputType === 'metric' || orderedOperators.has(draft.condition.operator) ? 'number' : 'text'} disabled={draft.condition.operator === 'absent'} value={String(draft.condition.threshold ?? '')} onChange={(event) => updateCondition('threshold', event.target.type === 'number' ? Number(event.target.value) : event.target.value)} /></label>{orderedOperators.has(draft.condition.operator) && <label>{copy.alerts.recoveryThreshold}<input autoComplete="off" id="alert-rule-recovery-threshold" name="recoveryThreshold" type="number" value={String(draft.condition.recoveryThreshold ?? '')} onChange={(event) => updateCondition('recoveryThreshold', event.target.value === '' ? null : Number(event.target.value))} /><small>{copy.alerts.recoveryThresholdHelp}</small></label>}
<label>{copy.alerts.interval}<input autoComplete="off" id="alert-rule-interval" name="evaluationIntervalSeconds" type="number" min={5} max={3600} value={draft.evaluationIntervalSeconds} onChange={(event) => updateField('evaluationIntervalSeconds', Number(event.target.value))} /></label>
<label>{copy.alerts.pending}<input autoComplete="off" id="alert-rule-pending" name="pendingSeconds" type="number" min={0} value={draft.pendingSeconds} onChange={(event) => updateField('pendingSeconds', Number(event.target.value))} /></label>
<label>{copy.alerts.resolve}<input autoComplete="off" id="alert-rule-resolve" name="resolveSeconds" type="number" min={0} value={draft.resolveSeconds} onChange={(event) => updateField('resolveSeconds', Number(event.target.value))} /></label><label>{copy.alerts.cooldown}<input autoComplete="off" id="alert-rule-cooldown" name="cooldownSeconds" type="number" min={0} value={draft.cooldownSeconds ?? 0} onChange={(event) => updateField('cooldownSeconds', Number(event.target.value))} /><small>{copy.alerts.cooldownHelp}</small></label>
<fieldset className="form-grid__wide guided-options"><legend>{copy.alerts.suppressWhen}</legend>{causes.map((cause) => <label key={cause.value}><input type="checkbox" checked={draft.suppressWhen.includes(cause.value)} onChange={(event) => toggleCause(cause.value, event.target.checked)} />{cause.label}</label>)}<small>{copy.alerts.suppressWhenHelp}</small></fieldset>
<label>{copy.alerts.unknownBehavior}<select id="alert-rule-unknown-behavior" name="unknownBehavior" value={draft.unknownBehavior} onChange={(event) => updateField('unknownBehavior', event.target.value)}><option value="retain-firing-as-unknown">{copy.alerts.unknownRetain}</option><option value="become-unknown">{copy.alerts.unknownBecome}</option><option value="ignore-short-gap">{copy.alerts.unknownIgnore}</option></select></label>
<details className="form-grid__wide technical-details"><summary>{copy.alerts.technicalDetails}</summary><dl><div><dt>{copy.alerts.titleKey}</dt><dd><code>{draft.message.titleKey}</code></dd></div><div><dt>{copy.alerts.bodyKey}</dt><dd><code>{draft.message.bodyKey}</code></dd></div>{draft.suppressWhen.map((cause) => <div key={cause}><dt>{copy.alerts.suppressWhen}</dt><dd><code>{cause}</code></dd></div>)}</dl></details>
</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>
</section>}
{section === 'controls' && <AlertControlsPanel />}
</>;
}
function toDraft(rule: Rule): DraftRule {
const { revision: _revision, currentVersion: _version, updatedAt: _updatedAt, ...draft } = rule;
return { ...draft, cooldownSeconds: rule.cooldownSeconds ?? 0, condition: { ...draft.condition, recoveryThreshold: rule.condition.recoveryThreshold ?? null } };
}
export function validateDraft(draft: DraftRule, metrics: MetricDefinition[]): string {
if (!draft.name.trim()) return copy.alerts.invalidName;
if (draft.condition.inputType === 'metric' && (!draft.condition.metric || !metrics.some((metric) => metric.semanticName === draft.condition.metric))) return copy.alerts.invalidMetric;
if (draft.condition.inputType !== 'metric' && draft.condition.metric) return copy.alerts.invalidMetric;
if (!supportedOperators.has(draft.condition.operator)) return copy.alerts.invalidThreshold;
const threshold = draft.condition.threshold;
if (draft.condition.operator === 'absent' ? threshold !== null : orderedOperators.has(draft.condition.operator) ? typeof threshold !== 'number' || !Number.isFinite(threshold) : (typeof threshold !== 'number' || !Number.isFinite(threshold)) && (typeof threshold !== 'string' || !threshold.trim() || threshold.length > 160)) return copy.alerts.invalidThreshold;
if (draft.condition.operator === 'matches') {
try { new RegExp(String(threshold)); } catch { return copy.alerts.invalidThreshold; }
}
const recovery = draft.condition.recoveryThreshold;
if (recovery != null && (typeof threshold !== 'number' || !Number.isFinite(recovery) || !orderedOperators.has(draft.condition.operator) || ((draft.condition.operator === '>' || draft.condition.operator === '>=') && recovery >= threshold) || ((draft.condition.operator === '<' || draft.condition.operator === '<=') && recovery <= threshold))) return copy.alerts.invalidThreshold;
const times = [draft.evaluationIntervalSeconds, draft.pendingSeconds, draft.resolveSeconds, draft.cooldownSeconds ?? 0];
if (draft.evaluationIntervalSeconds < 5 || draft.evaluationIntervalSeconds > 3600 || times.some((value) => !Number.isInteger(value) || value < 0 || value > 2_592_000)) return copy.alerts.invalidTiming;
return '';
}
function parsePreviewValue(value: string): number | string {
const numeric = Number(value);
return value.trim() !== '' && Number.isFinite(numeric) ? numeric : value;
}
+807
View File
@@ -0,0 +1,807 @@
import { Component, Suspense, lazy, type CSSProperties, type ErrorInfo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MetricWidget, metricStatus, RankedListWidget, StatusGridWidget, type MetricWidgetProps, type RankedListItem, type StatusGridItem } from './MetricWidgets';
import { DashboardRuntimeWidget, type RuntimeState } from './DashboardRuntimeWidget';
import { copy } from './copy';
import { routeFromLocation, type RoutePath } from './routes';
import { HostPage } from './HostPage';
import { ArrayPage } from './ArrayPage';
import { DiskDetailPage, DiskPage } from './DiskPage';
import { PoolPage } from './PoolPage';
import { SharePage } from './SharePage';
import { StoragePage } from './StoragePage';
import { CapacityPage } from './CapacityPage';
import { StorageMapWidget, TemperatureHeatmap, type HeatmapPoint, type StorageMapNode } from './StorageVisuals';
import { ContainerDetailPage, ContainerPage } from './ContainerPage';
import { ApplicationPage } from './ApplicationPage';
import { ServicePage } from './ServicePage';
import type { TopologyData } from './TopologyPage';
import { NetworkPage, NetworkHealthWidget, type NetworkData } from './NetworkPage';
import { IncidentPage } from './IncidentPage';
import { OnboardingPage } from './OnboardingPage';
import { SystemStatusPage } from './SystemStatusPage';
import { InventoryPage } from './InventoryPage';
import { EventsPage } from './EventsPage';
import { NotFoundPage } from './NotFoundPage';
import { formatDateTime } from './locale';
import { installSessionWatcher, onUnauthenticated } from './auth';
import { AuthNoticeBanner, SignInButton } from './SignIn';
import { aggregateStatus, refreshSystemStatus, statusProblems, useSystemStatus } from './systemStatus';
import { operationalStorageState, presentComponent, presentReason, presentStatus } from './presentation';
import { wallboardColumns, wallboardPlacement, wallboardSlideIndex } from './wallboardLayout';
import { OperationalSignalPath, type OperationalSignalStage } from './OperationalSignalPath';
import { containerSignalTone, signalToneFromState, signalToneRank, sourceSignalTone, worstSignalTone, type SignalTone } from './overviewSignals';
// Heavy, rarely-used surfaces. The wallboard and mobile personas never execute
// the editor stack, the alert-rule editor, the topology graph or the process
// explorer, so those stay out of the initial chunk (FRONTEND_STANDARDS "Charts").
const DashboardEditor = lazy(() => import('./DashboardEditor').then((module) => ({ default: module.DashboardEditor })));
const AlertRulesPage = lazy(() => import('./AlertRulesPage').then((module) => ({ default: module.AlertRulesPage })));
const TopologyPage = lazy(() => import('./TopologyPage').then((module) => ({ default: module.TopologyPage })));
const TopologyWidget = lazy(() => import('./TopologyPage').then((module) => ({ default: module.TopologyWidget })));
const ProcessPage = lazy(() => import('./ProcessPage').then((module) => ({ default: module.ProcessPage })));
const navigation = [
{ path: '/', label: copy.navigation.overview, icon: '⌂', group: copy.navigation.groups.command },
{ path: '/dashboards', label: copy.navigation.dashboards, icon: '▦', group: copy.navigation.groups.command },
{ path: '/host', label: copy.navigation.host, icon: '▣', group: copy.navigation.groups.infrastructure },
{ path: '/array', label: copy.navigation.array, icon: '▥', group: copy.navigation.groups.infrastructure },
{ path: '/disks', label: copy.navigation.disks, icon: '◉', group: copy.navigation.groups.infrastructure },
{ path: '/pools', label: copy.navigation.pools, icon: '◫', group: copy.navigation.groups.infrastructure },
{ path: '/shares', label: copy.navigation.shares, icon: '⇄', group: copy.navigation.groups.infrastructure },
{ path: '/storage', label: copy.navigation.storage, icon: '▤', group: copy.navigation.groups.infrastructure },
{ path: '/capacity', label: copy.navigation.capacity, icon: '⌁', group: copy.navigation.groups.infrastructure },
{ path: '/network', label: copy.navigation.network, icon: '⌘', group: copy.navigation.groups.infrastructure },
{ path: '/processes', label: copy.navigation.processes, icon: '≋', group: copy.navigation.groups.workloads },
{ path: '/containers', label: copy.navigation.containers, icon: '⬡', group: copy.navigation.groups.workloads },
{ path: '/applications', label: copy.navigation.applications, icon: '◆', group: copy.navigation.groups.workloads },
{ path: '/services', label: copy.navigation.services, icon: '◉', group: copy.navigation.groups.services },
{ path: '/topology', label: copy.navigation.topology, icon: '⌬', group: copy.navigation.groups.services },
{ path: '/alerts', label: copy.navigation.alerts, icon: '!', group: copy.navigation.groups.response },
{ path: '/events', label: copy.navigation.events, icon: '≡', group: copy.navigation.groups.response },
{ path: '/incidents', label: copy.navigation.incidents, icon: '△', group: copy.navigation.groups.response },
{ path: '/inventory', label: copy.navigation.inventory, icon: '▥', group: copy.navigation.groups.manage },
{ path: '/wallboard', label: copy.navigation.wallboard, icon: '▰', group: copy.navigation.groups.manage },
{ path: '/settings', label: copy.navigation.settings, icon: '⚙', group: copy.navigation.groups.manage },
{ path: '/status', label: copy.navigation.status, icon: '♥', group: copy.navigation.groups.manage },
{ path: '/onboarding', label: copy.navigation.onboarding, icon: '→', group: copy.navigation.groups.manage },
] as const satisfies ReadonlyArray<{ path: RoutePath; label: string; icon: string; group: string }>;
const mobilePrimaryPaths = new Set<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>;
}
function PageIntro({ eyebrow, title, intro }: { eyebrow: string; title: string; intro: string }) {
return <header className="page-intro"><p className="eyebrow">{eyebrow}</p><h1>{title}</h1><p className="intro">{intro}</p></header>;
}
type OverviewSource = { state?: string; freshness?: string; reason?: string };
type OverviewHost = { identity?: { name?: string }; cpu?: { totalPercent?: number }; memory?: { utilizationPercent?: number }; source?: OverviewSource };
type OverviewContainer = { id?: string; state?: string; health?: string; intentionalStop?: boolean };
type OverviewPool = { id: string; name: string; state: string; capacitySeverity?: string; utilizationPercent: number };
type OverviewService = { id: string; name: string; state: string };
type OverviewIncident = { id: string; title: string; severity: string; startedAt: string };
type OverviewContainerSnapshot = { source?: OverviewSource; containers?: OverviewContainer[]; total?: number; nextCursor?: string };
type OverviewPoolSnapshot = { source?: OverviewSource; pools?: OverviewPool[]; total?: number };
type OverviewServiceSnapshot = { capabilityState?: string; configurationState?: string; reason?: string; services?: OverviewService[]; total?: number };
type OverviewResourceState = 'loading' | 'ready' | 'unavailable' | 'unauthorized' | 'forbidden';
type OverviewResource = 'host' | 'containers' | 'pools' | 'services' | 'incidents';
type OverviewData = {
host?: OverviewHost;
containers: OverviewContainer[];
containerSource?: OverviewSource;
containerTotal: number;
containersPartial: boolean;
pools: OverviewPool[];
poolSource?: OverviewSource;
poolTotal: number;
poolsPartial: boolean;
services: OverviewService[];
serviceTotal: number;
servicesPartial: boolean;
serviceCapability?: string;
serviceConfiguration?: string;
incidents: OverviewIncident[];
incidentsPartial: boolean;
resources: Record<OverviewResource, OverviewResourceState>;
};
const loadingOverviewResources: Record<OverviewResource, OverviewResourceState> = {
host: 'loading', containers: 'loading', pools: 'loading', services: 'loading', incidents: 'loading',
};
const overviewInitialData: OverviewData = {
containers: [], containerTotal: 0, containersPartial: false,
pools: [], poolTotal: 0, poolsPartial: false,
services: [], serviceTotal: 0, servicesPartial: false,
incidents: [], incidentsPartial: false,
resources: loadingOverviewResources,
};
const OVERVIEW_REFRESH_MS = 30_000;
const OVERVIEW_REQUEST_TIMEOUT_MS = 10_000;
const CONTAINER_PAGE_LIMIT = 100;
const CONTAINER_MAX_PAGES = 3;
type ReadResult<T> = { state: OverviewResourceState; data?: T };
function collectionExtent(reported: number | undefined, count: number): { total: number; partial: boolean } {
const valid = Number.isInteger(reported) && (reported ?? -1) >= count;
const total = valid ? reported as number : count;
return { total, partial: !valid || total > count };
}
function useOverviewData(): { data: OverviewData; refresh: () => void } {
const [data, setData] = useState<OverviewData>(overviewInitialData);
const [generation, setGeneration] = useState(0);
const refresh = useCallback(() => {
setData((current) => ({ ...current, resources: { ...loadingOverviewResources } }));
setGeneration((current) => current + 1);
}, []);
useEffect(() => {
const controller = new AbortController();
const read = async <T,>(url: string): Promise<ReadResult<T>> => {
const requestController = new AbortController();
const abortRequest = () => requestController.abort();
if (controller.signal.aborted) abortRequest();
else controller.signal.addEventListener('abort', abortRequest, { once: true });
const timeout = window.setTimeout(abortRequest, OVERVIEW_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: requestController.signal, cache: 'no-store' });
if (requestController.signal.aborted) return { state: 'unavailable' };
if (response.status === 401) return { state: 'unauthorized' };
if (response.status === 403) return { state: 'forbidden' };
return response.ok ? { state: 'ready', data: await response.json() as T } : { state: 'unavailable' };
} catch {
return { state: 'unavailable' };
} finally {
window.clearTimeout(timeout);
controller.signal.removeEventListener('abort', abortRequest);
}
};
const readContainers = async (): Promise<ReadResult<{ source?: OverviewSource; items: OverviewContainer[]; total: number; partial: boolean }>> => {
const items: OverviewContainer[] = [];
const seen = new Set<string>();
let source: OverviewSource | undefined;
let expectedTotal: number | undefined;
let after = '';
let complete = false;
let inconsistent = false;
for (let page = 0; page < CONTAINER_MAX_PAGES; page += 1) {
const params = new URLSearchParams({ limit: String(CONTAINER_PAGE_LIMIT), sort: 'name' });
if (after) params.set('after', after);
const result = await read<OverviewContainerSnapshot>('/api/v1/containers?' + params);
if (result.state !== 'ready' || !result.data) return { state: result.state };
const pageItems = result.data.containers ?? [];
const reportedTotal = result.data.total;
if (!Number.isInteger(reportedTotal) || (reportedTotal ?? -1) < pageItems.length) {
inconsistent = true;
} else if (expectedTotal == null) {
expectedTotal = reportedTotal as number;
} else {
if (reportedTotal !== expectedTotal) inconsistent = true;
expectedTotal = Math.max(expectedTotal, reportedTotal as number);
}
if (!source || signalToneRank[sourceSignalTone(result.data.source)] < signalToneRank[sourceSignalTone(source)]) source = result.data.source;
for (const item of pageItems) {
const id = item.id?.trim();
if (!id) {
inconsistent = true;
items.push(item);
} else if (seen.has(id)) {
inconsistent = true;
} else {
seen.add(id);
items.push(item);
}
}
const next = result.data.nextCursor?.trim() ?? '';
if (!next) {
complete = true;
break;
}
if (next === after) {
inconsistent = true;
break;
}
after = next;
}
const total = Math.max(expectedTotal ?? 0, items.length);
return { state: 'ready', data: { source, items, total, partial: !complete || inconsistent || items.length < total } };
};
void read<OverviewHost>('/api/v1/host').then((result) => {
if (controller.signal.aborted) return;
setData((current) => ({ ...current, host: result.data, resources: { ...current.resources, host: result.state } }));
});
void readContainers().then((result) => {
if (controller.signal.aborted) return;
setData((current) => ({ ...current, containers: result.data?.items ?? [], containerSource: result.data?.source, containerTotal: result.data?.total ?? 0, containersPartial: result.data?.partial ?? false, resources: { ...current.resources, containers: result.state } }));
});
void read<OverviewPoolSnapshot>('/api/v1/pools?limit=64').then((result) => {
if (controller.signal.aborted) return;
const items = result.data?.pools ?? [];
const extent = collectionExtent(result.data?.total, items.length);
setData((current) => ({ ...current, pools: items, poolSource: result.data?.source, poolTotal: extent.total, poolsPartial: result.state === 'ready' && extent.partial, resources: { ...current.resources, pools: result.state } }));
});
void read<OverviewServiceSnapshot>('/api/v1/services?limit=100').then((result) => {
if (controller.signal.aborted) return;
const items = result.data?.services ?? [];
const extent = collectionExtent(result.data?.total, items.length);
setData((current) => ({ ...current, services: items, serviceTotal: extent.total, servicesPartial: result.state === 'ready' && extent.partial, serviceCapability: result.data?.capabilityState, serviceConfiguration: result.data?.configurationState, resources: { ...current.resources, services: result.state } }));
});
void read<{ items?: OverviewIncident[] }>('/api/v1/incidents?limit=100&status=open').then((result) => {
if (controller.signal.aborted) return;
const items = result.data?.items ?? [];
setData((current) => ({ ...current, incidents: items, incidentsPartial: result.state === 'ready' && items.length >= 100, resources: { ...current.resources, incidents: result.state } }));
});
return () => controller.abort();
}, [generation]);
useEffect(() => {
const timer = window.setInterval(() => {
if (document.visibilityState === 'visible') setGeneration((current) => current + 1);
}, OVERVIEW_REFRESH_MS);
return () => window.clearInterval(timer);
}, []);
return { data, refresh };
}
function signalResourceLabel(state: OverviewResourceState, tone: SignalTone): string {
if (state === 'loading') return copy.overview.signalPathLoading;
if (state === 'unauthorized') return copy.overview.signalPathUnauthorized;
if (state === 'forbidden') return copy.overview.signalPathForbidden;
if (state === 'unavailable') return copy.overview.signalPathUnavailable;
return presentStatus(tone === 'attention' ? 'attention' : tone);
}
function resourceStateDetail(state: OverviewResourceState): string | undefined {
if (state === 'loading') return copy.overview.resourceLoadingDetail;
if (state === 'unauthorized') return copy.overview.resourceUnauthorizedDetail;
if (state === 'forbidden') return copy.overview.resourceForbiddenDetail;
if (state === 'unavailable') return copy.overview.resourceUnavailableDetail;
return undefined;
}
function thresholdTone(values: Array<number | undefined>): SignalTone {
const usable = values.filter((value): value is number => value != null && Number.isFinite(value));
if (usable.length !== values.length) return 'unknown';
if (usable.some((value) => value >= 95)) return 'critical';
if (usable.some((value) => value >= 85)) return 'attention';
return 'healthy';
}
function metric(value: number | undefined, suffix = '%'): string {
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + suffix;
}
function boundedRatio(known: number, total: number, partial: boolean): string {
return `${partial ? '≥' : ''}${known}/${total}`;
}
function signalCollectionLabel(state: OverviewResourceState, tone: SignalTone, options: { partial?: boolean; notConfigured?: boolean; emptyLabel?: string } = {}): string {
if (state !== 'ready') return signalResourceLabel(state, tone);
if (tone === 'critical' || tone === 'attention' || tone === 'stale') return presentStatus(tone);
if (options.notConfigured) return copy.overview.signalPathNotConfigured;
if (options.partial) return copy.overview.signalPathPartial;
if (options.emptyLabel) return options.emptyLabel;
return presentStatus(tone);
}
function OverviewPage() {
const snapshot = useSystemStatus();
const status = aggregateStatus(snapshot);
const { data: overview, refresh: refreshOverview } = useOverviewData();
const resourcesLoading = snapshot.state === 'loading' || Object.values(overview.resources).some((state) => state === 'loading');
const overviewNeedsAuthentication = snapshot.state === 'unauthorized' || Object.values(overview.resources).some((state) => state === 'unauthorized');
const systemResourceState: OverviewResourceState = snapshot.state === 'ready' ? 'ready' : snapshot.state === 'loading' ? 'loading' : snapshot.state === 'unauthorized' ? 'unauthorized' : snapshot.state === 'forbidden' ? 'forbidden' : 'unavailable';
const poolRank: Record<string, number> = { critical: 0, faulted: 0, degraded: 1, attention: 2, unknown: 3 };
const poolProblems = (overview.resources.pools === 'ready' && sourceSignalTone(overview.poolSource) === 'healthy' ? overview.pools : [])
.map((pool) => ({ pool, state: operationalStorageState(pool.state, pool.capacitySeverity) }))
.filter(({ state }) => state !== 'healthy' && state !== 'normal')
.sort((a, b) => (poolRank[a.state] ?? 4) - (poolRank[b.state] ?? 4))
.map(({ pool, state }) => ({ id: 'pool:' + pool.id, label: `${pool.name}: ${presentStatus(state)}`, reason: state === 'critical' || state === 'faulted' ? copy.overview.poolCapacityCritical : state === 'attention' ? copy.overview.poolCapacityAttention : presentReason('source_health_unknown') }));
const resourceLabels: Record<OverviewResource, string> = { host: copy.navigation.host, containers: copy.overview.signalWorkloads, pools: copy.overview.signalStorage, services: copy.navigation.services, incidents: copy.overview.signalIncidents };
const resourceProblems = (Object.keys(overview.resources) as OverviewResource[]).flatMap((resource) => {
const state = overview.resources[resource];
if (state !== 'unavailable' && state !== 'unauthorized' && state !== 'forbidden') return [];
return [{ id: `resource:${resource}`, label: `${resourceLabels[resource]}: ${signalResourceLabel(state, 'unknown')}`, reason: resourceStateDetail(state) ?? copy.overview.resourceUnavailableDetail }];
});
const systemProblems = systemResourceState === 'ready' || systemResourceState === 'loading' ? [] : [{ id: 'resource:system-status', label: `${copy.overview.sources}: ${signalResourceLabel(systemResourceState, 'unknown')}`, reason: resourceStateDetail(systemResourceState) ?? copy.overview.resourceUnavailableDetail }];
const partialProblems: Array<{ id: string; label: string; reason: string }> = [];
if (overview.containersPartial) partialProblems.push({ id: 'partial:containers', label: `${copy.overview.signalWorkloads}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
if (overview.poolsPartial) partialProblems.push({ id: 'partial:pools', label: `${copy.overview.signalStorage}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
if (overview.servicesPartial) partialProblems.push({ id: 'partial:services', label: `${copy.navigation.services}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
if (overview.incidentsPartial) partialProblems.push({ id: 'partial:incidents', label: `${copy.overview.signalIncidents}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
const problems = [...systemProblems, ...resourceProblems, ...partialProblems, ...poolProblems, ...statusProblems(snapshot.status)].slice(0, 10);
const poolUsage = overview.pools.length ? Math.max(...overview.pools.map((pool) => pool.utilizationPercent)) : undefined;
const runningContainers = overview.containers.filter((item) => item.state?.toLowerCase() === 'running').length;
const availableServices = overview.services.filter((item) => item.state?.toLowerCase() === 'up').length;
const sourceLags = snapshot.status?.sourceLag ?? [];
const sourceTones = sourceLags.map((lag) => signalToneFromState(lag.state));
const healthySources = sourceTones.filter((tone) => tone === 'healthy').length;
const sourceTone = snapshot.state !== 'ready' || sourceTones.length === 0 ? 'unknown' : status.stale ? 'stale' : worstSignalTone(sourceTones);
const hostSourceTone = sourceSignalTone(overview.host?.source);
const hostTone = overview.resources.host !== 'ready' || !overview.host ? 'unknown' : hostSourceTone !== 'healthy' ? hostSourceTone : thresholdTone([overview.host.cpu?.totalPercent, overview.host.memory?.utilizationPercent]);
const poolSourceTone = sourceSignalTone(overview.poolSource);
const knownStorageTone = overview.pools.length ? worstSignalTone(overview.pools.map((pool) => signalToneFromState(operationalStorageState(pool.state, pool.capacitySeverity)))) : 'unknown';
const storageTone = overview.resources.pools !== 'ready' ? 'unknown' : poolSourceTone !== 'healthy' ? poolSourceTone : overview.poolsPartial ? worstSignalTone([knownStorageTone, 'unknown']) : overview.poolTotal === 0 ? 'unknown' : knownStorageTone;
const containerSourceTone = sourceSignalTone(overview.containerSource);
const knownWorkloadTone = overview.containerTotal === 0 ? 'healthy' : worstSignalTone(overview.containers.map(containerSignalTone));
const workloadTone = overview.resources.containers !== 'ready' ? 'unknown' : containerSourceTone !== 'healthy' ? containerSourceTone : overview.containersPartial ? worstSignalTone([knownWorkloadTone, 'unknown']) : knownWorkloadTone;
const serviceConfigured = overview.serviceCapability === 'available' && overview.serviceConfiguration === 'configured';
const knownServiceTone = overview.services.length ? worstSignalTone(overview.services.map((service) => signalToneFromState(service.state))) : 'unknown';
const serviceTone = overview.resources.services !== 'ready' || !serviceConfigured ? 'unknown' : overview.servicesPartial ? worstSignalTone([knownServiceTone, 'unknown']) : overview.serviceTotal === 0 ? 'unknown' : knownServiceTone;
const incidentTone = overview.resources.incidents !== 'ready' ? 'unknown' : overview.incidents.length === 0 ? 'healthy' : overview.incidents.some((incident) => signalToneFromState(incident.severity) === 'critical') ? 'critical' : 'attention';
const orderedIncidents = [...overview.incidents].sort((left, right) => signalToneRank[signalToneFromState(left.severity)] - signalToneRank[signalToneFromState(right.severity)] || right.startedAt.localeCompare(left.startedAt) || left.id.localeCompare(right.id));
const highestIncident = orderedIncidents[0];
const incidentSeverity = highestIncident ? presentStatus(highestIncident.severity) : copy.overview.noOpenIncidents;
const hostUsable = overview.resources.host === 'ready' && hostSourceTone === 'healthy';
const poolsUsable = overview.resources.pools === 'ready' && poolSourceTone === 'healthy';
const containersUsable = overview.resources.containers === 'ready' && containerSourceTone === 'healthy';
const servicesUsable = overview.resources.services === 'ready' && serviceConfigured;
const signalStages: OperationalSignalStage[] = [
{ id: 'sources', label: copy.overview.sources, icon: '◉', tone: sourceTone, statusLabel: signalResourceLabel(systemResourceState, sourceTone), primaryLabel: copy.overview.connectedSources, primaryValue: snapshot.state === 'ready' ? `${healthySources}/${sourceTones.length || '—'}` : '—', secondaryLabel: copy.overview.freshness, secondaryValue: signalResourceLabel(systemResourceState, sourceTone), detail: copy.overview.signalPathSourcesDetail, route: '/status' },
{ id: 'host', label: copy.navigation.host, icon: '▣', tone: hostTone, statusLabel: signalResourceLabel(overview.resources.host, hostTone), primaryLabel: copy.overview.cpu, primaryValue: hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—', secondaryLabel: copy.overview.memoryShort, secondaryValue: hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—', detail: copy.overview.signalPathHostDetail, route: '/host' },
{ id: 'storage', label: copy.overview.signalStorage, icon: '▤', tone: storageTone, statusLabel: signalCollectionLabel(overview.resources.pools, storageTone, { partial: overview.poolsPartial }), primaryLabel: copy.overview.storage, primaryValue: poolsUsable ? (overview.poolsPartial && poolUsage != null ? `${metric(poolUsage)}` : metric(poolUsage)) : '—', secondaryLabel: copy.navigation.pools, secondaryValue: poolsUsable ? String(overview.poolTotal) : '—', detail: copy.overview.signalPathStorageDetail, route: '/storage' },
{ id: 'workloads', label: copy.overview.signalWorkloads, icon: '⬡', tone: workloadTone, statusLabel: signalCollectionLabel(overview.resources.containers, workloadTone, { partial: overview.containersPartial, emptyLabel: overview.containerTotal === 0 ? copy.overview.signalPathNoWorkloads : undefined }), primaryLabel: copy.overview.activeContainers, primaryValue: containersUsable ? boundedRatio(runningContainers, overview.containerTotal, overview.containersPartial) : '—', secondaryLabel: copy.overview.total, secondaryValue: containersUsable ? String(overview.containerTotal) : '—', detail: copy.overview.signalPathWorkloadsDetail, route: '/containers' },
{ id: 'services', label: copy.navigation.services, icon: '◇', tone: serviceTone, statusLabel: signalCollectionLabel(overview.resources.services, serviceTone, { partial: overview.servicesPartial, notConfigured: overview.serviceConfiguration === 'not_configured' }), primaryLabel: copy.overview.available, primaryValue: servicesUsable ? boundedRatio(availableServices, overview.serviceTotal, overview.servicesPartial) : '—', secondaryLabel: copy.overview.total, secondaryValue: servicesUsable ? String(overview.serviceTotal) : '—', detail: copy.overview.signalPathServicesDetail, route: '/services' },
{ id: 'incidents', label: copy.overview.signalIncidents, icon: '△', tone: incidentTone, statusLabel: signalCollectionLabel(overview.resources.incidents, incidentTone, { partial: overview.incidentsPartial }), primaryLabel: copy.overview.openIncidents, primaryValue: overview.resources.incidents === 'ready' ? `${overview.incidents.length}${overview.incidentsPartial ? '+' : ''}` : '—', secondaryLabel: copy.overview.highestSeverity, secondaryValue: overview.resources.incidents === 'ready' ? incidentSeverity : '—', detail: copy.overview.signalPathIncidentsDetail, route: '/incidents' },
];
const hasSignalAttention = signalStages.some((stage) => stage.tone === 'critical' || stage.tone === 'attention');
const hasSignalUncertainty = signalStages.some((stage) => stage.tone === 'stale' || stage.tone === 'unknown');
const heading = hasSignalAttention || problems.length > 0 || (poolUsage != null && poolUsage >= 90) ? copy.overview.attentionTitle : hasSignalUncertainty ? copy.overview.unknownTitle : copy.overview.title;
return <div className="command-overview">
<header className="overview-heading"><div><p className="eyebrow">{copy.overview.eyebrow}</p><h1>{heading}</h1><p className="intro">{copy.overview.intro}</p></div><div className="overview-heading-status"><StatusBadge label={status.label} tone={status.tone} /><small>{snapshot.status ? formatDateTime(snapshot.status.generatedAt) : copy.overview.statusLoading}</small></div></header>
<section className="source-health-strip" aria-label={copy.overview.sourceLag} tabIndex={0}>
{sourceLags.slice(0, 6).map((source) => <span className={'health-chip health-chip--' + source.state} key={source.sourceId}><span aria-hidden="true">{source.state === 'healthy' ? '✓' : source.state === 'degraded' ? '!' : '?'}</span><strong>{presentComponent(source.sourceId)}</strong><small>{presentStatus(source.state)}</small></span>)}
{sourceLags.length === 0 && <span className="health-chip health-chip--unknown"><span aria-hidden="true">?</span><strong>{copy.overview.sourceLag}</strong><small>{copy.overview.unknown}</small></span>}
</section>
<section className="overview-kpi-grid instrument-band" aria-label={copy.overview.metrics}>
<article className="overview-kpi instrument-cell"><p>{copy.overview.cpu}</p><strong>{hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'}</strong><small>{hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}</small></article>
<article className="overview-kpi instrument-cell"><p>{copy.overview.memory}</p><strong>{hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'}</strong><small>{hostUsable ? overview.host?.identity?.name ?? copy.overview.unknown : signalResourceLabel(overview.resources.host, hostTone)}</small></article>
<article className="overview-kpi instrument-cell"><p>{copy.overview.storage}</p><strong>{poolsUsable ? (overview.poolsPartial && poolUsage != null ? `${metric(poolUsage)}` : metric(poolUsage)) : '—'}</strong><small>{poolsUsable ? `${overview.poolTotal} ${copy.navigation.pools.toLowerCase()}` : signalResourceLabel(overview.resources.pools, storageTone)}</small></article>
<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>
<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 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>
</section>
</div>;
}
type ApiRecord = Record<string, unknown>;
type DashboardSummary = { id: string; slug: string; name: string; description: string; scope: string; revision: number; currentVersion: number };
type DashboardWidget = { id: string; type: string; title: string; description?: string; data?: ApiRecord; visualization?: ApiRecord; behavior?: ApiRecord; layouts?: ApiRecord };
type DashboardViewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
type CrossFilter = { key: string; value: string; label: string; sourceWidgetId: string };
class ApiError extends Error { constructor(readonly status: number) { super('api request failed'); } }
function field<T>(record: ApiRecord | undefined, name: string): T | undefined {
if (!record) return undefined;
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
}
function summaryFromApi(record: ApiRecord): DashboardSummary {
return { id: String(field(record, 'id') ?? ''), slug: String(field(record, 'slug') ?? ''), name: String(field(record, 'name') ?? copy.dashboards.unnamed), description: String(field(record, 'description') ?? ''), scope: String(field(record, 'scope') ?? 'unknown'), revision: Number(field(record, 'revision') ?? 0), currentVersion: Number(field(record, 'currentVersion') ?? 0) };
}
async function getJSON<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[]>([]);
const [reload, setReload] = useState(0);
useEffect(() => {
const controller = new AbortController();
// Rotation is a background replacement once a dashboard is visible. Keep
// the current view (and any identical shared live subscription) mounted
// until the next document arrives instead of bouncing through `loading`.
setState((current) => current === 'ready' ? current : 'loading');
getJSON<{ items?: ApiRecord[] }>('/api/v1/dashboards?limit=100', controller.signal).then((data) => {
const next = (data.items ?? []).map(summaryFromApi).filter((item) => item.id !== '');
setItems(next); setState(next.length === 0 ? 'empty' : 'ready');
}).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState(error instanceof ApiError && error.status === 401 ? 'unauthorized' : 'error');
});
return () => controller.abort();
}, [reload]);
const stateContent = state === 'loading' ? <p className="card-copy" role="status">{copy.dashboards.loading}</p> :
state === 'unauthorized' ? <div className="dashboard-message" role="alert"><h2>{copy.dashboards.unauthorizedTitle}</h2><p>{copy.dashboards.unauthorizedDetail}</p><SignInButton /></div> :
state === 'error' ? <div className="dashboard-message" role="alert"><h2>{copy.dashboards.errorTitle}</h2><p>{copy.dashboards.errorDetail}</p><button className="button button--secondary" type="button" onClick={() => setReload((value) => value + 1)}>{copy.dashboards.retry}</button></div> :
state === 'empty' ? <div className="dashboard-message dashboard-message--empty"><span className="empty-state-icon" aria-hidden="true"></span><h2>{copy.dashboards.empty}</h2><p>{copy.dashboards.emptyDetail}</p></div> :
<ul className="dashboard-list">{items.map((item) => <li key={item.id}><button type="button" className="dashboard-list-item" onClick={() => navigate('/dashboards/' + encodeURIComponent(item.id))}><span><strong>{item.name}</strong><small>{item.description || item.slug}</small></span><span className="dashboard-list-meta"><span>{copy.dashboards.version} {item.currentVersion}</span><StatusBadge label={item.scope === 'personal' ? copy.dashboards.personal : copy.dashboards.shared} tone="ready" /></span></button></li>)}</ul>;
return <><PageIntro eyebrow={copy.dashboards.eyebrow} title={copy.dashboards.title} intro={copy.dashboards.intro} /><section className="card dashboard-list-panel" aria-labelledby="dashboard-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.dashboards.catalog}</p><h2 id="dashboard-list-title">{copy.dashboards.listTitle}</h2></div><StatusBadge label={state === 'ready' ? copy.dashboards.ready : copy.dashboards.unknown} tone={state === 'ready' ? 'ready' : 'unknown'} /></div>{stateContent}</section>{state === 'ready' && <p className="dashboard-count">{items.length} {copy.dashboards.available}</p>}</>;
}
class WidgetBoundary extends Component<{ title: string; children: ReactNode }, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError(): { failed: boolean } { return { failed: true }; }
componentDidCatch(_error: Error, _info: ErrorInfo): void {}
render() { return this.state.failed ? <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();
function responsiveViewport(): 'desktop' | 'tablet' | 'mobile' { return window.innerWidth <= 700 ? 'mobile' : window.innerWidth <= 900 ? 'tablet' : 'desktop'; }
function widgetFilter(widget: DashboardWidget): { key: string; value: string; label: string } {
const data = widget.data ?? {};
const scope = field<ApiRecord>(data, 'scope') ?? {};
const entityType = field<string>(scope, 'entityType');
const sourceType = String(field(data, 'sourceType') ?? 'unknown');
return entityType ? { key: 'entityType', value: entityType, label: entityType } : { key: 'sourceType', value: sourceType, label: sourceType };
}
function filterAllowed(document: ApiRecord, key: string, value: string): boolean {
const safeSources = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'];
if (key === 'sourceType') return safeSources.includes(value);
const variables = (field<unknown[]>(document, 'variables') ?? []) as ApiRecord[];
return variables.some((variable) => { const options = field<unknown[]>(variable, 'options') ?? []; return options.includes(value); });
}
function filterFromURL(document: ApiRecord): CrossFilter | null {
const params = new URLSearchParams(window.location.search);
const key = params.get('filterKey');
const value = params.get('filterValue');
if (!key || !value || !filterAllowed(document, key, value)) return null;
return { key, value, label: value, sourceWidgetId: '' };
}
function compatibleWithFilter(widget: DashboardWidget, filter: CrossFilter | null): boolean {
if (!filter || widget.id === filter.sourceWidgetId) return true;
const next = widgetFilter(widget);
return next.key === filter.key && next.value === filter.value;
}
function wallboardSlideFor(widget: DashboardWidget): number {
const layout = field<ApiRecord>(widget.layouts ?? {}, 'wallboard') ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
return wallboardSlideIndex(field(layout, 'y'));
}
function widgetLayoutStyle(layout: ApiRecord, viewport: DashboardViewport): CSSProperties {
const columns = viewport === 'wallboard' ? wallboardColumns : viewport === 'tablet' ? 8 : viewport === 'mobile' ? 1 : 18;
const width = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, Number(field<number>(layout, 'w') ?? 6)));
if (viewport !== 'wallboard') return { '--widget-span': String(width) } as CSSProperties;
const placement = wallboardPlacement(layout);
return { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties;
}
function DashboardWidgetView({ widget, viewport, onFilter, metric }: { widget: DashboardWidget; viewport: DashboardViewport; onFilter: (widget: DashboardWidget) => void; metric?: MetricWidgetProps }) {
if (!widget.id || !widget.title) return <article className="widget-card widget-card--error" role="alert"><p className="card-kicker">{copy.dashboards.widgetError}</p><h3>{copy.widgets.unknown}</h3><p>{copy.dashboards.widgetErrorDetail}</p></article>;
const behavior = widget.behavior ?? {};
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 source = String(field(widget.data ?? {}, 'sourceType') ?? copy.dashboards.unknown);
const status = metric ? metricStatus(metric) : { label: copy.dashboards.unknown, tone: 'unknown' as const };
const metricKind = metric && (widget.type === 'stat' || widget.type === 'timeseries' || widget.type === 'gauge' || widget.type === 'query-inspector');
const widgetItems = (field<unknown[]>(widget.data ?? {}, 'items') ?? []) as ApiRecord[];
const rankedItems: RankedListItem[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), value: String(field(item, 'value') ?? ''), detail: field<string>(item, 'detail') } )).filter((item) => item.id !== '' && item.label !== '');
const statusItems: StatusGridItem[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), status: String(field(item, 'status') ?? 'unknown'), reason: field<string>(item, 'reason') } )).filter((item) => item.id !== '' && item.label !== '');
const storageNodes: StorageMapNode[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), kind: String(field(item, 'kind') ?? 'storage'), state: String(field(item, 'status') ?? 'unknown'), detail: field<string>(item, 'detail'), href: field<string>(item, 'href') })).filter((item) => item.id !== '' && item.label !== '');
const topologyData = field<TopologyData>(widget.data ?? {}, 'topology');
const networkData = field<NetworkData>(widget.data ?? {}, 'network');
const heatmapPoints: HeatmapPoint[] = widgetItems.map((item) => { const value = Number(field(item, 'value')); return { id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), observedAt: String(field(item, 'observedAt') ?? new Date(0).toISOString()), value: Number.isFinite(value) ? value : null, status: String(field(item, 'status') ?? 'unknown'), href: field<string>(item, 'href') }; }).filter((item) => item.id !== '' && item.label !== '');
return <article className="widget-card" data-viewport={viewport} style={widgetLayoutStyle(activeLayout, viewport)} aria-labelledby={'widget-' + widget.id}><div className="widget-card-heading"><div><p className="card-kicker">{typeLabel}</p><h3 id={'widget-' + widget.id}>{widget.title}</h3></div><StatusBadge label={status.label} tone={status.tone} /></div>{widget.description && <p className="widget-description">{widget.description}</p>}{metricKind ? <MetricWidget {...metric!} /> : widget.type === 'ranked-list' && rankedItems.length > 0 ? <RankedListWidget items={rankedItems} onSelect={() => onFilter(widget)} /> : widget.type === 'status-grid' && statusItems.length > 0 ? <StatusGridWidget items={statusItems} onSelect={() => onFilter(widget)} /> : widget.type === 'storage-map' && storageNodes.length > 0 ? <StorageMapWidget nodes={storageNodes} title={widget.title} description={widget.description || copy.storage.mapDescription} idPrefix={'storage-map-' + widget.id} /> : widget.type === 'topology' && topologyData ? <Suspense fallback={<p className="card-copy" role="status">{copy.topology.loading}</p>}><TopologyWidget topology={topologyData} compact /></Suspense> : widget.type === 'network' && networkData ? <NetworkHealthWidget snapshot={networkData} compact /> : widget.type === 'heatmap' && heatmapPoints.length > 0 ? <TemperatureHeatmap points={heatmapPoints} title={widget.title} description={widget.description || copy.storage.heatmapDescription} idPrefix={'heatmap-' + widget.id} /> : <button className="widget-placeholder" type="button" data-widget-type={widget.type} aria-label={typeLabel + ': ' + widget.title + ' filteren'} onClick={() => onFilter(widget)}><span className="widget-placeholder-icon" aria-hidden="true">{widget.type === 'text' ? 'T' : '◌'}</span><strong>{copy.dashboards.noData}</strong><small>{source} · {copy.dashboards.dataPending}</small></button>}</article>;
}
function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0, onWallboardSlideCount, onRuntimeState }: { dashboardId: string; wallboard?: boolean; wallboardSlide?: number; onWallboardSlideCount?: (count: number) => void; onRuntimeState?: (state: RuntimeState) => void }) {
const [state, setState] = useState<'loading' | 'error' | 'unauthorized' | 'ready'>('loading');
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [document, setDocument] = useState<ApiRecord>({});
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
const [editing, setEditing] = 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');
const [filter, setFilter] = useState(() => window.localStorage.getItem(storageKey + '.filter') ?? '');
const updateRuntimeState = useCallback((id: string, next: RuntimeState) => setRuntimeStates((current) => current[id] === next ? current : { ...current, [id]: next }), []);
useEffect(() => {
const controller = new AbortController();
const replacingVisibleWallboard = wallboard && hasWallboardContent.current;
if (!replacingVisibleWallboard) setState('loading');
getJSON<{ dashboard: ApiRecord; version: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(dashboardId), controller.signal).then((data) => {
const rawDocument = field<ApiRecord>(data.version, 'document') ?? {};
setDocument(rawDocument);
setCrossFilter(filterFromURL(rawDocument));
setSummary(summaryFromApi(data.dashboard));
setWidgets((field<unknown[]>(rawDocument, 'widgets') ?? []) as DashboardWidget[]);
setRuntimeStates({});
if (wallboard) hasWallboardContent.current = true;
setState('ready');
}).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (error instanceof ApiError && error.status === 401) {
setState('unauthorized');
return;
}
// A wallboard is a continuous operational display. During a transient
// failed rotation, keep the last verified document visible and let the
// next bounded rotation/refresh retry. Never apply this to first load or
// authentication failure.
if (!replacingVisibleWallboard) setState('error');
});
return () => controller.abort();
}, [dashboardId, wallboard]);
useEffect(() => { window.localStorage.setItem(storageKey + '.range', timeRange); }, [storageKey, timeRange]);
useEffect(() => { window.localStorage.setItem(storageKey + '.filter', filter); }, [storageKey, filter]);
useEffect(() => {
const values = Object.values(runtimeStates);
const aggregate: RuntimeState = values.includes('usable') ? 'usable' : values.includes('error') ? 'error' : values.length > 0 && values.every((value) => value === 'empty') ? 'empty' : 'loading';
onRuntimeState?.(aggregate);
}, [onRuntimeState, runtimeStates]);
useEffect(() => { if (wallboard) setRuntimeStates({}); }, [wallboard, wallboardSlide]);
const wallboardWidgets = widgets.filter((widget) => {
const layout = field<ApiRecord>(widget.layouts ?? {}, 'wallboard') ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
return field<boolean>(widget.behavior ?? {}, 'hidden') !== true && field<boolean>(layout, 'visible') !== false;
});
const wallboardSlideCount = Math.max(1, ...wallboardWidgets.map((widget) => wallboardSlideFor(widget) + 1));
useEffect(() => { if (wallboard) onWallboardSlideCount?.(wallboardSlideCount); }, [onWallboardSlideCount, wallboard, wallboardSlideCount]);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.dashboards.loading}</h1></section>;
if (state === 'unauthorized') return <StatePage kind="unauthorized" />;
if (state === 'error' || !summary) return <StatePage kind="error" />;
if (editing) return <Suspense fallback={<StatePage kind="loading" />}><DashboardEditor dashboardId={dashboardId} revision={summary.revision} document={document} onExit={() => setEditing(false)} onSaved={(revision, nextDocument) => { setDocument(nextDocument); setWidgets((field<unknown[]>(nextDocument, 'widgets') ?? []) as DashboardWidget[]); setSummary({ ...summary, revision, currentVersion: summary.currentVersion + 1 }); setEditing(false); }} /></Suspense>;
const viewport: DashboardViewport = wallboard ? 'wallboard' : responsiveViewport();
const normalized = filter.trim().toLowerCase();
const shown = widgets.filter((widget) => { const activeLayout = field<ApiRecord>(widget.layouts ?? {}, viewport) ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {}; return field<boolean>(widget.behavior ?? {}, 'hidden') !== true && field<boolean>(activeLayout, 'visible') !== false && (!wallboard || wallboardSlideFor(widget) === Math.min(wallboardSlide, wallboardSlideCount - 1)) && (!normalized || widget.title.toLowerCase().includes(normalized)) && compatibleWithFilter(widget, crossFilter); });
const systemStatus = aggregateStatus(systemSnapshot);
const applyCrossFilter = (widget: DashboardWidget) => { const next = widgetFilter(widget); const filter = { ...next, sourceWidgetId: widget.id }; setCrossFilter(filter); const params = new URLSearchParams(window.location.search); params.set('filterKey', filter.key); params.set('filterValue', filter.value); window.history.replaceState({}, '', window.location.pathname + '?' + params.toString()); };
const clearCrossFilter = () => { setCrossFilter(null); const params = new URLSearchParams(window.location.search); params.delete('filterKey'); params.delete('filterValue'); const query = params.toString(); window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '')); };
const usableCount = Object.values(runtimeStates).filter((value) => value === 'usable').length;
return <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>;
}
type WallboardPriorityData = { serviceProblems: number; openIncidents: number; loading: boolean; unavailable: boolean };
function useWallboardPriorityData(): WallboardPriorityData {
const [value, setValue] = useState<WallboardPriorityData>({ serviceProblems: 0, openIncidents: 0, loading: true, unavailable: false });
useEffect(() => {
let active = true;
let controller: AbortController | null = null;
const load = async () => {
controller?.abort();
const current = new AbortController();
controller = current;
try {
const [servicesResponse, incidentsResponse] = await Promise.all([
fetch('/api/v1/services?limit=100', { signal: current.signal, cache: 'no-store' }),
fetch('/api/v1/incidents?limit=100&status=open', { signal: current.signal, cache: 'no-store' }),
]);
if (!servicesResponse.ok || !incidentsResponse.ok) throw new Error('priority');
const services = await servicesResponse.json() as { services?: Array<{ state?: string }> };
const incidents = await incidentsResponse.json() as { items?: unknown[] };
if (active) setValue({ serviceProblems: (services.services ?? []).filter((item) => item.state !== 'up').length, openIncidents: (incidents.items ?? []).length, loading: false, unavailable: false });
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (active) setValue((currentValue) => ({ ...currentValue, loading: false, unavailable: true }));
}
};
void load();
const timer = window.setInterval(load, 30000);
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
}, []);
return value;
}
function WallboardPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error' | 'unauthorized' | 'empty'>('loading');
const [items, setItems] = useState<DashboardSummary[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [activeSlide, setActiveSlide] = useState(0);
const [slideCount, setSlideCount] = useState(1);
const [lastUpdated, setLastUpdated] = useState<string | undefined>();
const [transport, setTransport] = useState<'connected' | 'reconnecting' | 'unavailable'>('reconnecting');
const [dataState, setDataState] = useState<RuntimeState>('loading');
const [fullscreen, setFullscreen] = useState(false);
const [shift, setShift] = useState(0);
const systemSnapshot = useSystemStatus();
const system = aggregateStatus(systemSnapshot);
const storage = systemSnapshot.status?.components.find((component) => component.id === 'storage');
const priority = useWallboardPriorityData();
const params = new URLSearchParams(window.location.search);
const intervalSeconds = Math.min(300, Math.max(10, Number(params.get('interval') ?? 30) || 30));
const refreshSeconds = Math.min(300, Math.max(10, Number(params.get('refresh') ?? 30) || 30));
useEffect(() => onUnauthenticated(() => { setTransport('unavailable'); setState('unauthorized'); }), []);
useEffect(() => {
let active = true;
let inFlight: AbortController | null = null;
const load = async () => {
inFlight?.abort();
const controller = new AbortController();
inFlight = controller;
try {
const response = await fetch('/api/v1/dashboards?limit=100', { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error('wallboard');
const data = await response.json() as { items?: ApiRecord[] };
const next = (data.items ?? []).map(summaryFromApi).filter((item) => item.id !== '').sort((a, b) => a.id.localeCompare(b.id));
if (!active) return;
setItems(next);
setActiveIndex((value) => next.length === 0 ? 0 : Math.min(value, next.length - 1));
setLastUpdated(new Date().toISOString());
setTransport('connected');
setState(next.length === 0 ? 'empty' : 'ready');
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (!active) return;
setTransport('unavailable');
setState((value) => value === 'ready' || value === 'unauthorized' ? value : 'error');
} finally {
if (inFlight === controller) inFlight = null;
}
};
void load();
const refresh = window.setInterval(() => { setTransport('reconnecting'); void load(); }, refreshSeconds * 1000);
return () => { active = false; inFlight?.abort(); window.clearInterval(refresh); };
}, []);
useEffect(() => {
if (items.length === 0) return undefined;
const rotation = window.setInterval(() => setActiveSlide((value) => {
if (value + 1 < slideCount) return value + 1;
if (items.length > 1) setActiveIndex((dashboard) => (dashboard + 1) % items.length);
return 0;
}), intervalSeconds * 1000);
return () => window.clearInterval(rotation);
}, [items.length, intervalSeconds, slideCount]);
useEffect(() => {
const timer = window.setInterval(() => setShift((value) => (value + 1) % 2), 60000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
const update = () => setFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', update);
update();
return () => document.removeEventListener('fullscreenchange', update);
}, []);
const toggleFullscreen = async () => {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen();
} catch { /* Fullscreen is optional; transport and data state remain truthful. */ }
};
const handleRuntimeState = useCallback((runtime: RuntimeState) => setDataState(runtime), []);
const handleSlideCount = useCallback((count: number) => { setSlideCount(Math.max(1, count)); setActiveSlide((value) => Math.min(value, Math.max(1, count) - 1)); }, []);
if (state === 'loading') return <section className="wallboard-shell wallboard-shell--state" aria-live="polite"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.loading}</h1></section>;
if (state === 'unauthorized') return <section className="wallboard-shell wallboard-shell--state" role="alert"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.states.unauthorizedTitle}</h1><p>{copy.states.unauthorizedDetail}</p><SignInButton /></section>;
if (state === 'error') return <section className="wallboard-shell wallboard-shell--state" role="alert"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.errorTitle}</h1><p>{copy.wallboard.errorDetail}</p></section>;
if (state === 'empty') return <section className="wallboard-shell wallboard-shell--state"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.noDashboards}</h1></section>;
const current = items[activeIndex];
return <section className={'wallboard-shell wallboard-shell--shift-' + shift} aria-labelledby="wallboard-title">
<header className="wallboard-header"><div><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1 id="wallboard-title">{copy.wallboard.title}</h1><p className="intro">{copy.wallboard.intro}</p></div><div className="wallboard-actions"><span className={'wallboard-connection wallboard-connection--' + transport} role="status">{copy.wallboard.transport}: {transport === 'connected' ? copy.wallboard.connected : transport === 'reconnecting' ? copy.wallboard.reconnecting : copy.wallboard.unavailable}</span><span className={'wallboard-connection wallboard-connection--' + (dataState === 'usable' ? 'connected' : dataState === 'loading' ? 'reconnecting' : 'unavailable')} role="status">{copy.wallboard.data}: {dataState === 'usable' ? copy.wallboard.dataUsable : dataState === 'loading' ? copy.wallboard.dataLoading : dataState === 'empty' ? copy.wallboard.dataEmpty : copy.wallboard.unavailable}</span><button className="button button--secondary" type="button" onClick={toggleFullscreen}>{fullscreen ? copy.wallboard.exitFullscreen : copy.wallboard.enterFullscreen}</button></div></header>
<div className="wallboard-priority" aria-label={copy.wallboard.priority}><span className={'wallboard-priority-item wallboard-priority-item--' + (system.state === 'healthy' ? 'ready' : 'attention')}><strong>{copy.wallboard.overall}</strong><small>{system.label}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (storage?.state === 'healthy' ? 'ready' : 'attention')}><strong>{copy.wallboard.storage}</strong><small>{storage ? presentStatus(storage.state) : copy.wallboard.unknown}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (priority.serviceProblems === 0 && !priority.unavailable ? 'ready' : 'attention')}><strong>{copy.wallboard.services}</strong><small>{priority.loading ? copy.wallboard.dataLoading : priority.unavailable ? copy.wallboard.unknown : `${priority.serviceProblems} ${copy.wallboard.problems}`}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (priority.openIncidents === 0 && !priority.unavailable ? 'ready' : 'attention')}><strong>{copy.wallboard.incidents}</strong><small>{priority.loading ? copy.wallboard.dataLoading : priority.unavailable ? copy.wallboard.unknown : `${priority.openIncidents} ${copy.wallboard.open}`}</small></span></div>
<div className="wallboard-status"><span>{copy.wallboard.lastUpdated}: {lastUpdated ? formatDateTime(lastUpdated) : copy.wallboard.reconnecting}</span><span>{copy.wallboard.rotate} {copy.wallboard.every} {intervalSeconds} {copy.wallboard.seconds}</span><span>{copy.wallboard.slide} {activeSlide + 1} / {slideCount}</span><span>{copy.wallboard.dashboard} {activeIndex + 1} / {items.length}</span></div>
<div className="wallboard-frame"><DashboardViewPage dashboardId={current.id} wallboard wallboardSlide={activeSlide} onWallboardSlideCount={handleSlideCount} onRuntimeState={handleRuntimeState} /></div>
</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') {
// The API answered 401, so the visitor is not signed in: offer the real
// sign-in entry point and come back to the page they asked for.
return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">!</span><h1>{copy.states.unauthorizedTitle}</h1><p>{copy.states.unauthorizedDetail}</p><p>{copy.auth.signInHint}</p><div className="state-page-actions"><SignInButton /><button className="button button--secondary" type="button" onClick={() => navigate('/')}>{copy.states.returnHome}</button></div></section>;
}
return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.states.errorTitle}</h1><p>{copy.states.errorDetail}</p><button className="button" type="button" onClick={() => navigate(routeFromLocation(window.location.pathname))}>{copy.states.retry}</button></section>;
}
/** Wraps lazily loaded routes in the same loading state the rest of the app uses. */
function RouteSuspense({ children }: { children: ReactNode }) {
return <Suspense fallback={<StatePage kind="loading" />}>{children}</Suspense>;
}
function Page({ route }: { route: RoutePath }) {
if (route.startsWith('/inventory/')) return <InventoryPage id={decodeURIComponent(route.slice('/inventory/'.length))} />;
if (route.startsWith('/dashboards/')) return <DashboardViewPage dashboardId={decodeURIComponent(route.slice('/dashboards/'.length))} />;
if (route.startsWith('/services/')) return <ServicePage id={decodeURIComponent(route.slice('/services/'.length))} />;
if (route.startsWith('/incidents/')) return <IncidentPage id={decodeURIComponent(route.slice('/incidents/'.length))} />;
if (route.startsWith('/containers/')) return <ContainerDetailPage id={decodeURIComponent(route.slice('/containers/'.length))} />;
if (route.startsWith('/disks/')) return <DiskDetailPage id={decodeURIComponent(route.slice('/disks/'.length))} />;
if (route.startsWith('/pools/')) return <PoolPage id={decodeURIComponent(route.slice('/pools/'.length))} />;
if (route.startsWith('/shares/')) return <SharePage id={decodeURIComponent(route.slice('/shares/'.length))} />;
if (route.startsWith('/applications/')) return <ApplicationPage id={decodeURIComponent(route.slice('/applications/'.length))} />;
switch (route) {
case '/': return <OverviewPage />;
case '/processes': return <RouteSuspense><ProcessPage /></RouteSuspense>;
case '/containers': return <ContainerPage />;
case '/services': return <ServicePage />;
case '/topology': return <RouteSuspense><TopologyPage /></RouteSuspense>;
case '/network': return <NetworkPage />;
case '/applications': return <ApplicationPage />;
case '/host': return <HostPage />;
case '/array': return <ArrayPage />;
case '/disks': return <DiskPage />;
case '/pools': return <PoolPage />;
case '/shares': return <SharePage />;
case '/storage': return <StoragePage />;
case '/capacity': return <CapacityPage />;
case '/inventory': return <InventoryPage />;
case '/dashboards': return <DashboardsPage />;
case '/wallboard': return <WallboardPage />;
case '/alerts': return <RouteSuspense><AlertsPage /></RouteSuspense>;
case '/events': return <EventsPage />;
case '/incidents': return <IncidentPage />;
case '/settings': return <SettingsPage />;
case '/status': return <SystemStatusPage />;
case '/onboarding': return <OnboardingPage />;
case '/loading': return <StatePage kind="loading" />;
case '/error': return <StatePage kind="error" />;
case '/unauthorized': return <StatePage kind="unauthorized" />;
case '/404': return <NotFoundPage />;
default: return <NotFoundPage />;
}
}
function App() {
const [route, setRoute] = useState<RoutePath>(() => routeFromLocation(window.location.pathname));
const shellStatus = aggregateStatus(useSystemStatus());
useEffect(() => { const handleNavigation = () => setRoute(routeFromLocation(window.location.pathname)); window.addEventListener('popstate', handleNavigation); return () => window.removeEventListener('popstate', handleNavigation); }, []);
useEffect(() => {
const mobileMenu = document.querySelector<HTMLDetailsElement>('.mobile-more');
if (mobileMenu?.open) mobileMenu.open = false;
}, [route]);
const currentNavigation = navigation.find((item) => item.path === route)
?? navigation.find((item) => item.path !== '/' && route.startsWith(item.path + '/'))
?? navigation[0];
if (route === '/wallboard') return <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>;
}
export default App;
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentReason } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type ComponentItem = { id: string; name: string; kind: string; critical: boolean; containerState: string; serviceState: string; status: string; reason?: string };
type ApplicationItem = { id: string; name: string; status: string; overridden: boolean; components: ComponentItem[]; reasons?: Array<{ code: string; message: string; componentId?: string; critical: boolean }> };
type ApplicationSnapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; applications: ApplicationItem[]; total: number };
type ApplicationDetail = { source: ApplicationSnapshot['source']; application: ApplicationItem };
function Badge({ state }: { state: string }) {
const normalized = state.toLowerCase();
const tone = normalized === 'healthy' ? 'ready' : normalized === 'degraded' || normalized === 'down' ? 'attention' : 'unknown';
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'}</span>{normalized === 'healthy' ? copy.applications.healthy : normalized === 'degraded' || normalized === 'down' ? copy.applications.degraded : copy.applications.unknown}</span>;
}
function Source({ source }: { source: ApplicationSnapshot['source'] }) {
return <SourceStatusDetails source={source ?? {}} fallbackReason={source?.freshness === 'fresh' ? copy.applications.fresh : copy.applications.stale} />;
}
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>;
}
export function ApplicationPage({ id }: { id?: string }) {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [data, setData] = useState<ApplicationSnapshot | ApplicationDetail | null>(null);
useEffect(() => {
const controller = new AbortController();
const url = id ? '/api/v1/applications/' + encodeURIComponent(id) : '/api/v1/applications';
fetch(url, { signal: controller.signal }).then((response) => {
if (!response.ok) throw new Error('applications');
return response.json() as Promise<ApplicationSnapshot | ApplicationDetail>;
}).then((value) => { setData(value); setState('ready'); }).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState('error');
});
return () => controller.abort();
}, [id]);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.applications.loading}</h1></section>;
if (state === 'error' || !data) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.applications.errorTitle}</h1><p>{copy.applications.errorDetail}</p><a className="button" href="/applications">{copy.applications.backToList}</a></section>;
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} /></>;
}
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></>;
}
+32
View File
@@ -0,0 +1,32 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentArrayRole, presentStatus } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type Member = { id: string; name: string; role: string; state: string; capacityBytes: number; readBytes: number; writeBytes: number };
type Check = { id: string; state: string; progressPercent: number; speedBytesPerSecond: number; errors: number; startedAt?: string; completedAt?: string };
type ArraySnapshot = { contractVersion: string; source: { id: string; state: string; freshness: string; observedAt?: string; receivedAt?: string; reason?: string }; state: string; parity: { present: boolean; state: string; errors: number }; members?: Member[] | null; currentCheck?: Check; history?: Check[] | null };
function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
function date(value?: string): string { return formatDateTime(value); }
function Badge({ label, ready }: { label: string; ready: boolean }) { return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{label}</span>; }
export function ArrayPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<ArraySnapshot | null>(null);
useEffect(() => { const controller = new AbortController(); fetch('/api/v1/array', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('array'); return response.json() as Promise<ArraySnapshot>; }).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.array.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.array.errorTitle}</h1><p>{copy.array.errorDetail}</p></section>;
const available = snapshot.source?.state !== 'unknown' && snapshot.source?.freshness === 'fresh';
const stateLabel = snapshot.state === 'operational' ? copy.array.operational : snapshot.state === 'degraded' ? copy.array.degraded : snapshot.state === 'missing' ? copy.array.missing : copy.array.unknown;
const members = Array.isArray(snapshot.members) ? snapshot.members : [];
const history = Array.isArray(snapshot.history) ? snapshot.history : [];
return <>
<header className="page-intro"><p className="eyebrow">{copy.array.eyebrow}</p><h1>{copy.array.title}</h1><p className="intro">{copy.array.intro}</p></header>
<section className="card container-summary" aria-labelledby="array-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.array.source}</p><h2 id="array-summary-title">{snapshot.source?.id || copy.array.unknown}</h2></div><Badge label={stateLabel} ready={snapshot.state === 'operational'} /></div><SourceStatusDetails source={snapshot.source ?? {}} fallbackReason={available ? copy.array.fresh : copy.array.stale} /><p className="card-copy">{copy.array.readOnly}</p></section>
<section className="card-grid" aria-label={copy.array.metrics}><article className="card"><p className="card-kicker">{copy.array.parity}</p><h2>{snapshot.parity.present ? copy.array.present : copy.array.notPresent}</h2><p className="card-copy">{presentStatus(snapshot.parity.state)} · {copy.array.errors}: {snapshot.parity.errors}</p></article><article className="card"><p className="card-kicker">{copy.array.members}</p><h2>{members.length}</h2><p className="card-copy">{members.filter((member) => member.state !== 'online').length} {copy.array.notOperational}</p></article>{snapshot.currentCheck && <article className="card"><p className="card-kicker">{copy.array.currentCheck}</p><h2>{snapshot.currentCheck.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</h2><p className="card-copy">{presentStatus(snapshot.currentCheck.state)} · {bytes(snapshot.currentCheck.speedBytesPerSecond)}/s · {copy.array.errors}: {snapshot.currentCheck.errors}</p></article>}</section>
<section className="card container-panel" aria-labelledby="array-members-title"><div className="card-heading"><div><p className="card-kicker">{copy.array.members}</p><h2 id="array-members-title">{copy.array.membersTitle}</h2></div></div>{members.length === 0 ? <p className="card-copy">{copy.array.noMembers}</p> : <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.array.name}</th><th>{copy.array.role}</th><th>{copy.array.state}</th><th>{copy.array.capacity}</th><th>{copy.array.io}</th></tr></thead><tbody>{members.map((member) => <tr key={member.id}><th scope="row">{member.name}</th><td>{presentArrayRole(member.role)}</td><td><Badge label={presentStatus(member.state)} ready={member.state === 'online'} /></td><td>{bytes(member.capacityBytes)}</td><td>{bytes(member.readBytes)} gelezen / {bytes(member.writeBytes)} geschreven</td></tr>)}</tbody></table></div>}</section>
<details className="card technical-details"><summary>{copy.array.history}</summary>{history.length === 0 ? <p className="card-copy">{copy.array.noHistory}</p> : <div className="host-table-wrap"><table className="host-table"><thead><tr><th>ID</th><th>{copy.array.state}</th><th>{copy.array.progress}</th><th>{copy.array.speed}</th><th>{copy.array.completed}</th></tr></thead><tbody>{history.map((check) => <tr key={check.id}><th scope="row">{check.id}</th><td>{presentStatus(check.state)}</td><td>{check.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</td><td>{bytes(check.speedBytesPerSecond)}/s</td><td>{date(check.completedAt)}</td></tr>)}</tbody></table></div>}</details>
</>;
}
+52
View File
@@ -0,0 +1,52 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
type Forecast = {
entityId: string;
name: string;
kind: string;
enabled: boolean;
method: string;
windowSeconds: number;
dataPoints: number;
confidence: string;
currentUsedBytes: number;
capacityBytes: number;
rateBytesPerDay: number;
daysToCapacity?: number;
projectedAt?: string;
reason?: string;
};
type Snapshot = { contractVersion: string; generatedAt: string; policy: { enabled: boolean; windowSeconds: number; minPoints: number; method: string }; items: Forecast[]; qualifiedCount: number; reason?: string };
function formatBytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let amount = value; let index = 0; while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1; } return `${amount.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${units[index]}`; }
function formatWindow(seconds: number): string { const days = Math.round(seconds / 86400); return `${days} ${days === 1 ? copy.capacity.day : copy.capacity.days}`; }
function formatDate(value?: string): string { return formatDateTime(value); }
function label(value: string): string {
const labels: Record<string, string> = {
linear_median_rate: copy.capacity.linearMedian, insufficient_data: copy.capacity.insufficient,
disabled: copy.capacity.disabled, high: copy.capacity.high, medium: copy.capacity.medium,
low: copy.capacity.low, none: copy.capacity.none, insufficient_points: copy.capacity.insufficientPoints,
insufficient_time_span: copy.capacity.insufficientSpan, history_stale: copy.capacity.historyStale,
history_unavailable: copy.capacity.historyUnavailable, source_unavailable: copy.capacity.sourceUnavailable,
no_capacity_entities: copy.capacity.noEntities, capacity_unknown_or_reached: copy.capacity.capacityUnknown,
no_positive_growth: copy.capacity.noGrowth, bulk_import_detected: copy.capacity.bulkImport,
irregular_intervals: copy.capacity.irregular, disabled_by_policy: copy.capacity.disabledByPolicy,
};
return labels[value] ?? copy.capacity.unknown;
}
export function CapacityPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
useEffect(() => { const controller = new AbortController(); fetch('/api/v1/forecasts', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('forecasts'); return response.json() as Promise<Snapshot>; }).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.capacity.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.capacity.errorTitle}</h1><p>{copy.capacity.errorDetail}</p></section>;
return <><header className="page-intro"><p className="eyebrow">{copy.capacity.eyebrow}</p><h1>{copy.capacity.title}</h1><p className="intro">{copy.capacity.intro}</p></header><section className="card container-summary" aria-labelledby="capacity-policy-title"><div className="card-heading"><div><p className="card-kicker">{copy.capacity.policy}</p><h2 id="capacity-policy-title">{snapshot.policy.enabled ? copy.capacity.enabled : copy.capacity.disabled}</h2><p className="card-copy">{copy.capacity.method}: {label(snapshot.policy.method)} · {copy.capacity.window}: {formatWindow(snapshot.policy.windowSeconds)} · {copy.capacity.minimum}: {snapshot.policy.minPoints}</p></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">i</span>{copy.capacity.readOnly}</span></div><p className="container-provenance">{copy.capacity.observed}: {formatDate(snapshot.generatedAt)} · {snapshot.qualifiedCount ?? 0} {copy.capacity.items} · {snapshot.items.length} {copy.capacity.assessments}</p></section><section className="card-grid" aria-label={copy.capacity.cards}>{snapshot.items.length ? snapshot.items.map((item) => <ForecastCard key={item.entityId + item.kind} item={item} />) : <article className="card empty-state"><h2>{copy.capacity.emptyTitle}</h2><p className="card-copy">{snapshot.reason ? label(snapshot.reason) : copy.capacity.empty}</p><a className="button" href="/shares">{copy.capacity.openShares}</a></article>}</section></>;
}
function ForecastCard({ item }: { item: Forecast }) {
const qualified = item.confidence === 'high' || item.confidence === 'medium';
return <article className="card forecast-card"><div className="card-heading"><div><p className="card-kicker">{item.kind === 'share' ? copy.capacity.share : item.kind}</p><h2>{item.name || item.entityId || copy.capacity.unknown}</h2></div><span className={'status-badge status-badge--' + (qualified ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{qualified ? '✓' : '?'}</span>{label(item.confidence)}</span></div><dl className="metric-list"><div><dt>{copy.capacity.method}</dt><dd>{label(item.method)}</dd></div><div><dt>{copy.capacity.window}</dt><dd>{formatWindow(item.windowSeconds)}</dd></div><div><dt>{copy.capacity.points}</dt><dd>{item.dataPoints}</dd></div><div><dt>{copy.capacity.current}</dt><dd>{formatBytes(item.currentUsedBytes)} / {item.capacityBytes > 0 ? formatBytes(item.capacityBytes) : '—'}</dd></div><div><dt>{copy.capacity.rate}</dt><dd>{qualified && item.rateBytesPerDay > 0 ? `${formatBytes(item.rateBytesPerDay)} / ${copy.capacity.day}` : '—'}</dd></div>{qualified && item.daysToCapacity !== undefined && <div><dt>{copy.capacity.projected}</dt><dd>{Math.round(item.daysToCapacity)} {copy.capacity.days} · {formatDate(item.projectedAt)}</dd></div>}</dl><p className="card-copy">{item.reason ? label(item.reason) : copy.capacity.qualified}</p></article>;
}
+144
View File
@@ -0,0 +1,144 @@
import { formatDateTime } from './locale';
import { useDeferredValue, useEffect, useRef, useState } from 'react';
import { copy } from './copy';
import { queryValue, replaceListQuery } from './listQuery';
import { presentReason, presentStatus } from './presentation';
type ContainerItem = {
id: string;
name: string;
image?: string;
imageDigest?: string;
state: string;
health: string;
intentionalStop: boolean;
metricsAvailable?: boolean;
lifecycleAvailable?: boolean;
uptimeSeconds: number;
restartCount: number;
exitCode: number;
cpuPercent: number;
memoryBytes: number;
memoryLimitBytes: number;
networkRxBytes: number;
networkTxBytes: number;
blockReadBytes: number;
blockWriteBytes: number;
project?: string;
ports?: Array<{ containerPort: number; hostPort?: number; protocol: string }>;
};
type ContainerSnapshot = { source: { id: string; state: string; reason?: string }; containers: ContainerItem[]; total: number; nextCursor?: string };
function bytes(value: number): string {
if (!Number.isFinite(value) || value < 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let scaled = value;
let index = 0;
while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; }
return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index];
}
function duration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '—';
const hours = Math.floor(seconds / 3600);
return hours >= 24 ? Math.floor(hours / 24) + ' d ' + (hours % 24) + ' u' : hours + ' u';
}
type BadgeTone = 'ready' | 'attention' | 'unknown';
function Badge({ label, tone }: { label: string; tone: BadgeTone }) {
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'}</span>{label}</span>;
}
function runtimeTone(state: string, sourceHealthy = true): BadgeTone {
if (!sourceHealthy) return 'unknown';
switch (state.toLowerCase()) {
case 'running': return 'ready';
case 'restarting': case 'paused': case 'exited': case 'dead': case 'stopped': return 'attention';
default: return 'unknown';
}
}
function healthTone(health: string, sourceHealthy = true): BadgeTone {
if (!sourceHealthy) return 'unknown';
switch (health.toLowerCase()) {
case 'healthy': return 'ready';
case 'unhealthy': return 'attention';
default: return 'unknown';
}
}
export function ContainerPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<ContainerSnapshot | null>(null);
const [query, setQuery] = useState(() => queryValue('q'));
const deferredQuery = useDeferredValue(query);
const [stateFilter, setStateFilter] = useState(() => queryValue('state'));
const [healthFilter, setHealthFilter] = useState(() => queryValue('health'));
const [sort, setSort] = useState(() => queryValue('sort', ['name', 'cpu', 'memory', 'state'], 'name'));
const [cursor, setCursor] = useState(() => queryValue('after'));
const [history, setHistory] = useState<string[]>([]);
const pageStatus = useRef<HTMLSpanElement>(null);
const [reload, setReload] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState((current) => current === 'ready' ? 'ready' : 'loading');
const params = new URLSearchParams({ limit: '25', sort });
if (deferredQuery.trim()) params.set('q', deferredQuery.trim());
if (stateFilter) params.set('state', stateFilter);
if (healthFilter) params.set('health', healthFilter);
if (cursor) params.set('after', cursor);
replaceListQuery({ q: deferredQuery.trim(), state: stateFilter, health: healthFilter, sort: sort === 'name' ? '' : sort, after: cursor });
fetch('/api/v1/containers?' + params, { signal: controller.signal }).then((response) => {
if (!response.ok) throw new Error('containers');
return response.json() as Promise<ContainerSnapshot>;
}).then((data) => { setSnapshot(data); setState('ready'); if (cursor) requestAnimationFrame(() => pageStatus.current?.focus()); }).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState('error');
});
return () => controller.abort();
}, [deferredQuery, stateFilter, healthFilter, sort, cursor, reload]);
const resetPage = () => { setCursor(''); setHistory([]); };
const previous = () => { const prior = [...history]; setCursor(prior.pop() ?? ''); setHistory(prior); };
const next = () => { if (!snapshot?.nextCursor) return; setHistory((values) => [...values, cursor]); setCursor(snapshot.nextCursor ?? ''); };
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.containers.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.containers.errorTitle}</h1><p>{copy.containers.errorDetail}</p><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>{copy.containers.retry}</button></section>;
const available = snapshot.source?.state === 'healthy';
return <>
<header className="page-intro"><p className="eyebrow">{copy.containers.eyebrow}</p><h1>{copy.containers.title}</h1><p className="intro">{copy.containers.intro}</p></header>
<section className="card container-summary" aria-labelledby="container-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.containers.source}</p><h2 id="container-summary-title">{snapshot.source?.id || copy.containers.unknown}</h2><p className="card-copy">{snapshot.source?.reason ? presentReason(snapshot.source.reason) : copy.containers.readOnly}</p></div><Badge label={available ? copy.containers.available : copy.containers.unknown} tone={available ? 'ready' : 'unknown'} /></div><p className="container-count">{snapshot.total} {copy.containers.rows} · {copy.containers.limitNote}</p></section>
<section className="card container-panel" aria-labelledby="container-list-title">
<div className="card-heading"><div><p className="card-kicker">{copy.containers.list}</p><h2 id="container-list-title">{copy.containers.top}</h2></div></div>
<form className="list-filters" onSubmit={(event) => event.preventDefault()}>
<label>{copy.containers.search}<input type="search" value={query} placeholder={copy.containers.searchPlaceholder} onChange={(event) => { setQuery(event.target.value); resetPage(); }} /></label>
<label>{copy.containers.filterState}<select value={stateFilter} onChange={(event) => { setStateFilter(event.target.value); resetPage(); }}><option value="">{copy.containers.all}</option><option value="running">{presentStatus('running')}</option><option value="exited">{presentStatus('exited')}</option><option value="restarting">{presentStatus('restarting')}</option><option value="paused">{presentStatus('paused')}</option><option value="unknown">{presentStatus('unknown')}</option></select></label>
<label>{copy.containers.filterHealth}<select value={healthFilter} onChange={(event) => { setHealthFilter(event.target.value); resetPage(); }}><option value="">{copy.containers.all}</option><option value="healthy">{presentStatus('healthy')}</option><option value="unhealthy">{presentStatus('unhealthy')}</option><option value="starting">{presentStatus('starting')}</option><option value="unknown">{presentStatus('unknown')}</option></select></label>
<label>{copy.containers.sort}<select value={sort} onChange={(event) => { setSort(event.target.value); resetPage(); }}><option value="name">{copy.containers.sortName}</option><option value="cpu">{copy.containers.sortCPU}</option><option value="memory">{copy.containers.sortMemory}</option><option value="state">{copy.containers.sortState}</option></select></label>
</form>
{snapshot.containers.length === 0 ? <p className="card-copy">{copy.containers.empty}</p> : <><div className="host-table-wrap desktop-data-view"><table className="host-table container-table"><thead><tr><th>{copy.containers.name}</th><th>{copy.containers.state}</th><th>{copy.containers.health}</th><th>{copy.containers.resources}</th><th>{copy.containers.image}</th></tr></thead><tbody>{snapshot.containers.map((item) => <tr key={item.id}><th scope="row"><a className="entity-link" href={"/containers/" + encodeURIComponent(item.id)}>{item.name}</a><small>{item.project || copy.containers.noProject} · {item.lifecycleAvailable ? duration(item.uptimeSeconds) : copy.containers.unknown}</small></th><td><Badge label={available ? presentStatus(item.state) : copy.containers.unknown} tone={runtimeTone(item.state, available)} /></td><td><Badge label={available ? presentStatus(item.health) : copy.containers.unknown} tone={healthTone(item.health, available)} /></td><td>{item.metricsAvailable ? item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : copy.containers.unknown}<small>{item.metricsAvailable ? `${bytes(item.memoryBytes)} / ${bytes(item.memoryLimitBytes)}` : copy.containers.metricsUnavailable}</small></td><td>{item.image || copy.containers.unknown}</td></tr>)}</tbody></table></div><ul className="mobile-data-list" aria-label={copy.containers.mobileList}>{snapshot.containers.map((item) => <li key={item.id}><a href={"/containers/" + encodeURIComponent(item.id)}><strong>{item.name}</strong></a><div><Badge label={available ? presentStatus(item.state) : copy.containers.unknown} tone={runtimeTone(item.state, available)} /><Badge label={available ? presentStatus(item.health) : copy.containers.unknown} tone={healthTone(item.health, available)} /></div><dl><div><dt>{copy.containers.resources}</dt><dd>{item.metricsAvailable ? `${item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% · ${bytes(item.memoryBytes)}` : copy.containers.unknown}</dd></div><div><dt>{copy.containers.image}</dt><dd>{item.image || copy.containers.unknown}</dd></div></dl></li>)}</ul></>}
<nav className="list-pager" aria-label={copy.containers.page}><button className="button button--secondary" type="button" disabled={!cursor} onClick={previous}>{copy.containers.previous}</button><span ref={pageStatus} role="status" tabIndex={-1}>{copy.containers.page} {history.length + 1}</span><button className="button button--secondary" type="button" disabled={!snapshot.nextCursor} onClick={next}>{copy.containers.next}</button></nav>
</section>
</>;
}
type ContainerDetailResponse = { source: { id: string; state: string; freshness: string; observedAt?: string; receivedAt?: string; reason?: string }; container: ContainerItem };
export function ContainerDetailPage({ id }: { id: string }) {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [detail, setDetail] = useState<ContainerDetailResponse | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch('/api/v1/containers/' + encodeURIComponent(id), { signal: controller.signal }).then((response) => {
if (!response.ok) throw new Error('container-detail');
return response.json() as Promise<ContainerDetailResponse>;
}).then((data) => { setDetail(data); setState('ready'); }).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState('error');
});
return () => controller.abort();
}, [id]);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.containers.loading}</h1></section>;
if (state === 'error' || !detail) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.containers.errorTitle}</h1><p>{copy.containers.errorDetail}</p><a className="button" href="/containers">{copy.containers.backToList}</a></section>;
const item = detail.container;
const fresh = detail.source?.freshness === 'fresh';
return <>
<header className="page-intro"><p className="eyebrow">{copy.containers.detailEyebrow}</p><h1>{item.name}</h1><p className="intro">{copy.containers.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/containers">{copy.containers.backToList}</a><a className="button button--secondary" href="/dashboards?filterKey=sourceType&filterValue=inventory">{copy.containers.openDashboard}</a></div></header>
<section className="card container-summary" aria-labelledby="container-detail-summary"><div className="card-heading"><div><p className="card-kicker">{copy.containers.source}</p><h2 id="container-detail-summary">{detail.source?.id || copy.containers.unknown}</h2><p className="card-copy">{detail.source?.reason ? presentReason(detail.source.reason) : (fresh ? copy.containers.fresh : copy.containers.stale)}</p></div><Badge label={detail.source?.state === 'healthy' ? copy.containers.available : copy.containers.unknown} tone={detail.source?.state === 'healthy' ? 'ready' : 'unknown'} /></div><p className="container-provenance">{copy.containers.observed}: {detail.source?.observedAt ? formatDateTime(detail.source.observedAt) : '—'} · {fresh ? copy.containers.fresh : copy.containers.stale}</p><div className="container-detail-status"><Badge label={fresh ? presentStatus(item.state) : copy.containers.unknown} tone={runtimeTone(item.state, fresh)} /><Badge label={fresh ? presentStatus(item.health) : copy.containers.unknown} tone={healthTone(item.health, fresh)} />{item.intentionalStop && <span className="status-note">{copy.containers.intentionalStop}</span>}</div></section>
<section className="card-grid container-detail-grid" aria-label={copy.containers.metrics}><article className="card"><p className="card-kicker">{copy.containers.resources}</p><h2>{item.metricsAvailable ? item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : copy.containers.unknown}</h2><p className="card-copy">{item.metricsAvailable ? `${bytes(item.memoryBytes)} / ${bytes(item.memoryLimitBytes)}` : copy.containers.metricsUnavailable} · {copy.containers.restarts}: {item.lifecycleAvailable ? item.restartCount : copy.containers.unknown}</p></article><article className="card"><p className="card-kicker">{copy.containers.image}</p><h2>{item.image || copy.containers.unknown}</h2><p className="card-copy">{item.project || copy.containers.noProject}</p></article></section>
<details className="card technical-details"><summary>{copy.containers.technical}</summary><dl className="technical-grid"><dt>ID</dt><dd>{item.id}</dd><dt>{copy.containers.exitCode}</dt><dd>{item.lifecycleAvailable ? item.exitCode : copy.containers.unknown}</dd><dt>{copy.containers.network}</dt><dd>{item.metricsAvailable ? `${bytes(item.networkRxBytes)} RX / ${bytes(item.networkTxBytes)} TX` : copy.containers.unknown}</dd><dt>{copy.containers.blockIO}</dt><dd>{item.metricsAvailable ? `${bytes(item.blockReadBytes)} read / ${bytes(item.blockWriteBytes)} write` : copy.containers.unknown}</dd><dt>{copy.containers.digest}</dt><dd>{item.imageDigest || copy.containers.noDigest}</dd></dl><h2>{copy.containers.ports}</h2>{item.ports?.length ? <ul className="technical-list">{item.ports.map((port) => <li key={port.containerPort + '-' + port.protocol}>{port.hostPort || '—'} {port.containerPort}/{port.protocol}</li>)}</ul> : <p className="card-copy">{copy.containers.noData}</p>}</details>
</>;
}
+244
View File
@@ -0,0 +1,244 @@
import { useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react';
import { copy } from './copy';
import { WidgetConfigDrawer, type PreviewResult, type ValidationErrors } from './WidgetConfigDrawer';
import { DashboardVariablesEditor, validateVariables, type DashboardVariable } from './DashboardVariablesEditor';
import { DashboardTransfer } from './DashboardTransfer';
export type RecordValue = Record<string, unknown>;
export type EditorWidget = { id: string; type: string; title: string; description?: string; data?: RecordValue; visualization?: RecordValue; behavior?: RecordValue; layouts?: RecordValue };
type EditorProps = { dashboardId: string; revision: number; document: RecordValue; onExit: () => void; onSaved: (revision: number, document: RecordValue) => void };
const shell = copy.editor.shell;
const cardCopy = copy.editor.card;
const messages = copy.editor.messages;
const validation = copy.editor.validation;
const widgetTypeCopy = copy.editor.widgetTypes;
export const types = ['stat', 'timeseries', 'gauge', 'ranked-list', 'status-grid', 'table', 'heatmap', 'event-timeline', 'storage-map', 'topology', 'service-matrix', 'alert-summary', 'text', 'query-inspector'];
export const labels: Record<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 };
const sourceTypes = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'];
const ranges = ['live', '15m', '1h', '6h', '24h', '7d'];
const aggregations = ['avg', 'min', 'max', 'sum', 'last'];
type Viewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
const viewports: Viewport[] = ['desktop', 'tablet', 'mobile', 'wallboard'];
const viewportLabels: Record<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;
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return (value[name] ?? value[upper] ?? value[name.toUpperCase()]) as T | undefined;
}
function copyWidget(value: EditorWidget): EditorWidget { return JSON.parse(JSON.stringify(value)) as EditorWidget; }
function widgetId(): string { return typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : 'widget-' + Date.now().toString(36); }
function layout(widget: EditorWidget, viewport: Viewport = 'desktop'): RecordValue {
const layouts = widget.layouts ?? {};
const fallback = read<RecordValue>(layouts, 'desktop') ?? {};
return { ...fallback, ...(read<RecordValue>(layouts, viewport) ?? {}) };
}
function withLayout(widget: EditorWidget, viewport: Viewport, nextLayout: RecordValue): EditorWidget {
return { ...widget, layouts: { ...(widget.layouts ?? {}), [viewport]: nextLayout } };
}
function layoutWidth(widget: EditorWidget, viewport: Viewport): number {
return Math.min(viewportColumns[viewport], Math.max(1, Number(layout(widget, viewport).w ?? 6) || 1));
}
function normalizeWidget(widget: EditorWidget, index: number): EditorWidget {
const base = { x: 0, y: index * 4, w: widget.type === 'timeseries' ? 9 : 6, h: 4, visible: true };
const layouts = widget.layouts ?? {};
const desktop = { ...base, ...(read<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') ?? {}) };
return { ...widget, data: { sourceType: 'inventory', limit: 100, ...(widget.data ?? {}) }, visualization: { decimals: 0, thresholds: [], ...(widget.visualization ?? {}) }, behavior: { locked: false, hidden: false, liveIntervalSeconds: 30, ...(widget.behavior ?? {}) }, layouts: { desktop, tablet, mobile, wallboard } };
}
function newWidget(type: string, index: number): EditorWidget {
const baseLayout = { x: 0, y: index * 4, w: type === 'timeseries' ? 9 : 6, h: 4, visible: true };
return { id: widgetId(), type, title: labels[type] ?? widgetTypeCopy.newWidget, data: { sourceType: type === 'text' ? 'text' : 'inventory', limit: 100 }, visualization: { decimals: 0, thresholds: [] }, behavior: { locked: false, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, liveIntervalSeconds: 30 }, layouts: { desktop: baseLayout, tablet: baseLayout, mobile: { ...baseLayout, w: 1 }, wallboard: baseLayout } };
}
function numberValue(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
export function validateWidget(widget: EditorWidget): ValidationErrors {
const errors: ValidationErrors = {};
if (!widget.id.trim()) errors.id = validation.idRequired;
if (!types.includes(widget.type)) errors.type = validation.typeUnsupported;
if (!widget.title.trim()) errors.title = validation.titleRequired;
if (widget.title.length > 120) errors.title = validation.titleTooLong;
const data = widget.data ?? {};
const source = typeof data.sourceType === 'string' ? data.sourceType : '';
if (!sourceTypes.includes(source)) errors['data.sourceType'] = validation.sourceUnsupported;
if (source === 'semantic-metric') {
const metric = typeof data.metric === 'string' ? data.metric.trim() : '';
if (!metric) errors['data.metric'] = validation.metricRequired;
if (/promql|query=/i.test(metric)) errors['data.metric'] = validation.metricNoPromql;
}
if (typeof data.range === 'string' && data.range && !ranges.includes(data.range) && !data.range.startsWith('$')) errors['data.range'] = validation.rangeUnsupported;
if (typeof data.aggregation === 'string' && data.aggregation && !aggregations.includes(data.aggregation)) errors['data.aggregation'] = validation.aggregationUnsupported;
const limit = numberValue(data.limit);
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 1000)) errors['data.limit'] = validation.limitRange;
const visualization = widget.visualization ?? {};
const decimals = numberValue(visualization.decimals);
if (decimals !== undefined && (!Number.isInteger(decimals) || decimals < 0 || decimals > 6)) errors['visualization.decimals'] = validation.decimalsRange;
const minimum = numberValue(visualization.min);
const maximum = numberValue(visualization.max);
if (minimum !== undefined && maximum !== undefined && minimum > maximum) errors['visualization.max'] = validation.maxBelowMin;
const interval = numberValue((widget.behavior ?? {}).liveIntervalSeconds);
if (interval !== undefined && (!Number.isInteger(interval) || interval < 1 || interval > 300)) errors['behavior.liveIntervalSeconds'] = validation.intervalRange;
return errors;
}
export function DashboardEditor({ dashboardId, revision, document, onExit, onSaved }: EditorProps) {
const initial = (read<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 [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 [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);
const [message, setMessage] = useState('');
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewFor, setPreviewFor] = useState<string | null>(null);
const [previewState, setPreviewState] = useState('loading');
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState('');
const resize = useRef<{ id: string; x: number; w: number } | null>(null);
const drag = useRef<{ id: string; y: number } | null>(null);
const update = (id: string, change: (widget: EditorWidget) => EditorWidget) => applyDraft((items) => items.map((item) => item.id === id ? change(item) : item));
const selectedWidget = draft.find((item) => item.id === selected);
const dirty = JSON.stringify({ widgets: draft, variables }) !== savedSnapshot;
const applyDraft = (change: (items: EditorWidget[]) => EditorWidget[]): void => setDraft((items) => {
const next = change(items);
if (next === items) return items;
setHistory((entries) => [...entries, items.map(copyWidget)].slice(-50));
setFuture([]);
return next;
});
const selectedErrors = selectedWidget ? validateWidget(selectedWidget) : {};
const updateSelected = (next: EditorWidget) => { setPreview(null); setPreviewFor(null); setPreviewError(''); if (selected) update(selected, () => next); };
const move = (id: string, direction: -1 | 1) => applyDraft((items) => {
const index = items.findIndex((item) => item.id === id);
const next = index + direction;
if (index < 0 || next < 0 || next >= items.length || read<boolean>(items[index].behavior, 'locked')) return items;
const copy = [...items]; const [item] = copy.splice(index, 1); copy.splice(next, 0, item); return copy;
});
const onWidgetPointerDown = (event: ReactPointerEvent<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 };
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 onWidgetPointerUp = (event: ReactPointerEvent<HTMLElement>) => {
drag.current = null;
setDragged(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const duplicate = (id: string) => applyDraft((items) => {
const source = items.find((item) => item.id === id); if (!source) return items;
const copy = copyWidget(source); copy.id = widgetId(); copy.title = copy.title + widgetTypeCopy.copySuffix; copy.behavior = { ...(copy.behavior ?? {}), locked: false, hidden: false }; return [...items, copy];
});
const remove = (id: string) => { applyDraft((items) => items.filter((item) => item.id !== id)); setSelected((value) => value === id ? null : value); };
const add = () => { const item = newWidget(newType, draft.length); applyDraft((items) => [...items, item]); setSelected(item.id); };
// Single width mutation used by the pointer handle, the arrow keys and the
// width field in the configuration drawer, so all three stay in step.
const setWidgetWidth = (id: string, next: number) => {
if (!Number.isFinite(next)) return;
const width = Math.min(viewportColumns[activeViewport], Math.max(1, Math.round(next)));
update(id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), w: width }));
};
const onResizeMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
const active = resize.current; if (!active) return;
setWidgetWidth(active.id, active.w + Math.round((event.clientX - active.x) / 48));
};
// Keyboard alternative for the pointer-only resize handle (UX_SPEC 5 and 12).
const onResizeKeyDown = (event: ReactKeyboardEvent<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;
if (step === 0 && event.key !== 'Home' && event.key !== 'End') return;
event.preventDefault();
event.stopPropagation();
setWidgetWidth(id, event.key === 'Home' ? 1 : event.key === 'End' ? max : current + step);
};
const undo = () => {
const previous = history[history.length - 1];
if (!previous) return;
setHistory((entries) => entries.slice(0, -1));
setFuture((entries) => [draft.map(copyWidget), ...entries].slice(0, 50));
setDraft(previous.map(copyWidget));
};
const redo = () => {
const next = future[0];
if (!next) return;
setFuture((entries) => entries.slice(1));
setHistory((entries) => [...entries, draft.map(copyWidget)].slice(-50));
setDraft(next.map(copyWidget));
};
const requestExit = () => { if (dirty) setConfirmExit(true); else onExit(); };
const importDocument = (nextDocument: RecordValue) => {
const importedWidgets = (read<unknown[]>(nextDocument, 'widgets') ?? []) as EditorWidget[];
const importedVariables = (read<unknown[]>(nextDocument, 'variables') ?? []) as DashboardVariable[];
setDraft(importedWidgets.map((widget, index) => normalizeWidget(copyWidget(widget), index)));
setVariables(importedVariables);
setHistory([]); setFuture([]); setMessage(messages.importLoaded);
};
const reloadServer = async () => {
try {
const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId));
if (!response.ok) throw new Error('reload');
const data = await response.json() as { version?: RecordValue };
const serverDocument = read<RecordValue>(data.version, 'document') ?? {};
const serverWidgets = (read<unknown[]>(serverDocument, 'widgets') ?? []) as EditorWidget[];
const next = serverWidgets.map((widget, index) => normalizeWidget(copyWidget(widget), index));
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); }
};
const previewSelected = async () => {
if (!selectedWidget || Object.keys(selectedErrors).length > 0) { setPreviewError(messages.previewBlocked); return; }
setPreviewing(true); setPreviewError('');
try {
const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId) + '/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ widget: selectedWidget, state: previewState }) });
const data = await response.json() as { preview?: PreviewResult; detail?: string };
if (!response.ok || !data.preview) { setPreviewError(data.detail ?? messages.previewFailed); return; }
setPreview(data.preview); setPreviewFor(selectedWidget.id);
} catch { setPreviewError(messages.previewFailedConnection); } finally { setPreviewing(false); }
};
const save = async () => {
const invalid = draft.map((widget) => ({ widget, errors: validateWidget(widget) })).find((item) => Object.keys(item.errors).length > 0);
if (invalid) { setSelected(invalid.widget.id); setMessage(messages.saveBlockedWidgets); return; }
if (Object.keys(validateVariables(variables)).length > 0) { setMessage(messages.saveBlockedVariables); return; }
setSaving(true); setMessage('');
const body = { ...document, widgets: draft, variables };
try {
const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId) + '/document', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'If-Match': String(revision) }, body: JSON.stringify(body) });
if (!response.ok) { if (response.status === 409) { setConflict(true); setMessage(messages.saveConflict); } else setMessage(messages.saveFailed); return; }
const data = await response.json() as RecordValue;
const summary = read<RecordValue>(data, 'dashboard') ?? {};
setSavedSnapshot(JSON.stringify({ widgets: body.widgets, variables: body.variables })); setConflict(false); onSaved(Number(read(summary, 'revision') ?? revision + 1), body);
} catch { setMessage(messages.saveFailedConnection); } finally { setSaving(false); }
};
return <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>
<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>
</section>;
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { resolveDashboardScope } from './dashboardScope';
import { LiveChartAdapter, historicalSamplesFromData, type LiveFreshness } from './liveBuffer';
import { LiveClient } from './liveClient';
import { MetricClient, rangeForPreset, type MetricQueryRequest, type MetricRangePreset } from './metricClient';
import { MetricWidget, StatusGridWidget, type MetricWidgetProps, type StatusGridItem } from './MetricWidgets';
import { buildStorageNodes, type StorageData } from './StoragePage';
import { StorageMapWidget } from './StorageVisuals';
import { aggregateStatus, useSystemStatus } from './systemStatus';
import { useLiveMetric } from './useLiveMetric';
import { useMetricQuery } from './useMetricQuery';
import { formatDateTime } from './locale';
import { copy } from './copy';
import { presentEventSummary, presentEventType, presentReason, presentStatus } from './presentation';
import { wallboardColumns, wallboardPlacement } from './wallboardLayout';
type RecordValue = Record<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';
const metricClient = new MetricClient();
const liveClient = new LiveClient();
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 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>;
}
type Resource = { state: RuntimeState; items?: StatusGridItem[]; storage?: StorageData; events?: EventItem[]; error?: string };
type EventItem = { id: string; type: string; severity: string; summary: string; occurredAt: string; sourceId?: string };
function useInventoryResource(widget: RuntimeWidget): Resource {
const [resource, setResource] = useState<Resource>({ state: 'loading' });
const sourceType = String(field(widget.data, 'sourceType') ?? '');
const scope = field<RecordValue>(widget.data, 'scope') ?? {};
const entityType = String(field(scope, 'entityType') ?? '');
const isApplications = sourceType === 'inventory' && entityType === 'application';
const isStorage = sourceType === 'inventory' && Array.isArray(field(scope, 'entityTypes'));
const isEvents = sourceType === 'events';
const interval = Math.min(300, Math.max(5, Number(field(widget.behavior, 'liveIntervalSeconds') ?? 15))) * 1000;
useEffect(() => {
if (!isApplications && !isStorage && !isEvents) {
setResource({ state: 'empty' });
return undefined;
}
let active = true;
let controller: AbortController | null = null;
const read = async <T,>(url: string, signal: AbortSignal): Promise<T> => {
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) {
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),
]);
const storage = { array, disks, pools };
if (active) setResource({ state: buildStorageNodes(storage).length ? 'usable' : 'empty', storage });
} else {
const limit = Math.min(100, Math.max(1, Number(field(widget.data, 'limit') ?? 100)));
const value = await read<{ items?: EventItem[] }>('/api/v1/events?limit=' + limit, current.signal);
const events = (value.items ?? []).slice(0, limit);
if (active) setResource({ state: events.length ? 'usable' : 'empty', events });
}
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (active) setResource({ state: 'error', error: error instanceof Error ? error.message : 'bron niet beschikbaar' });
}
};
void load();
const timer = window.setInterval(load, interval);
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
}, [isApplications, isStorage, isEvents, interval, widget.data]);
return resource;
}
export function DashboardRuntimeWidget({ widget, viewport, document, timeRange, onState, onFilter }: { widget: RuntimeWidget; viewport: 'desktop' | 'tablet' | 'mobile' | 'wallboard'; document: RecordValue; timeRange: string; onState: (id: string, state: RuntimeState) => void; onFilter: () => void }) {
const sourceType = String(field(widget.data, 'sourceType') ?? '');
const semantic = sourceType === 'semantic-metric';
const system = useSystemStatus();
const inventory = useInventoryResource(widget);
const range = useMemo(() => rangeForPreset(timeRange as MetricRangePreset), [timeRange]);
const request = useMemo<MetricQueryRequest | null>(() => {
if (!semantic) 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]);
const metricState = useMetricQuery(metricClient, request);
const historicalSamples = useMemo(() => metricState.status === 'success' ? historicalSamplesFromData(metricState.response?.data) : [], [metricState.status, metricState.response?.data]);
const freshness: LiveFreshness = metricState.response?.freshness ?? 'unavailable';
const historicalSeries = useMemo(() => { const adapter = new LiveChartAdapter(4000); adapter.append(historicalSamples.map((sample) => ({ ...sample, freshness }))); return adapter.snapshot(); }, [historicalSamples, freshness]);
const live = useLiveMetric(liveClient, semantic && timeRange === 'live' ? request : null, historicalSamples);
const systemWidget = sourceType === 'inventory' && String(field(field<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;
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>;
}
+50
View File
@@ -0,0 +1,50 @@
import { copy } from './copy';
import type { RecordValue } from './DashboardEditor';
export type TransferResult = { document: RecordValue; errors: string[] };
const transferCopy = copy.editor.transfer;
function hasUnsafeText(value: unknown): boolean {
if (typeof value === 'string') return /<\s*script|<\s*iframe|javascript:/i.test(value);
if (Array.isArray(value)) return value.some(hasUnsafeText);
if (value && typeof value === 'object') return Object.values(value as RecordValue).some(hasUnsafeText);
return false;
}
function depth(value: unknown, current = 0): number {
if (!value || typeof value !== 'object') return current;
const children = Array.isArray(value) ? value : Object.values(value as RecordValue);
return children.reduce((max, child) => Math.max(max, depth(child, current + 1)), current);
}
export function validatePortableDashboard(value: unknown): TransferResult {
const errors: string[] = [];
if (!value || typeof value !== 'object' || Array.isArray(value)) return { document: {}, errors: [transferCopy.errors.notAnObject] };
const document = value as RecordValue;
const schemaVersion = document.schemaVersion;
if (schemaVersion !== 1 && schemaVersion !== 2) errors.push(transferCopy.errors.schemaVersion);
const widgets = Array.isArray(document.widgets) ? document.widgets : [];
const variables = Array.isArray(document.variables) ? document.variables : [];
if (!Array.isArray(document.widgets)) errors.push(transferCopy.errors.widgetsArray);
if (!Array.isArray(document.variables)) errors.push(transferCopy.errors.variablesArray);
if (widgets.length > 200) errors.push(transferCopy.errors.tooManyWidgets);
if (variables.length > 30) errors.push(transferCopy.errors.tooManyVariables);
if (depth(value) > 12) errors.push(transferCopy.errors.tooDeep);
if (hasUnsafeText(value)) errors.push(transferCopy.errors.unsafeContent);
return { document, errors };
}
export function parsePortableDashboard(text: string): TransferResult {
if (text.length > 2 * 1024 * 1024) return { document: {}, errors: [transferCopy.errors.tooLarge] };
try { return validatePortableDashboard(JSON.parse(text)); } catch { return { document: {}, errors: [transferCopy.errors.invalidJson] }; }
}
type Props = { document: RecordValue; onImport: (document: RecordValue) => void };
const templateLabels: Record<string, string> = { empty: transferCopy.templateEmpty, operations: transferCopy.templateOperations };
const templates: Record<string, RecordValue> = {
empty: { schemaVersion: 2, id: '00000000-0000-0000-0000-000000000000', slug: 'nieuw-dashboard', name: transferCopy.templateEmptyName, scope: 'personal', variables: [], widgets: [], settings: { defaultTimeRange: '1h' } },
operations: { schemaVersion: 2, id: '00000000-0000-0000-0000-000000000000', slug: 'operations-template', name: transferCopy.templateOperations, scope: 'personal', variables: [{ name: 'timeRange', type: 'time-range', label: copy.editor.variables.periodLabel, default: '1h', options: ['live', '15m', '1h', '6h', '24h', '7d'] }], widgets: [], settings: { defaultTimeRange: '1h' } }
};
export function DashboardTransfer({ document, onImport }: Props) {
const exportDocument = () => { const blob = new Blob([JSON.stringify(document, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = window.document.createElement('a'); link.href = url; link.download = 'pulse-dashboard.json'; link.click(); URL.revokeObjectURL(url); };
const importDocument = async (file: File) => { const result = parsePortableDashboard(await file.text()); if (result.errors.length) { window.alert(result.errors.join(' ')); return; } onImport(result.document); };
return <section className="dashboard-transfer card" aria-labelledby="dashboard-transfer-title"><div className="card-heading"><div><p className="card-kicker">{transferCopy.kicker}</p><h2 id="dashboard-transfer-title">{transferCopy.title}</h2></div><button className="button button--secondary" type="button" onClick={exportDocument}>{transferCopy.export}</button></div><label className="transfer-import">{transferCopy.import}<input type="file" accept="application/json,.json" onChange={(event) => { const file = event.target.files?.[0]; if (file) void importDocument(file); event.currentTarget.value = ''; }} /></label><div className="template-list" aria-label={transferCopy.templates}>{Object.entries(templates).map(([key, template]) => <button className="button button--secondary" type="button" key={key} onClick={() => onImport(JSON.parse(JSON.stringify(template)))}>{templateLabels[key] ?? key}</button>)}</div></section>;
}
+41
View File
@@ -0,0 +1,41 @@
import { copy } from './copy';
export type DashboardVariable = { name: string; type: string; label: string; default: string; options?: string[] };
export type VariableErrors = Record<string, string>;
const variableTypes = ['server', 'entity', 'container', 'application', 'disk', 'pool', 'service', 'time-range', 'enum'];
const text = copy.editor.variables;
export function validateVariables(variables: DashboardVariable[]): VariableErrors {
const errors: VariableErrors = {};
if (variables.length > 30) errors.form = text.errors.tooMany;
const names = new Set<string>();
variables.forEach((variable, index) => {
const prefix = 'variables.' + index;
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(variable.name)) errors[prefix + '.name'] = text.errors.nameFormat;
if (names.has(variable.name)) errors[prefix + '.name'] = text.errors.nameUnique;
names.add(variable.name);
if (!variableTypes.includes(variable.type)) errors[prefix + '.type'] = text.errors.typeUnsupported;
if (!variable.label.trim()) errors[prefix + '.label'] = text.errors.labelRequired;
if (variable.options && variable.options.length > 1000) errors[prefix + '.options'] = text.errors.tooManyOptions;
if ((variable.type === 'entity' || variable.type === 'server') && variable.options?.length && !variable.options.includes(variable.default)) errors[prefix + '.default'] = text.errors.defaultNotAllowed;
});
return errors;
}
type Props = { variables: DashboardVariable[]; onChange: (variables: DashboardVariable[]) => void };
export function DashboardVariablesEditor({ variables, onChange }: Props) {
const errors = validateVariables(variables);
const error = (key: string) => errors[key] ? <p className="field-error">{errors[key]}</p> : null;
const update = (index: number, change: (variable: DashboardVariable) => DashboardVariable) => onChange(variables.map((variable, current) => current === index ? change(variable) : variable));
const add = () => onChange([...variables, { name: 'variable' + (variables.length + 1), type: 'time-range', label: text.newLabel, default: '1h', options: ['live', '15m', '1h', '6h', '24h', '7d'] }]);
const remove = (index: number) => onChange(variables.filter((_, current) => current !== index));
return <section className="variable-editor card" aria-labelledby="variable-editor-title">
<div className="card-heading"><div><p className="card-kicker">{text.kicker}</p><h2 id="variable-editor-title">{text.title}</h2></div><button className="button button--secondary" type="button" onClick={add}>{text.add}</button></div>
<p className="card-copy">{text.intro}</p>
{errors.form && <p className="field-error" role="alert">{errors.form}</p>}
{variables.length === 0 && <p className="card-copy">{text.empty}</p>}
<div className="variable-list">{variables.map((variable, index) => { const prefix = 'variables.' + index; const options = variable.options ?? []; return <article className="variable-row" key={index}><div className="config-fields"><label htmlFor={prefix + '-name'}>{text.name}<input id={prefix + '-name'} value={variable.name} onChange={(event) => update(index, (item) => ({ ...item, name: event.target.value }))} /></label><label htmlFor={prefix + '-label'}>{text.label}<input id={prefix + '-label'} value={variable.label} onChange={(event) => update(index, (item) => ({ ...item, label: event.target.value }))} /></label></div>{error(prefix + '.name')}{error(prefix + '.label')}<div className="config-fields"><label htmlFor={prefix + '-type'}>{text.type}<select id={prefix + '-type'} value={variable.type} onChange={(event) => update(index, (item) => ({ ...item, type: event.target.value }))}>{variableTypes.map((type) => <option key={type} value={type}>{type}</option>)}</select></label><label htmlFor={prefix + '-default'}>{text.default}{(variable.type === 'entity' || variable.type === 'server') && options.length > 0 ? <select id={prefix + '-default'} value={variable.default} onChange={(event) => update(index, (item) => ({ ...item, default: event.target.value }))}>{options.map((option) => <option key={option} value={option}>{option}</option>)}</select> : <input id={prefix + '-default'} value={variable.default} disabled={variable.type === 'entity' || variable.type === 'server'} onChange={(event) => update(index, (item) => ({ ...item, default: event.target.value }))} />}</label></div>{error(prefix + '.type')}{error(prefix + '.default')}<label htmlFor={prefix + '-options'}>{text.options}<input id={prefix + '-options'} value={options.join(',')} placeholder={text.optionsPlaceholder} onChange={(event) => update(index, (item) => ({ ...item, options: event.target.value.split(',').map((option) => option.trim()).filter(Boolean) }))} /></label>{error(prefix + '.options')}<button className="button button--secondary variable-remove" type="button" onClick={() => remove(index)}>{text.remove}</button></article>; })}</div>
</section>;
}
File diff suppressed because one or more lines are too long
+110
View File
@@ -0,0 +1,110 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { copy } from './copy';
import { formatDateTime, hasReceivedTimestamp } from './locale';
import { queryValue, replaceListQuery } from './listQuery';
import { presentEventSummary, presentEventType, presentStatus } from './presentation';
type EventItem = { id: string; type: string; severity: string; entityId?: string; sourceId?: string; occurredAt: string; receivedAt: string; summary: string };
type EventResponse = { items?: EventItem[] };
type LoadState = 'loading' | 'ready' | 'error';
const pageSize = 20;
const severities = ['critical', 'warning', 'attention', 'info', 'unknown'] as const;
function severityTone(value: string): 'ready' | 'attention' | 'critical' | 'unknown' {
if (value === 'critical') return 'critical';
if (value === 'warning' || value === 'attention' || value === 'error') return 'attention';
if (value === 'info') return 'ready';
return 'unknown';
}
function initialPage(): number {
const value = Number.parseInt(queryValue('page'), 10);
return Number.isFinite(value) && value > 1 ? value - 1 : 0;
}
function searchableText(item: EventItem): string {
return [item.summary, presentEventSummary(item.type, item.summary), item.type, presentEventType(item.type), item.entityId, item.sourceId]
.filter(Boolean)
.join(' ')
.toLocaleLowerCase('nl-BE');
}
export function EventsPage() {
const [state, setState] = useState<LoadState>('loading');
const [items, setItems] = useState<EventItem[]>([]);
const [search, setSearch] = useState(() => queryValue('q'));
const deferredSearch = useDeferredValue(search);
const [severity, setSeverity] = useState(() => queryValue('severity', severities));
const [type, setType] = useState(() => queryValue('type'));
const [entity, setEntity] = useState(() => queryValue('entity'));
const [page, setPage] = useState(initialPage);
const [revision, setRevision] = useState(0);
const pageStatus = useRef<HTMLSpanElement>(null);
useEffect(() => {
const controller = new AbortController();
setState('loading');
fetch('/api/v1/events?limit=100', { signal: controller.signal })
.then((response) => { if (!response.ok) throw new Error('events'); return response.json() as Promise<EventResponse>; })
.then((value) => { setItems((value.items ?? []).slice(0, 100)); setState('ready'); })
.catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
return () => controller.abort();
}, [revision]);
const types = useMemo(() => [...new Set(items.map((item) => item.type))].sort((a, b) => presentEventType(a).localeCompare(presentEventType(b), 'nl-BE')), [items]);
const entities = useMemo(() => [...new Set(items.map((item) => item.entityId).filter((value): value is string => Boolean(value)))].sort((a, b) => a.localeCompare(b)), [items]);
const normalizedSearch = deferredSearch.trim().toLocaleLowerCase('nl-BE');
const shown = useMemo(() => items.filter((item) => (!severity || item.severity === severity)
&& (!type || item.type === type)
&& (!entity || item.entityId === entity)
&& (!normalizedSearch || searchableText(item).includes(normalizedSearch))), [entity, items, normalizedSearch, severity, type]);
const criticalCount = items.filter((item) => item.severity === 'critical').length;
const pageCount = Math.max(1, Math.ceil(shown.length / pageSize));
const currentPage = Math.min(page, pageCount - 1);
const pageItems = shown.slice(currentPage * pageSize, (currentPage + 1) * pageSize);
const firstResult = shown.length === 0 ? 0 : currentPage * pageSize + 1;
const lastResult = Math.min((currentPage + 1) * pageSize, shown.length);
useEffect(() => {
if (page !== currentPage) setPage(currentPage);
}, [currentPage, page]);
useEffect(() => {
replaceListQuery({ q: search.trim(), severity, type, entity, page: currentPage > 0 ? String(currentPage + 1) : '' });
}, [currentPage, entity, search, severity, type]);
const resetPage = () => setPage(0);
const movePage = (next: number) => {
setPage(next);
requestAnimationFrame(() => pageStatus.current?.focus());
};
const clearFilters = () => { setSearch(''); setSeverity(''); setType(''); setEntity(''); setPage(0); };
return <>
<header className="page-intro"><p className="eyebrow">{copy.events.eyebrow}</p><h1>{copy.events.title}</h1><p className="intro">{copy.events.intro}</p></header>
<section className="event-summary" aria-label={copy.events.summary}>
<article className="card"><p className="card-kicker">{copy.events.loaded}</p><h2>{items.length}</h2><p className="card-copy">{copy.events.loadedDetail}</p></article>
<button className="card event-summary-action" type="button" onClick={() => { setSeverity('critical'); setPage(0); }}><span className="card-kicker">{copy.events.criticalSummary}</span><strong>{criticalCount}</strong><small>{criticalCount ? copy.events.showCritical : copy.events.noCritical}</small></button>
<article className="card"><p className="card-kicker">{copy.events.results}</p><h2>{shown.length}</h2><p className="card-copy">{firstResult}{lastResult} {copy.events.of} {shown.length}</p></article>
</section>
<section className="card event-timeline" aria-labelledby="event-timeline-title">
<div className="card-heading"><div><p className="card-kicker">{copy.events.timeline}</p><h2 id="event-timeline-title">{copy.events.latest}</h2></div><button className="button button--secondary" type="button" onClick={() => setRevision((value) => value + 1)}>{copy.events.refresh}</button></div>
<form className="list-filters event-filters" onSubmit={(event) => event.preventDefault()}>
<label>{copy.events.search}<input type="search" value={search} placeholder={copy.events.searchPlaceholder} onChange={(event) => { setSearch(event.target.value); resetPage(); }} /></label>
<label>{copy.events.severity}<select value={severity} onChange={(event) => { setSeverity(event.target.value); resetPage(); }}><option value="">{copy.events.allSeverities}</option><option value="critical">{copy.events.critical}</option><option value="warning">{copy.events.warning}</option><option value="attention">{copy.events.attention}</option><option value="info">{copy.events.info}</option><option value="unknown">{copy.events.unknown}</option></select></label>
<label>{copy.events.type}<select value={type} onChange={(event) => { setType(event.target.value); resetPage(); }}><option value="">{copy.events.allTypes}</option>{types.map((value) => <option key={value} value={value}>{presentEventType(value)}</option>)}</select></label>
<label>{copy.events.entity}<select value={entity} onChange={(event) => { setEntity(event.target.value); resetPage(); }}><option value="">{copy.events.allEntities}</option>{entities.map((value) => <option key={value} value={value}>{value}</option>)}</select></label>
<button className="button button--secondary" type="button" onClick={clearFilters} disabled={!search && !severity && !type && !entity}>{copy.events.clearFilters}</button>
</form>
{state === 'loading' ? <p className="card-copy" role="status">{copy.events.loading}</p> : state === 'error' ? <div role="alert"><h3>{copy.events.errorTitle}</h3><p className="card-copy">{copy.events.errorDetail}</p></div> : shown.length === 0 ? <div className="event-empty"><p className="card-copy">{copy.events.empty}</p><button className="button button--secondary" type="button" onClick={clearFilters}>{copy.events.clearEmpty}</button></div> : <ol className="event-list" aria-label={copy.events.results}>{pageItems.map((item) => <li key={item.id}>
<span className={'status-badge status-badge--' + severityTone(item.severity)}><span className="status-icon" aria-hidden="true">{item.severity === 'critical' ? '!' : '•'}</span>{presentStatus(item.severity)}</span>
<div className="event-row-content"><div className="event-row-heading"><h3>{presentEventType(item.type)}</h3>{hasReceivedTimestamp(item.occurredAt) ? <time dateTime={item.occurredAt}>{formatDateTime(item.occurredAt)}</time> : <span>{formatDateTime(item.occurredAt)}</span>}</div><p>{presentEventSummary(item.type, item.summary)}</p><small>{item.entityId ? `${copy.events.entity}: ${item.entityId}` : copy.events.noEntity}</small>
<details className="inline-technical"><summary>{copy.events.technical}</summary><dl className="technical-grid"><dt>ID</dt><dd>{item.id}</dd><dt>{copy.events.type}</dt><dd>{item.type}</dd><dt>{copy.events.source}</dt><dd>{item.sourceId || copy.events.noSource}</dd><dt>{copy.events.entity}</dt><dd>{item.entityId || copy.events.noEntity}</dd><dt>{copy.events.received}</dt><dd>{formatDateTime(item.receivedAt)}</dd></dl></details>
</div>
</li>)}</ol>}
{state === 'ready' && shown.length > 0 && <nav className="list-pager" aria-label={copy.events.pagination}><button className="button button--secondary" type="button" disabled={currentPage === 0} onClick={() => movePage(currentPage - 1)}>{copy.events.previous}</button><span ref={pageStatus} role="status" tabIndex={-1}>{copy.events.page} {currentPage + 1} {copy.events.of} {pageCount} · {firstResult}{lastResult}</span><button className="button button--secondary" type="button" disabled={currentPage + 1 >= pageCount} onClick={() => movePage(currentPage + 1)}>{copy.events.next}</button></nav>}
<p className="card-copy event-limit-note">{items.length} {copy.events.rows}</p>
</section>
</>;
}
+90
View File
@@ -0,0 +1,90 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentReason, presentStatus } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type HostSnapshot = {
source: { id: string; type: string; observedAt?: string; freshness: string; state: string; reason?: string };
identity: { name: string; version?: string; kernel?: string; architecture?: string };
uptimeSeconds: number;
bootTime?: string;
cpu: { totalPercent?: number; perCore?: number[]; iowaitPercent?: number };
load: { one: number; five: number; fifteen: number };
memory: { totalBytes: number; availableBytes: number; usedBytes: number; utilizationPercent: number; swapTotalBytes: number; swapUsedBytes: number; swapUtilizationPercent: number };
filesystems: Array<{ mount: string; filesystem?: string; capacityBytes: number; usedBytes: number; utilizationPercent: number; inodes?: { total: number; used: number } }>;
network: Array<{ name: string; state?: string; rxBytes: number; txBytes: number; rxErrors: number; txErrors: number; rxDrops: number; txDrops: number }>;
time: { synchronized: boolean; offsetSeconds: number; stratum?: number; state: string };
hardware?: { capabilities: Array<{ id: string; version: string; state: string; reason?: string }>; temperatures: Array<{ id: string; name: string; celsius: number }>; fans: Array<{ id: string; name: string; rpm: number }>; gpus: Array<{ id: string; name: string; vendor?: string; utilizationPercent?: number; memoryUsedBytes: number; memoryTotalBytes: number; memoryUtilizationPercent?: number; temperatureCelsius?: number }>; status: { state: string; reasons?: Array<{ code: string; message: string }> } };
status: { state: string; reasons?: Array<{ code: string; message: string }> };
observedAt?: string;
receivedAt: string;
warnings?: string[];
};
function getStatusLabel(state: string): string {
return state === 'healthy' ? 'Gezond' : state === 'degraded' ? 'Aandacht' : 'Onbekend';
}
function Badge({ state }: { state: string }) {
const ready = state === 'healthy';
return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{getStatusLabel(state)}</span>;
}
function bytes(value: number): string {
if (!Number.isFinite(value) || value < 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let scaled = value;
let index = 0;
while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; }
return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index];
}
function duration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '—';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return days > 0 ? days + ' d ' + hours + ' u' : hours + ' u ' + minutes + ' min';
}
function when(value?: string): string { return formatDateTime(value); }
export function HostPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<HostSnapshot | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch('/api/v1/host', { signal: controller.signal }).then((response) => {
if (!response.ok) throw new Error('host');
return response.json() as Promise<HostSnapshot>;
}).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState('error');
});
return () => controller.abort();
}, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.host.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.host.errorTitle}</h1><p>{copy.host.errorDetail}</p><button className="button" type="button" onClick={() => window.location.reload()}>{copy.host.retry}</button></section>;
const status = snapshot.status?.state ?? 'unknown';
const observed = snapshot.observedAt ?? snapshot.source?.observedAt;
return <>
<header className="page-intro"><p className="eyebrow">{copy.host.eyebrow}</p><h1>{copy.host.title}</h1><p className="intro">{copy.host.intro}</p></header>
<section className="host-summary card" aria-labelledby="host-summary-title">
<div className="card-heading"><div><p className="card-kicker">{copy.host.identity}</p><h2 id="host-summary-title">{snapshot.identity?.name || copy.host.unknown}</h2><p className="card-copy">{[snapshot.identity?.version, snapshot.identity?.kernel, snapshot.identity?.architecture].filter(Boolean).join(' · ') || copy.host.noIdentityDetails}</p></div><Badge state={status} /></div>
<SourceStatusDetails source={{ ...snapshot.source, observedAt: observed }} fallbackReason={snapshot.source?.freshness === 'fresh' ? copy.host.fresh : copy.host.stale} />
{snapshot.status?.reasons?.map((reason) => <p className="host-reason" key={reason.code}>{presentReason(reason.code)}</p>)}
</section>
<section className="host-metric-grid" aria-label={copy.host.metrics}>
<article className="card"><p className="card-kicker">{copy.host.uptime}</p><h2>{duration(snapshot.uptimeSeconds)}</h2><p className="card-copy">{snapshot.bootTime ? copy.host.boot + ' ' + when(snapshot.bootTime) : copy.host.noBoot}</p></article>
<article className="card"><p className="card-kicker">{copy.host.cpu}</p><h2>{snapshot.cpu?.totalPercent == null ? '—' : snapshot.cpu.totalPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</h2><p className="card-copy">{snapshot.cpu?.perCore?.length ?? 0} {copy.host.cores} · iowait {snapshot.cpu?.iowaitPercent == null ? '—' : snapshot.cpu.iowaitPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</p></article>
<article className="card"><p className="card-kicker">{copy.host.load}</p><h2>{snapshot.load?.one?.toLocaleString('nl-BE', { maximumFractionDigits: 2 }) ?? '—'}</h2><p className="card-copy">1 / 5 / 15 min: {snapshot.load?.one ?? '—'} · {snapshot.load?.five ?? '—'} · {snapshot.load?.fifteen ?? '—'}</p></article>
<article className="card"><p className="card-kicker">{copy.host.memory}</p><h2>{snapshot.memory?.utilizationPercent?.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) ?? '—'}%</h2><p className="card-copy">{bytes(snapshot.memory?.usedBytes ?? 0)} / {bytes(snapshot.memory?.totalBytes ?? 0)} · {copy.host.available}: {bytes(snapshot.memory?.availableBytes ?? 0)}</p></article>
<article className="card"><p className="card-kicker">{copy.host.time}</p><h2>{snapshot.time?.synchronized ? copy.host.synchronized : copy.host.notSynchronized}</h2><p className="card-copy">Offset {snapshot.time?.offsetSeconds?.toLocaleString('nl-BE', { maximumFractionDigits: 3 }) ?? '—'} s · stratum {snapshot.time?.stratum ?? '—'}</p></article>
</section>
<section className="host-detail-grid">
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.filesystems}</p><h2>{snapshot.filesystems?.length ?? 0}</h2></div></div>{snapshot.filesystems?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.mount}</th><th>{copy.host.used}</th><th>{copy.host.inodes}</th></tr></thead><tbody>{snapshot.filesystems.map((item) => <tr key={item.mount}><th scope="row">{item.mount}</th><td>{item.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%<small>{bytes(item.usedBytes)} / {bytes(item.capacityBytes)}</small></td><td>{item.inodes ? ((item.inodes.used / item.inodes.total) * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noData}</p>}</article>
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.network}</p><h2>{snapshot.network?.length ?? 0}</h2></div></div>{snapshot.network?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.interface}</th><th>RX / TX</th><th>{copy.host.errors}</th></tr></thead><tbody>{snapshot.network.map((item) => <tr key={item.name}><th scope="row">{item.name}<small>{item.state || copy.host.unknown}</small></th><td>{bytes(item.rxBytes)} / {bytes(item.txBytes)}</td><td>{item.rxErrors + item.txErrors + item.rxDrops + item.txDrops}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noData}</p>}</article>
</section>
<section className="host-detail-grid">
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.hardware}</p><h2>{snapshot.hardware?.temperatures?.length ?? 0} · {snapshot.hardware?.fans?.length ?? 0} {copy.host.sensors}</h2></div><Badge state={snapshot.hardware?.status?.state ?? 'unknown'} /></div>{snapshot.hardware?.status?.reasons?.map((reason) => <p className="host-reason" key={reason.code}>{presentReason(reason.code)}</p>)}{snapshot.hardware?.capabilities?.length ? <ul className="host-capabilities">{snapshot.hardware.capabilities.map((capability) => <li key={capability.id}><span>{capability.id}</span><small>{presentStatus(capability.state)} · {presentReason(capability.reason)}</small></li>)}</ul> : null}{snapshot.hardware?.temperatures?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.sensor}</th><th>{copy.host.temperature}</th></tr></thead><tbody>{snapshot.hardware.temperatures.map((item) => <tr key={item.id}><th scope="row">{item.name}<small>{item.id}</small></th><td>{item.celsius.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} °C</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noSensors}</p>}</article>
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.gpu}</p><h2>{snapshot.hardware?.gpus?.length ?? 0}</h2></div></div>{snapshot.hardware?.gpus?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.device}</th><th>{copy.host.gpuUsage}</th><th>{copy.host.gpuMemory}</th></tr></thead><tbody>{snapshot.hardware.gpus.map((item) => <tr key={item.id}><th scope="row">{item.name}<small>{item.vendor || copy.host.unknown}</small></th><td>{item.utilizationPercent == null ? '—' : item.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</td><td>{item.memoryTotalBytes ? item.memoryUtilizationPercent?.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noGpu}</p>}</article>
</section> {snapshot.warnings?.length ? <section className="card host-warnings" aria-label={copy.host.warnings}><p className="card-kicker">{copy.host.warnings}</p>{snapshot.warnings.map((warning) => <p key={warning}>{presentReason(warning)}</p>)}</section> : null}
</>;
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { formatDateTime } from './locale';
type Association = { alertId: string; rationale: string; confidence: number; correlationMethod: string; manual: boolean; addedBy?: string; createdAt: string };
type Note = { id: string; incidentId: string; author: string; body: string; createdAt: string };
type Incident = { id: string; correlationKey: string; title: string; summary: string; severity: string; status: string; startedAt: string; resolvedAt?: string; ownerUserId?: string; correlationMethod: string; confidence: number; revision: number; updatedAt: string; alerts?: Association[]; notes?: Note[] };
function navigate(path: string) { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')); }
function formatTime(value: string) { return formatDateTime(value); }
function severityLabel(value: string) { return value === 'critical' ? 'Kritiek' : value === 'degraded' ? 'Aandacht' : 'Opmerking'; }
function statusLabel(value: string) { return value === 'resolved' ? 'Opgelost' : value === 'acknowledged' ? 'Erkend' : 'Open'; }
export function IncidentPage({ id }: { id?: string }) {
const [state, setState] = useState<'loading' | 'ready' | 'empty' | 'error' | 'unauthorized'>('loading');
const [items, setItems] = useState<Incident[]>([]);
const [incident, setIncident] = useState<Incident | null>(null);
const [note, setNote] = useState('');
const [owner, setOwner] = useState('');
const [message, setMessage] = useState('');
const [reload, setReload] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState('loading');
const url = id ? '/api/v1/incidents/' + encodeURIComponent(id) : '/api/v1/incidents?limit=100&status=open';
fetch(url, { signal: controller.signal }).then((response) => { if (response.status === 401) throw new Error('unauthorized'); if (!response.ok) throw new Error('error'); return response.json() as Promise<{ incident?: Incident; items?: Incident[] }>; }).then((data) => {
if (id) { const next = data.incident ?? null; setIncident(next); setOwner(next?.ownerUserId ?? ''); setState(next ? 'ready' : 'empty'); } else { const next = data.items ?? []; setItems(next); setState(next.length ? 'ready' : 'empty'); }
}).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState(error instanceof Error && error.message === 'unauthorized' ? 'unauthorized' : 'error'); });
return () => controller.abort();
}, [id, reload]);
const timeline = useMemo(() => {
if (!incident) return [] as Array<{ id: string; label: string; detail: string; at: string }>;
const entries = (incident.alerts ?? []).map((alert) => ({ id: 'alert-' + alert.alertId, label: alert.manual ? 'Handmatig gekoppeld alert' : 'Gecorreleerd alert', detail: alert.rationale + ' · confidence ' + Math.round(alert.confidence * 100) + '%', at: alert.createdAt }));
entries.push(...(incident.notes ?? []).map((item) => ({ id: item.id, label: 'Notitie van ' + item.author, detail: item.body, at: item.createdAt })));
return entries.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
}, [incident]);
const submitNote = async () => { if (!id || !note.trim()) return; setMessage('Notitie wordt opgeslagen…'); const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id) + '/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: note }) }); if (!response.ok) { setMessage('De notitie kon niet worden opgeslagen.'); return; } setNote(''); setMessage('Notitie opgeslagen.'); setReload((value) => value + 1); };
const saveOwner = async () => { if (!id || !incident) return; setMessage('Eigenaar wordt opgeslagen…'); const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ownerUserId: owner.trim(), revision: incident.revision }) }); if (!response.ok) { setMessage('De eigenaar kon niet worden opgeslagen.'); return; } setMessage('Eigenaar opgeslagen.'); setReload((value) => value + 1); };
if (state === 'loading') return <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></>;
}
+140
View File
@@ -0,0 +1,140 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { copy } from './copy';
import { formatDateTime } from './locale';
import { queryValue, replaceListQuery } from './listQuery';
import { presentEntityType, presentInventoryField, presentRelationType, presentStatus } from './presentation';
type Entity = {
id: string; entityType: string; canonicalName: string; displayName: string; status: string;
firstSeenAt?: string; lastSeenAt?: string; factCount: number; overrideCount: number;
relationCount: number; sourceCount: number; staleFactCount: number;
};
type Fact = { fieldName: string; sourceId: string; sourceName: string; value: unknown; observedAt: string; confidence: number; validUntil?: string; stale: boolean };
type Override = { fieldName: string; value: unknown; updatedAt: string };
type Effective = { fieldName: string; value: unknown; origin: 'override' | 'discovered'; sourceName?: string; observedAt?: string; confidence?: number; stale: boolean; overriddenAt?: string };
type Alias = { sourceName: string; externalType: string; externalId: string };
type Relation = { id: string; direction: 'incoming' | 'outgoing'; relationType: string; peerId: string; peerType: string; peerName: string; peerStatus: string; peerTombstonedAt?: string; sourceName: string; confidence: number; confirmed: boolean; tombstonedAt?: string };
type Detail = { entity: Entity; aliases: Alias[]; facts: Fact[]; overrides: Override[]; effectiveValues: Effective[]; relations: Relation[] };
type Page = { items: Entity[]; nextCursor: string; hasMore: boolean };
type LoadState = 'loading' | 'ready' | 'error';
function valueText(value: unknown): string {
if (value == null) return copy.inventory.missingValue;
if (typeof value === 'string') return value;
if (typeof value === 'boolean') return value ? copy.inventory.yes : copy.inventory.no;
if (typeof value === 'number') return value.toLocaleString('nl-BE');
return JSON.stringify(value);
}
function effectiveValueText(fieldName: string, value: unknown): string {
return /(?:status|state|health)$/i.test(fieldName) && typeof value === 'string' ? presentStatus(value) : valueText(value);
}
function statusTone(status: string, stale = false): string {
if (stale || !status || status.toLowerCase() === 'unknown') return 'unknown';
if (['healthy', 'operational', 'running', 'up', 'online', 'ready', 'gereed'].includes(status.toLowerCase())) return 'ready';
return 'attention';
}
function Badge({ label, stale = false }: { label: string; stale?: boolean }) {
const tone = statusTone(label, stale);
const rawStatus = /^(healthy|operational|running|up|online|ready|degraded|attention|critical|down|offline|missing|faulted|unknown)$/i.test(label);
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'}</span>{stale ? copy.inventory.stale : rawStatus ? presentStatus(label) : label || copy.inventory.unknown}</span>;
}
function ErrorState({ retry }: { retry: () => void }) {
return <div role="alert"><p className="card-copy">{copy.inventory.error}</p><button className="button button--secondary" type="button" onClick={retry}>{copy.inventory.retry}</button></div>;
}
export function InventoryPage({ id }: { id?: string }) {
return id ? <InventoryDetail id={id} /> : <InventoryList />;
}
function InventoryList() {
const [state, setState] = useState<LoadState>('loading');
const [items, setItems] = useState<Entity[]>([]);
const [cursor, setCursor] = useState(() => queryValue('after'));
const [nextCursor, setNextCursor] = useState('');
const [history, setHistory] = useState<string[]>([]);
const pageStatus = useRef<HTMLSpanElement>(null);
const [hasMore, setHasMore] = useState(false);
const [query, setQuery] = useState(() => queryValue('q'));
const deferredQuery = useDeferredValue(query);
const [type, setType] = useState(() => queryValue('type'));
const [status, setStatus] = useState(() => queryValue('status'));
const [order, setOrder] = useState(() => queryValue('order', ['asc', 'desc'], 'asc'));
const [revision, setRevision] = useState(0);
const request = useMemo(() => {
const params = new URLSearchParams({ limit: '25', order });
if (deferredQuery.trim()) params.set('q', deferredQuery.trim());
if (type) params.set('type', type);
if (status) params.set('status', status);
if (cursor) params.set('after', cursor);
return params;
}, [deferredQuery, type, status, order, cursor]);
useEffect(() => {
const controller = new AbortController();
setState((current) => current === 'ready' ? 'ready' : 'loading');
replaceListQuery({ q: deferredQuery.trim(), type, status, order: order === 'asc' ? '' : order, after: cursor });
fetch('/api/v1/entities?' + request, { signal: controller.signal })
.then((response) => { if (!response.ok) throw new Error('inventory'); return response.json() as Promise<Page>; })
.then((page) => { setItems(page.items ?? []); setNextCursor(page.nextCursor ?? ''); setHasMore(Boolean(page.hasMore)); setState('ready'); if (cursor) requestAnimationFrame(() => pageStatus.current?.focus()); })
.catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
return () => controller.abort();
}, [request, deferredQuery, type, status, order, cursor, revision]);
const resetPage = () => { setCursor(''); setHistory([]); };
const previous = () => { const prior = [...history]; setCursor(prior.pop() ?? ''); setHistory(prior); };
const next = () => { if (!nextCursor) return; setHistory((values) => [...values, cursor]); setCursor(nextCursor); };
const sourceTotal = items.filter((item) => item.sourceCount > 0).length;
return <>
<header className="page-intro"><p className="eyebrow">{copy.inventory.eyebrow}</p><h1>{copy.inventory.title}</h1><p className="intro">{copy.inventory.intro}</p></header>
<section className="inventory-summary" aria-label={copy.inventory.summary}>
<article className="card"><p className="card-kicker">{copy.inventory.entities}</p><h2>{state === 'ready' ? items.length + (hasMore ? '+' : '') : '—'}</h2><p className="card-copy">{copy.inventory.visibleEntities}</p></article>
<article className="card"><p className="card-kicker">{copy.inventory.provenance}</p><h2>{state === 'ready' ? sourceTotal : '—'}</h2><p className="card-copy">{copy.inventory.sourceCoverage}</p></article>
<article className="card"><p className="card-kicker">{copy.inventory.manualCorrections}</p><h2>{state === 'ready' ? items.reduce((sum, item) => sum + item.overrideCount, 0) : '—'}</h2><p className="card-copy">{copy.inventory.overrideProtection}</p></article>
</section>
<section className="inventory-panel card" aria-labelledby="inventory-list-title">
<div className="card-heading"><div><p className="card-kicker">{copy.inventory.entities}</p><h2 id="inventory-list-title">{copy.inventory.searchTitle}</h2></div>{state === 'ready' && <Badge label={copy.inventory.ready} />}</div>
<form className="inventory-filters" onSubmit={(event) => event.preventDefault()}>
<label>{copy.inventory.search}<input type="search" value={query} onChange={(event) => { setQuery(event.target.value); resetPage(); }} placeholder={copy.inventory.searchPlaceholder} /></label>
<label>{copy.inventory.type}<input value={type} onChange={(event) => { setType(event.target.value); resetPage(); }} placeholder={copy.inventory.allTypes} /></label>
<label>{copy.inventory.status}<input value={status} onChange={(event) => { setStatus(event.target.value); resetPage(); }} placeholder={copy.inventory.allStatuses} /></label>
<label>{copy.inventory.sort}<select value={order} onChange={(event) => { setOrder(event.target.value); resetPage(); }}><option value="asc">AZ</option><option value="desc">ZA</option></select></label>
</form>
{state === 'error' ? <ErrorState retry={() => setRevision((value) => value + 1)} /> : state === 'loading' ? <p className="card-copy" role="status">{copy.inventory.loading}</p> : items.length === 0 ? <p className="card-copy">{copy.inventory.empty}</p> : <ul className="inventory-list inventory-entity-list">{items.map((entity) => <li key={entity.id}><a className="entity-link" href={'/inventory/' + encodeURIComponent(entity.id)}><strong>{entity.displayName}</strong><small>{presentEntityType(entity.entityType)} · {entity.canonicalName}</small><span className="inventory-row-meta">{entity.sourceCount} {copy.inventory.sourcesShort} · {entity.factCount} {copy.inventory.factsShort} · {entity.relationCount} {copy.inventory.relationsShort}{entity.overrideCount > 0 ? ' · ' + entity.overrideCount + ' ' + copy.inventory.overridesShort : ''}</span></a><Badge label={entity.status} stale={entity.staleFactCount > 0 && entity.staleFactCount === entity.factCount} /></li>)}</ul>}
{state === 'ready' && <nav className="list-pager" aria-label={copy.inventory.page}><button className="button button--secondary" type="button" disabled={!cursor} onClick={previous}>{copy.inventory.previous}</button><span ref={pageStatus} role="status" tabIndex={-1}>{copy.inventory.page} {history.length + 1}</span><button className="button button--secondary" type="button" disabled={!hasMore} onClick={next}>{copy.inventory.next}</button></nav>}
</section>
</>;
}
function InventoryDetail({ id }: { id: string }) {
const [state, setState] = useState<LoadState>('loading');
const [detail, setDetail] = useState<Detail | null>(null);
const [revision, setRevision] = useState(0);
useEffect(() => {
const controller = new AbortController(); setState('loading');
fetch('/api/v1/entities/' + encodeURIComponent(id), { signal: controller.signal })
.then((response) => { if (!response.ok) throw new Error('inventory'); return response.json() as Promise<Detail>; })
.then((value) => { setDetail(value); setState('ready'); })
.catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
return () => controller.abort();
}, [id, revision]);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.inventory.loadingDetail}</h1></section>;
if (state === 'error' || !detail) return <section className="state-page"><h1>{copy.inventory.detailUnavailable}</h1><ErrorState retry={() => setRevision((value) => value + 1)} /></section>;
const entity = detail.entity;
return <>
<a className="back-link inventory-back" href="/inventory"> {copy.inventory.back}</a>
<header className="inventory-detail-header"><div><p className="eyebrow">{presentEntityType(entity.entityType)}</p><h1>{entity.displayName}</h1><p className="intro">{entity.canonicalName}</p></div><Badge label={entity.status} /></header>
<section className="inventory-summary" aria-label={copy.inventory.summary}><article className="card"><p className="card-kicker">{copy.inventory.sources}</p><h2>{entity.sourceCount}</h2><p className="card-copy">{copy.inventory.firstSeen}: {entity.firstSeenAt ? formatDateTime(entity.firstSeenAt) : copy.inventory.unknown}</p></article><article className="card"><p className="card-kicker">{copy.inventory.facts}</p><h2>{entity.factCount}</h2><p className="card-copy">{entity.staleFactCount} {copy.inventory.staleFacts}</p></article><article className="card"><p className="card-kicker">{copy.inventory.relations}</p><h2>{entity.relationCount}</h2><p className="card-copy">{detail.aliases.length} {copy.inventory.aliases.toLowerCase()}</p></article></section>
<section className="card inventory-effective" aria-labelledby="effective-title"><div className="card-heading"><div><p className="card-kicker">{copy.inventory.effective}</p><h2 id="effective-title">{copy.inventory.effectiveTitle}</h2></div></div>{detail.effectiveValues.length === 0 ? <p className="card-copy">{copy.inventory.noEffective}</p> : <dl className="inventory-value-grid">{detail.effectiveValues.map((item) => <div key={item.fieldName}><dt>{presentInventoryField(item.fieldName)}</dt><dd><strong>{effectiveValueText(item.fieldName, item.value)}</strong><span className={'provenance-chip provenance-chip--' + item.origin}>{item.origin === 'override' ? copy.inventory.manualOverride : item.sourceName || copy.inventory.discovered}</span>{item.stale && <span className="provenance-chip provenance-chip--stale">{copy.inventory.stale}</span>}<small>{item.origin === 'override' ? copy.inventory.overrideWins : `${copy.inventory.observed} ${item.observedAt ? formatDateTime(item.observedAt) : copy.inventory.unknown} · ${Math.round((item.confidence ?? 0) * 100)}%`}</small></dd></div>)}</dl>}</section>
<div className="inventory-detail-grid">
<section className="card" aria-labelledby="relations-title"><p className="card-kicker">{copy.inventory.topology}</p><h2 id="relations-title">{copy.inventory.relations}</h2>{detail.relations.length === 0 ? <p className="card-copy">{copy.inventory.noRelations}</p> : <ul className="inventory-list">{detail.relations.map((relation) => <li key={relation.id}><a className="entity-link" href={'/inventory/' + encodeURIComponent(relation.peerId)}><strong>{relation.peerName}</strong><small>{relation.direction === 'outgoing' ? '→' : '←'} {presentRelationType(relation.relationType)} · {presentEntityType(relation.peerType)}</small><span className="inventory-row-meta">{relation.sourceName} · {relation.confirmed ? copy.inventory.confirmed : copy.inventory.inferred}</span></a><Badge label={relation.tombstonedAt ? copy.inventory.missingRelation : relation.peerStatus} stale={Boolean(relation.tombstonedAt)} /></li>)}</ul>}</section>
<section className="card" aria-labelledby="aliases-title"><p className="card-kicker">{copy.inventory.identity}</p><h2 id="aliases-title">{copy.inventory.aliases}</h2>{detail.aliases.length === 0 ? <p className="card-copy">{copy.inventory.noAliases}</p> : <ul className="compact-list">{detail.aliases.map((alias) => <li key={alias.sourceName + alias.externalType + alias.externalId}><strong>{alias.sourceName}</strong>: {presentEntityType(alias.externalType)}</li>)}</ul>}</section>
</div>
<details className="card technical-details inventory-technical"><summary>{copy.inventory.allEvidence}</summary><div className="inventory-evidence"><section><h2>{copy.inventory.facts}</h2>{detail.facts.length === 0 ? <p className="card-copy">{copy.inventory.noFacts}</p> : <ul className="compact-list">{detail.facts.map((fact) => <li key={fact.fieldName + fact.sourceId}><strong>{fact.fieldName}</strong>: {valueText(fact.value)} · {fact.sourceName} · {formatDateTime(fact.observedAt)} {fact.stale ? '· ' + copy.inventory.stale : ''}</li>)}</ul>}</section><section><h2>{copy.inventory.overrides}</h2>{detail.overrides.length === 0 ? <p className="card-copy">{copy.inventory.noOverrides}</p> : <ul className="compact-list">{detail.overrides.map((override) => <li key={override.fieldName}><strong>{override.fieldName}</strong>: {valueText(override.value)} · {formatDateTime(override.updatedAt)}</li>)}</ul>}</section></div></details>
</>;
}
+264
View File
@@ -0,0 +1,264 @@
import { useId, type CSSProperties, type ReactNode } from 'react';
import type { BufferedPoint, ChartSeries, LiveFreshness } from './liveBuffer';
import type { MetricInspector } from './metricClient';
import { copy } from './copy';
import { presentMetric, presentStatus } from './presentation';
export type MetricWidgetKind = 'stat' | 'timeseries' | 'gauge' | 'query-inspector';
export type MetricWidgetAvailability = 'idle' | 'loading' | 'success' | 'error' | 'connecting' | 'live';
export type MetricWidgetProps = {
kind: MetricWidgetKind;
series: ChartSeries[];
freshness: LiveFreshness;
expectedStepSeconds: number;
availability: MetricWidgetAvailability;
error?: string | null;
visualization?: Record<string, unknown>;
metricName?: string;
sourceObservedAt?: string;
receivedAt?: string;
warnings?: string[];
inspector?: MetricInspector;
};
type LimitedSeries = { key: string; points: BufferedPoint[] };
type NumericPoint = Omit<BufferedPoint, 'value'> & { value: number };
type Summary = { min: number; max: number; average: number; count: number };
export function seriesDisplayName(series: LimitedSeries, metricName?: string): string {
const pointLabels = series.points.at(-1)?.labels ?? {};
const labels = Object.entries(pointLabels)
.filter(([key, value]) => key !== '__name__' && String(value).trim() !== '')
.sort(([left], [right]) => left.localeCompare(right, 'nl-BE'));
if (labels.length > 0) return labels.map(([, value]) => String(value)).join(' · ');
if (series.key.startsWith('{')) {
try {
const parsed = JSON.parse(series.key) as Record<string, unknown>;
const values = Object.entries(parsed)
.filter(([key, value]) => key !== '__name__' && String(value).trim() !== '')
.sort(([left], [right]) => left.localeCompare(right, 'nl-BE'))
.map(([, value]) => String(value));
if (values.length > 0) return values.join(' · ');
} catch { /* A non-JSON series key is handled by the readable fallback below. */ }
}
if (series.key === metricName) return copy.metrics.totalSeries;
return series.key.replaceAll('.', ' ').replaceAll('_', ' ');
}
const freshnessLabels: Record<LiveFreshness, string> = {
fresh: copy.metrics.fresh,
delayed: copy.metrics.delayed,
stale: copy.metrics.stale,
unavailable: copy.metrics.unavailable,
};
const unitLabels: Record<string, string> = {
percent: '%',
percentage: '%',
seconds: 's',
milliseconds: 'ms',
bytes: 'bytes',
bytesPerSecond: 'bytes/s',
count: '',
};
function numberSetting(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function booleanSetting(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; }
function decimalsSetting(value: unknown): number { const decimals = numberSetting(value); return decimals === undefined ? 2 : Math.min(6, Math.max(0, Math.trunc(decimals))); }
function formatNumber(value: number, decimals: number): string { return new Intl.NumberFormat('nl-BE', { maximumFractionDigits: decimals, minimumFractionDigits: decimals }).format(value); }
function unitText(value: unknown): string { const unit = typeof value === 'string' ? value : ''; return unitLabels[unit] ?? unit; }
function formatValue(value: number, visualization: Record<string, unknown>): string {
const decimals = decimalsSetting(visualization.decimals);
const unit = unitText(visualization.unit);
const formatted = formatNumber(value, decimals);
return unit ? formatted + ' ' + unit : formatted;
}
function validPoints(series: readonly LimitedSeries[]): NumericPoint[] { return series.flatMap((item) => item.points).filter((point): point is NumericPoint => point.value !== null && Number.isFinite(point.value)); }
function latestPoint(series: readonly LimitedSeries[]): BufferedPoint | null {
return validPoints(series).sort((a, b) => b.timestamp - a.timestamp || String(a.labels?.__name__ ?? '').localeCompare(String(b.labels?.__name__ ?? '')))[0] ?? null;
}
function summaryFor(points: readonly BufferedPoint[]): Summary | null {
const values = points.map((point) => point.value).filter((value): value is number => value !== null && Number.isFinite(value));
if (values.length === 0) return null;
return { min: Math.min(...values), max: Math.max(...values), average: values.reduce((sum, value) => sum + value, 0) / values.length, count: values.length };
}
function seriesSummary(series: LimitedSeries): Summary | null { return summaryFor(series.points); }
/**
* UX_SPEC section 8 asks for "readable charts with reduced series" on mobile.
* Narrow viewports get a smaller series/point budget than the documented
* desktop bounds; desktop keeps 20 series x 4000 points unchanged.
*/
export function reducedMetricLimits(width = typeof window === 'undefined' ? 1280 : window.innerWidth): { maxSeries: number; maxPoints: number } | null {
if (width <= 700) return { maxSeries: 4, maxPoints: 600 };
if (width <= 900) return { maxSeries: 8, maxPoints: 1500 };
return null;
}
export function limitMetricSeries(series: readonly ChartSeries[], maxSeries = 20, maxPoints = 4000): LimitedSeries[] {
const safeSeries = Math.max(1, Math.min(100, Math.trunc(maxSeries)));
const safePoints = Math.max(1, Math.min(10000, Math.trunc(maxPoints)));
const selected = [...series].sort((a, b) => a.key.localeCompare(b.key)).slice(0, safeSeries);
if (selected.length === 0) return [];
const perSeries = Math.max(1, Math.floor(safePoints / selected.length));
let remainder = safePoints - perSeries * selected.length;
return selected.map((item) => {
const allowance = perSeries + (remainder > 0 ? 1 : 0);
remainder = Math.max(0, remainder - 1);
return { key: item.key, points: [...item.points].sort((a, b) => a.timestamp - b.timestamp).slice(-allowance) };
});
}
function freshnessFor(props: MetricWidgetProps, point: BufferedPoint | null): LiveFreshness {
if (props.freshness === 'stale' || props.freshness === 'unavailable') return props.freshness;
return point?.freshness ?? props.freshness;
}
function freshnessLabel(freshness: LiveFreshness): string { return freshnessLabels[freshness]; }
function availabilityText(props: MetricWidgetProps): string {
if (props.availability === 'loading') return copy.metrics.loading;
if (props.availability === 'connecting') return copy.metrics.connecting;
if (props.availability === 'error') return props.error || 'De metric is niet beschikbaar.';
if (props.availability === 'idle') return copy.metrics.noMetric;
return copy.metrics.noReliableData;
}
function hasDataGap(series: readonly LimitedSeries[], expectedStepSeconds: number): boolean {
const threshold = Math.max(1, expectedStepSeconds) * 2000;
return series.some((item) => item.points.some((point, index) => point.value === null || (index > 0 && point.timestamp - item.points[index - 1].timestamp > threshold)));
}
function MetricNotice({ children, tone = 'unknown' }: { children: ReactNode; tone?: 'unknown' | 'warning' }) { return <p className={'metric-notice metric-notice--' + tone} role="status">{children}</p>; }
function FreshnessPill({ freshness }: { freshness: LiveFreshness }) { return <span className={'metric-freshness metric-freshness--' + freshness}>{freshnessLabel(freshness)}</span>; }
function Sparkline({ points, label }: { points: BufferedPoint[]; label: string }) {
const values = points.filter((point): point is BufferedPoint & { value: number } => point.value !== null && Number.isFinite(point.value));
if (values.length < 2) return null;
const min = Math.min(...values.map((point) => point.value));
const max = Math.max(...values.map((point) => point.value));
const spread = max - min || 1;
const path = values.map((point, index) => `${index === 0 ? 'M' : 'L'} ${(index / (values.length - 1)) * 100} ${100 - ((point.value - min) / spread) * 100}`).join(' ');
return <svg className="metric-sparkline" viewBox="0 0 100 100" role="img" aria-label={label} preserveAspectRatio="none"><path d={path} vectorEffect="non-scaling-stroke" /></svg>;
}
function sourceAgeText(iso?: string): string {
if (!iso) return '';
const timestamp = Date.parse(iso);
if (!Number.isFinite(timestamp)) return '';
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds < 60) return copy.metrics.sourceAge + ': ' + seconds + ' ' + copy.metrics.secondsAgo;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return copy.metrics.sourceAge + ': ' + minutes + ' ' + copy.metrics.minutesAgo;
return copy.metrics.sourceAge + ': ' + Math.floor(minutes / 60) + ' ' + copy.metrics.hoursAgo;
}
function SourceMetadata({ freshness, sourceObservedAt }: { freshness: LiveFreshness; sourceObservedAt?: string }) {
const age = sourceAgeText(sourceObservedAt);
return <div className="metric-value-meta"><FreshnessPill freshness={freshness} />{age && <time dateTime={sourceObservedAt}>{age}</time>}</div>;
}
function safeInspectorQuery(value: string): string {
let safe = value;
['authorization', 'cookie', 'password', 'passwd', 'secret', 'token', 'api_key', 'client_secret'].forEach((key) => {
safe = safe.replace(new RegExp(key + '[^,; ]*', 'gi'), key + '=<redacted>');
});
return safe;
}
function QueryInspectorWidget({ props }: { props: MetricWidgetProps }) {
const inspector = props.inspector;
if (!inspector) return <MetricNotice>{copy.metrics.inspectorForbidden}</MetricNotice>;
return <section className="metric-inspector" aria-label={copy.metrics.inspectorTitle}><h4>{copy.metrics.inspectorTitle}</h4><dl><div><dt>{copy.metrics.semanticMetric}</dt><dd>{inspector.semanticMetric}</dd></div><div><dt>{copy.metrics.estimatedSamples}</dt><dd>{inspector.cost.estimatedSamples.toLocaleString('nl-BE')}</dd></div><div><dt>{copy.metrics.seriesLimit}</dt><dd>{inspector.cost.series} / {inspector.limits.maxSeries}</dd></div><div><dt>{copy.metrics.pointLimit}</dt><dd>{inspector.cost.points} / {inspector.limits.maxPoints}</dd></div></dl><p className="metric-inspector-label">{copy.metrics.generatedQuery}</p><pre className="metric-inspector-query"><code>{safeInspectorQuery(inspector.generatedQuery)}</code></pre></section>;
}
function StatWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
const point = latestPoint(series);
const freshness = freshnessFor(props, point);
if (!point || point.value === null || props.availability === 'error' || props.availability === 'idle') return <MetricNotice>{availabilityText(props)}</MetricNotice>;
const visualization = props.visualization ?? {};
const primary = series[0];
return <div className="metric-stat" data-freshness={freshness}><div className="metric-value" title={new Date(point.timestamp).toISOString()}>{formatValue(point.value, visualization)}</div><SourceMetadata freshness={freshness} sourceObservedAt={props.sourceObservedAt} /><div className="metric-value-meta"><time dateTime={new Date(point.timestamp).toISOString()}>{new Date(point.timestamp).toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit' })}</time></div>{booleanSetting(visualization.showSparkline, false) && primary && <Sparkline points={primary.points} label={copy.metrics.trend} />}</div>;
}
function GaugeWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
const point = latestPoint(series);
const visualization = props.visualization ?? {};
const freshness = freshnessFor(props, point);
if (!point || point.value === null || props.availability === 'error' || props.availability === 'idle') return <MetricNotice>{availabilityText(props)}</MetricNotice>;
const values = validPoints(series).map((item) => item.value);
const configuredMin = numberSetting(visualization.min);
const configuredMax = numberSetting(visualization.max);
const min = configuredMin ?? Math.min(0, ...values);
const max = configuredMax ?? Math.max(min + 1, ...values);
const ratio = Math.min(1, Math.max(0, (point.value - min) / (max - min || 1)));
const style = { '--gauge-ratio': `${ratio * 100}%` } as CSSProperties;
return <div className="metric-gauge" data-freshness={freshness}><div className="metric-gauge-visual" style={style} aria-hidden="true"><meter min={min} max={max} value={Math.min(max, Math.max(min, point.value))} /></div><div className="metric-gauge-value">{formatValue(point.value, visualization)}</div><div className="metric-gauge-range"><span>{formatValue(min, visualization)}</span><span>{formatValue(max, visualization)}</span></div><SourceMetadata freshness={freshness} sourceObservedAt={props.sourceObservedAt} /><div className="metric-value-meta"><span>{configuredMin !== undefined && configuredMax !== undefined ? copy.metrics.fixedRange : copy.metrics.derivedRange}</span></div></div>;
}
function chartPath(points: readonly BufferedPoint[], minTime: number, timeSpread: number, minValue: number, valueSpread: number, gapThreshold: number): string {
let path = '';
points.forEach((point, index) => {
if (point.value === null || !Number.isFinite(point.value)) return;
const x = 28 + ((point.timestamp - minTime) / timeSpread) * 600;
const y = 192 - ((point.value - minValue) / valueSpread) * 180;
const previous = points[index - 1];
const command = !previous || previous.value === null || point.timestamp - previous.timestamp > gapThreshold ? 'M' : 'L';
path += `${command} ${Math.min(628, Math.max(28, x)).toFixed(3)} ${Math.min(192, Math.max(12, y)).toFixed(3)} `;
});
return path.trim();
}
function exportCsv(series: readonly LimitedSeries[], visualization: Record<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 csv = rows.map((row) => row.map((cell) => '"' + cell.replaceAll('"', '""') + '"').join(',')).join('\n');
const link = document.createElement('a');
const objectURL = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
link.href = objectURL;
link.download = (metricName || 'metric').replace(/[^a-z0-9_-]+/gi, '-') + '.csv';
link.click();
window.setTimeout(() => URL.revokeObjectURL(objectURL), 0);
void visualization;
}
function TimeSeriesWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
const chartId = useId();
const chartTitleId = chartId + '-title';
const chartDescriptionId = chartId + '-description';
const visualization = props.visualization ?? {};
const point = latestPoint(series);
const freshness = freshnessFor(props, point);
if (props.availability === 'error' || props.availability === 'idle' || series.length === 0 || validPoints(series).length === 0) return <MetricNotice>{availabilityText(props)}</MetricNotice>;
const points = validPoints(series);
const configuredMin = numberSetting(visualization.min);
const configuredMax = numberSetting(visualization.max);
const minValue = configuredMin ?? Math.min(...points.map((item) => item.value));
const maxValue = configuredMax ?? Math.max(...points.map((item) => item.value));
const valueSpread = maxValue - minValue || 1;
const minTime = Math.min(...series.flatMap((item) => item.points.map((pointItem) => pointItem.timestamp)));
const maxTime = Math.max(...series.flatMap((item) => item.points.map((pointItem) => pointItem.timestamp)));
const timeSpread = maxTime - minTime || 1;
const gap = hasDataGap(series, props.expectedStepSeconds);
const metricLabel = props.metricName ? presentMetric(props.metricName) : copy.metrics.metric;
const summaryText = `${series.length} reeks, ${points.length} meetpunten, bereik ${formatValue(minValue, visualization)} tot ${formatValue(maxValue, visualization)}.`;
return <div className="metric-timeseries"><div className="metric-chart-toolbar"><div className="metric-toolbar-status"><FreshnessPill freshness={freshness} />{gap && <span className="metric-gap-label">{copy.metrics.dataGap}</span>}</div><button className="button button--secondary metric-export" type="button" onClick={() => exportCsv(series, visualization, props.metricName)}>{copy.metrics.exportCsv}</button></div><figure className="metric-chart-figure"><svg className="metric-chart" viewBox="0 0 640 220" role="img" aria-labelledby={`${chartTitleId} ${chartDescriptionId}`}><title id={chartTitleId}>{copy.metrics.chartTitle} {metricLabel}</title><desc id={chartDescriptionId}>{summaryText}{gap ? ' ' + copy.metrics.gapDescription : ''}</desc><line className="metric-chart-axis" x1="28" y1="192" x2="628" y2="192" /><line className="metric-chart-axis" x1="28" y1="12" x2="28" y2="192" />{series.map((item, index) => { const label = seriesDisplayName(item, props.metricName); return <path key={item.key} className={'metric-chart-line metric-chart-line--' + (index % 6)} d={chartPath(item.points, minTime, timeSpread, minValue, valueSpread, Math.max(1, props.expectedStepSeconds) * 2000)} aria-label={label}><title>{label}</title></path>; })}</svg><figcaption className="metric-chart-caption">{summaryText}</figcaption></figure>{booleanSetting(visualization.legend, true) && <ul className="metric-legend" aria-label={copy.metrics.legend}>{series.map((item, index) => <li key={item.key}><span className={'metric-legend-swatch metric-legend-swatch--' + (index % 6)} aria-hidden="true" />{seriesDisplayName(item, props.metricName)}</li>)}</ul>}<div className="metric-summary"><span>{copy.metrics.minimum}: {formatValue(Math.min(...points.map((item) => item.value)), visualization)}</span><span>{copy.metrics.maximum}: {formatValue(Math.max(...points.map((item) => item.value)), visualization)}</span><span>{copy.metrics.average}: {formatValue(points.reduce((sum, item) => sum + item.value, 0) / points.length, visualization)}</span></div><details className="metric-accessible-summary"><summary>{copy.metrics.summary}</summary><table><thead><tr><th scope="col">{copy.metrics.series}</th><th scope="col">{copy.metrics.latest}</th><th scope="col">{copy.metrics.count}</th></tr></thead><tbody>{series.map((item) => { const latest = latestPoint([item]); const itemSummary = seriesSummary(item); return <tr key={item.key}><th scope="row">{seriesDisplayName(item, props.metricName)}</th><td>{ !latest || latest.value === null ? copy.dashboards.unknown : formatValue(latest.value, visualization)}</td><td>{itemSummary?.count ?? 0}</td></tr>; })}</tbody></table></details></div>;
}
export function metricStatus(props: MetricWidgetProps): { label: string; tone: 'unknown' | 'ready' } {
if (props.availability === 'loading' || props.availability === 'connecting') return { label: copy.metrics.connecting, tone: 'unknown' };
if (props.availability === 'error' || props.availability === 'idle' || props.series.length === 0) return { label: copy.dashboards.unknown, tone: 'unknown' };
const freshness = freshnessFor(props, latestPoint(limitMetricSeries(props.series)));
return { label: freshnessLabel(freshness), tone: freshness === 'fresh' ? 'ready' : 'unknown' };
}
export function MetricWidget(props: MetricWidgetProps) {
const reduced = reducedMetricLimits();
const series = reduced ? limitMetricSeries(props.series, reduced.maxSeries, reduced.maxPoints) : limitMetricSeries(props.series, 20, 4000);
const gap = hasDataGap(series, props.expectedStepSeconds);
const content = props.kind === 'stat' ? <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>;
}
export type RankedListItem = { id: string; label: string; value: string; detail?: string };
export type StatusGridItem = { id: string; label: string; status: string; reason?: string };
export function RankedListWidget({ items, onSelect }: { items: RankedListItem[]; onSelect: (id: string) => void }) {
return <ol className="ranked-list" aria-label={copy.widgets.rankedList}>{items.slice(0, 100).map((item, index) => <li key={item.id}><button type="button" className="ranked-list-item" onClick={() => onSelect(item.id)}><span className="ranked-list-rank">{index + 1}</span><span><strong>{item.label}</strong><small>{item.detail || item.id}</small></span><span className="ranked-list-value">{item.value}</span></button></li>)}</ol>;
}
export function StatusGridWidget({ items, onSelect }: { items: StatusGridItem[]; onSelect: (id: string) => void }) {
return <ul className="status-grid-widget" aria-label={copy.widgets.statusGrid}>{items.slice(0, 100).map((item) => <li key={item.id}><button type="button" className="status-grid-item" onClick={() => onSelect(item.id)}><span className={'status-dot status-dot--' + item.status} aria-hidden="true" /><span><strong>{item.label}</strong><small>{item.reason || presentStatus(item.status)}</small></span></button></li>)}</ul>;
}
+35
View File
@@ -0,0 +1,35 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentReason, presentStatus } from './presentation';
export type NetworkData = {
contractVersion: string;
observedAt: string;
source: { id: string; freshness: string; observedAt: string; state: string; reason?: string };
health: Array<{ scope: string; state: string; capabilityState?: string; configurationState?: string; reason?: string; sourceId?: string; freshness: string; observedAt: string; latencyMs?: number }>;
interfaces: Array<{ name: string; state: string; rxBytes: number; txBytes: number; rxErrors: number; txErrors: number; rxDrops: number; txDrops: number }>;
certificates: Array<{ id: string; serviceId: string; observedAt: string; expiresAt?: string; issuer?: string; subject?: string; hostnameValid?: boolean; verificationState: string }>;
events: Array<{ id: string; scope: string; state: string; reason?: string; occurredAt: string }>;
};
function stateLabel(state: string): string { if (state === 'up') return copy.network.up; if (state === 'degraded') return copy.network.degraded; if (state === 'down') return copy.network.down; return copy.network.unknown; }
function scopeLabel(scope: string): string { if (scope === 'internal') return copy.network.internal; if (scope === 'gateway') return copy.network.gateway; if (scope === 'dns') return copy.network.dns; return copy.network.internet; }
function reasonLabel(reason?: string): string { if (reason === 'not_configured') return copy.network.notConfiguredDetail; if (reason === 'stale_probe' || reason === 'source_stale') return copy.network.staleDetail; if (reason === 'source_unavailable') return copy.network.unavailableDetail; if (reason === 'unsupported') return copy.network.unsupportedDetail; return reason ? presentReason(reason) : copy.network.noReason; }
function when(value?: string): string { return value ? formatDateTime(value) : copy.network.notAvailable; }
function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return copy.network.notAvailable; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
function NetworkState({ state, capabilityState, configurationState }: { state: string; capabilityState?: string; configurationState?: string }) { const label = configurationState === 'not_configured' ? copy.network.notConfigured : capabilityState === 'unsupported' ? copy.network.unsupported : capabilityState === 'unavailable' ? copy.network.unavailable : stateLabel(state); return <span className={'network-state network-state--' + state}><span aria-hidden="true">{state === 'up' ? '✓' : state === 'unknown' ? '?' : '!'}</span>{label}</span>; }
export function NetworkHealthWidget({ snapshot, compact = false }: { snapshot: NetworkData; compact?: boolean }) {
const health = [...snapshot.health].sort((left, right) => left.scope.localeCompare(right.scope));
return <section className={'network-health-widget' + (compact ? ' network-health-widget--compact' : '')} aria-labelledby={compact ? undefined : 'network-health-title'}>{!compact && <div className="card-heading"><div><p className="card-kicker">{copy.network.widgetKicker}</p><h2 id="network-health-title">{copy.network.widgetTitle}</h2></div><span className="network-source">{snapshot.source.id} · {presentStatus(snapshot.source.freshness)}</span></div>}<div className="network-health-grid">{health.map((item) => <article className="network-health-item" key={item.scope}><div className="card-heading"><h3>{scopeLabel(item.scope)}</h3><NetworkState state={item.state} capabilityState={item.capabilityState} configurationState={item.configurationState} /></div><p>{reasonLabel(item.reason)}</p>{item.latencyMs != null && <small>{item.latencyMs} ms</small>}<small>{presentStatus(item.freshness)} · {when(item.observedAt)}</small></article>)}</div>{!compact && <p className="network-disclaimer">{copy.network.separateSignals}</p>}</section>;
}
export function NetworkPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<NetworkData | null>(null);
useEffect(() => { const controller = new AbortController(); fetch('/api/v1/network', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('network'); return response.json() as Promise<NetworkData>; }).then((data) => { setSnapshot({ ...data, health: data.health ?? [], interfaces: data.interfaces ?? [], certificates: data.certificates ?? [], events: data.events ?? [] }); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.network.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.network.errorTitle}</h1><p>{copy.network.errorDetail}</p><button className="button" type="button" onClick={() => window.location.reload()}>{copy.network.retry}</button></section>;
return <><header className="page-intro"><p className="eyebrow">{copy.network.eyebrow}</p><h1>{copy.network.title}</h1><p className="intro">{copy.network.intro}</p></header><NetworkHealthWidget snapshot={snapshot} /><section className="network-detail-grid"><article className="card network-panel" aria-labelledby="network-interface-title"><div className="card-heading"><div><p className="card-kicker">{copy.network.interfaces}</p><h2 id="network-interface-title">{snapshot.interfaces.length}</h2></div></div>{snapshot.interfaces.length === 0 ? <p className="card-copy">{copy.network.noInterfaces}</p> : <div className="network-table-wrap"><table className="network-table"><caption>{copy.network.interfaceCaption}</caption><thead><tr><th>{copy.network.interface}</th><th>RX / TX</th><th>{copy.network.errors}</th><th>{copy.network.drops}</th></tr></thead><tbody>{snapshot.interfaces.map((item) => <tr key={item.name}><th scope="row">{item.name}<small><NetworkState state={item.state} /></small></th><td>{bytes(item.rxBytes)} / {bytes(item.txBytes)}</td><td>{item.rxErrors} / {item.txErrors}</td><td>{item.rxDrops} / {item.txDrops}</td></tr>)}</tbody></table></div>}</article><article className="card network-panel" aria-labelledby="network-certificate-title"><div className="card-heading"><div><p className="card-kicker">{copy.network.certificates}</p><h2 id="network-certificate-title">{snapshot.certificates.length}</h2></div></div>{snapshot.certificates.length === 0 ? <p className="card-copy">{copy.network.noCertificates}</p> : <ul className="network-certificate-list">{snapshot.certificates.map((certificate) => <li key={certificate.id}><strong>{certificate.serviceId}</strong><span>{presentStatus(certificate.verificationState)} · {when(certificate.expiresAt)}</span><small>{certificate.issuer || copy.network.notAvailable} · {certificate.hostnameValid === false ? copy.network.hostnameInvalid : copy.network.hostnameValid}</small></li>)}</ul>}</article></section><section className="card network-panel" aria-labelledby="network-events-title"><div className="card-heading"><div><p className="card-kicker">{copy.network.events}</p><h2 id="network-events-title">{snapshot.events.length}</h2></div></div>{snapshot.events.length === 0 ? <p className="card-copy">{copy.network.noEvents}</p> : <ul className="network-event-list">{snapshot.events.map((event) => <li key={event.id}><strong>{scopeLabel(event.scope)} · {stateLabel(event.state)}</strong><span>{reasonLabel(event.reason)} · {when(event.occurredAt)}</span></li>)}</ul>}</section></>;
}
+11
View File
@@ -0,0 +1,11 @@
import { copy } from './copy';
export function NotFoundPage() {
return <section className="state-page not-found" role="status">
<span className="state-icon" aria-hidden="true">404</span>
<p className="eyebrow">{copy.notFound.eyebrow}</p>
<h1>{copy.notFound.title}</h1>
<p>{copy.notFound.detail}</p>
<div className="state-page-actions"><a className="button" href="/">{copy.notFound.home}</a><a className="button button--secondary" href="/inventory">{copy.notFound.inventory}</a></div>
</section>;
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { componentStatus, useSystemStatus } from './systemStatus';
type Capability = { id: string; state: string; detail: string };
type OnboardingStatus = { state: { completed: boolean; step: string; dashboardChoice?: string; rulesChoice?: string; dashboardId?: string; rulesReady: boolean }; capabilities: Capability[]; resume: boolean };
function capabilityLabel(state: string): string {
if (state === 'ready' || state === 'configured' || state === 'development') return copy.onboarding.ready;
if (state === 'incomplete' || state === 'not-ready') return copy.onboarding.action;
return copy.onboarding.unknown;
}
export function OnboardingPage() {
const runtime = useSystemStatus();
const [status, setStatus] = useState<OnboardingStatus | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [dashboard, setDashboard] = useState('default');
const [rules, setRules] = useState('default');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [reconfigure, setReconfigure] = useState(false);
const load = (signal?: AbortSignal) => {
setState('loading');
fetch('/api/v1/onboarding', { signal }).then((response) => { if (!response.ok) throw new Error('onboarding'); return response.json() as Promise<OnboardingStatus>; }).then((data) => {
setStatus(data); setDashboard(data.state.dashboardChoice || 'default'); setRules(data.state.rulesChoice || 'default'); setState('ready');
}).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); });
};
useEffect(() => { const controller = new AbortController(); load(controller.signal); return () => controller.abort(); }, []);
const complete = async () => {
if (status?.state.completed && !window.confirm(copy.onboarding.confirmReconfigure)) return;
setSaving(true); setMessage('');
try {
const response = await fetch('/api/v1/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dashboard, rules }) });
if (!response.ok) throw new Error(response.status === 403 ? 'admin' : 'save');
setStatus(await response.json() as OnboardingStatus);
setReconfigure(false);
setMessage(copy.onboarding.saved);
} catch (error) { setMessage(error instanceof Error && error.message === 'admin' ? copy.onboarding.adminRequired : copy.onboarding.saveError); } finally { setSaving(false); }
};
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.onboarding.loading}</h1></section>;
if (state === 'error' || !status) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.onboarding.errorTitle}</h1><p>{copy.onboarding.errorDetail}</p><button className="button" type="button" onClick={() => load()}>{copy.onboarding.retry}</button></section>;
const capabilities = status.capabilities.map((capability) => {
if (capability.id !== 'prometheus' && capability.id !== 'unraid') return capability;
const observed = componentStatus(runtime.status, capability.id);
if (!observed || observed.state !== 'healthy') return capability;
return { ...capability, state: 'ready', detail: copy.onboarding.runtimeReady };
});
return <><header className="page-intro"><p className="eyebrow">{copy.onboarding.eyebrow}</p><h1>{copy.onboarding.title}</h1><p className="intro">{status.state.completed ? copy.onboarding.completedIntro : status.resume ? copy.onboarding.resumeIntro : copy.onboarding.intro}</p></header><section className="onboarding-grid" aria-label={copy.onboarding.capabilities}>
<article className="card onboarding-card"><div className="card-heading"><div><p className="card-kicker">{copy.onboarding.capabilities}</p><h2>{copy.onboarding.readiness}</h2></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">?</span>{status.state.completed ? copy.onboarding.completed : copy.onboarding.inProgress}</span></div><ul className="onboarding-capabilities">{capabilities.map((capability) => <li key={capability.id}><span><strong>{copy.onboarding.capabilityNames[capability.id as keyof typeof copy.onboarding.capabilityNames] ?? capability.id}</strong><small>{capability.detail}</small></span><span className={'onboarding-state onboarding-state--' + capability.state}>{capabilityLabel(capability.state)}</span></li>)}</ul></article>
{status.state.completed && !reconfigure ? <article className="card onboarding-card onboarding-complete"><p className="card-kicker">{copy.onboarding.completedSummary}</p><h2>{copy.onboarding.configurationActive}</h2><p className="card-copy">{copy.onboarding.configurationActiveDetail}</p><dl className="onboarding-summary"><div><dt>{copy.onboarding.dashboardChoice}</dt><dd>{dashboard === 'default' ? copy.onboarding.dashboardInstalled : copy.onboarding.skipped}</dd></div><div><dt>{copy.onboarding.rulesChoice}</dt><dd>{rules === 'default' ? copy.onboarding.rulesInstalled : copy.onboarding.skipped}</dd></div></dl><p className="onboarding-safe-note">{copy.onboarding.reconfigureNote}</p><button className="button button--secondary" type="button" onClick={() => { setMessage(''); setReconfigure(true); }}>{copy.onboarding.reconfigure}</button>{message && <p className="form-message" role="status">{message}</p>}</article> : <article className="card onboarding-card"><p className="card-kicker">{status.state.completed ? copy.onboarding.reconfigure : copy.onboarding.choices}</p><h2>{copy.onboarding.defaultsTitle}</h2><p className="card-copy">{copy.onboarding.defaultsIntro}</p><fieldset className="onboarding-choice"><legend>{copy.onboarding.dashboardChoice}</legend><label><input type="radio" name="dashboard" value="default" checked={dashboard === 'default'} onChange={() => setDashboard('default')} /> {copy.onboarding.installDefault}</label><label><input type="radio" name="dashboard" value="skip" checked={dashboard === 'skip'} onChange={() => setDashboard('skip')} /> {copy.onboarding.skip}</label></fieldset><fieldset className="onboarding-choice"><legend>{copy.onboarding.rulesChoice}</legend><label><input type="radio" name="rules" value="default" checked={rules === 'default'} onChange={() => setRules('default')} /> {copy.onboarding.keepDefaults}</label><label><input type="radio" name="rules" value="skip" checked={rules === 'skip'} onChange={() => setRules('skip')} /> {copy.onboarding.skip}</label></fieldset><p className="onboarding-safe-note">{copy.onboarding.safeNote}</p><div className="detail-actions"><button className="button" type="button" disabled={saving} onClick={() => void complete()}>{saving ? copy.onboarding.saving : status.state.completed ? copy.onboarding.saveReconfiguration : copy.onboarding.complete}</button>{status.state.completed && <button className="button button--secondary" type="button" disabled={saving} onClick={() => setReconfigure(false)}>{copy.onboarding.cancel}</button>}</div>{message && <p className="form-message" role="status">{message}</p>}</article>}
</section></>;
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { signalToneRank, type SignalTone } from './overviewSignals';
export type OperationalSignalStage = {
id: string;
label: string;
icon: string;
tone: SignalTone;
statusLabel: string;
primaryLabel: string;
primaryValue: string;
secondaryLabel: string;
secondaryValue: string;
detail: string;
route: string;
};
const toneIcon: Record<SignalTone, string> = {
healthy: '✓',
attention: '!',
critical: '×',
stale: '◷',
unknown: '?',
};
function initialStage(stages: OperationalSignalStage[]): string {
return [...stages].sort((left, right) => signalToneRank[left.tone] - signalToneRank[right.tone])[0]?.id ?? '';
}
export function OperationalSignalPath({ stages, onNavigate }: { stages: OperationalSignalStage[]; onNavigate: (route: string) => void }) {
const preferredStage = useMemo(() => initialStage(stages), [stages]);
const [selectedID, setSelectedID] = useState(preferredStage);
const [userSelected, setUserSelected] = useState(false);
useEffect(() => {
if (!stages.some((stage) => stage.id === selectedID)) {
setSelectedID(preferredStage);
setUserSelected(false);
return;
}
if (!userSelected && selectedID !== preferredStage) setSelectedID(preferredStage);
}, [preferredStage, selectedID, stages, userSelected]);
const selected = stages.find((stage) => stage.id === selectedID) ?? stages[0];
const overall = [...stages].sort((left, right) => signalToneRank[left.tone] - signalToneRank[right.tone])[0];
return <article className="card data-plane signal-path-panel" aria-labelledby="signal-path-title">
<div className="card-heading signal-path-heading">
<div><p className="card-kicker">{copy.overview.signalPathKicker}</p><h2 id="signal-path-title">{copy.overview.signalPathTitle}</h2></div>
{overall && <span className={'signal-path-overall signal-tone--' + overall.tone}><span aria-hidden="true">{toneIcon[overall.tone]}</span>{overall.statusLabel}</span>}
</div>
<p className="signal-path-intro">{copy.overview.signalPathIntro}</p>
<ol className="signal-path" aria-label={copy.overview.signalPathStages}>
{stages.map((stage, index) => <li className={'signal-path-stage signal-path-stage--' + stage.tone + (selected?.id === stage.id ? ' signal-path-stage--selected' : '')} data-tone={stage.tone} key={stage.id}>
<button type="button" aria-pressed={selected?.id === stage.id} aria-label={`${stage.label}: ${stage.statusLabel}. ${stage.primaryLabel}: ${stage.primaryValue}. ${stage.secondaryLabel}: ${stage.secondaryValue}.`} onClick={() => { setSelectedID(stage.id); setUserSelected(true); }}>
<span className="signal-stage-index" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
<span className="signal-stage-icon" aria-hidden="true">{stage.icon}</span>
<span className="signal-stage-copy"><strong>{stage.label}</strong><small><span aria-hidden="true">{toneIcon[stage.tone]}</span>{stage.statusLabel}</small></span>
<span className="signal-stage-metric"><small>{stage.primaryLabel}</small><strong>{stage.primaryValue}</strong></span>
</button>
</li>)}
</ol>
{selected && <section className={'signal-path-inspector signal-path-inspector--' + selected.tone} aria-labelledby="signal-path-selection" aria-live="polite">
<div className="signal-inspector-state"><span className="signal-inspector-icon" aria-hidden="true">{selected.icon}</span><span><small>{copy.overview.signalPathSelected}</small><strong id="signal-path-selection">{selected.label}</strong></span></div>
<dl>
<div><dt>{selected.primaryLabel}</dt><dd>{selected.primaryValue}</dd></div>
<div><dt>{selected.secondaryLabel}</dt><dd>{selected.secondaryValue}</dd></div>
<div><dt>{copy.overview.signalPathState}</dt><dd><span className={'signal-state-text signal-tone--' + selected.tone}><span aria-hidden="true">{toneIcon[selected.tone]}</span>{selected.statusLabel}</span></dd></div>
</dl>
<p>{selected.detail}</p>
<button className="button button--secondary signal-path-open" type="button" onClick={() => onNavigate(selected.route)}>{copy.overview.signalPathOpen} {selected.label.toLowerCase()}</button>
</section>}
<p className="signal-path-disclaimer">{copy.overview.signalPathDisclaimer}</p>
</article>;
}
+21
View File
@@ -0,0 +1,21 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { operationalStorageState, presentArrayRole, presentStatus } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type Capability = 'available' | 'unsupported' | 'unavailable';
type Member = { id: string; name: string; role: string; state: string; capacityBytes: number; errors: number };
type Scrub = { id: string; state: string; progressPercent: number; errors: number; bytesChecked: number; startedAt?: string; completedAt?: string; result?: string };
type Pool = { id: string; name: string; filesystem: string; state: string; usableBytes: number; usedBytes: number; freeBytes: number; utilizationPercent: number; capacitySeverity?: string; profile?: string; redundancy?: string; capabilities: { members: Capability; capacity: Capability; redundancy: Capability; scrub: Capability; filesystemErrors: Capability; performance: Capability; ssdWear: Capability; moverSignals: Capability }; members?: Member[]; errors?: Array<{ id: string; kind: string; message: string; count: number; observedAt?: string }>; scrub?: Scrub; scrubHistory?: Scrub[] };
type Snapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; pools: Pool[]; total: number };
function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
function date(value?: string): string { return formatDateTime(value); }
function Badge({ label, ready }: { label: string; ready: boolean }) { return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{label}</span>; }
function stateLabel(state: string): string { if (state === 'healthy') return copy.pools.healthy; if (state === 'degraded') return copy.pools.degraded; if (state === 'faulted') return copy.pools.faulted; return copy.pools.unknown; }
function capabilityLabel(value: Capability): string { return value === 'available' ? copy.pools.available : value === 'unsupported' ? copy.pools.unsupported : copy.pools.unknown; }
function severityLabel(value?: string): string { return value === 'normal' ? 'Normaal' : value === 'attention' ? 'Aandacht' : value === 'critical' ? 'Kritiek' : 'Onbekend'; }
function PoolCard({ pool }: { pool: Pool }) { const operational = operationalStorageState(pool.state, pool.capacitySeverity); return <article className="card"><div className="card-heading"><div><p className="card-kicker">{pool.filesystem}</p><h2><a className="entity-link" href={'/pools/' + encodeURIComponent(pool.id)}>{pool.name}</a></h2></div><Badge label={presentStatus(operational)} ready={operational === 'healthy' || operational === 'normal'} /></div><p className="card-copy">{bytes(pool.usedBytes)} {copy.pools.usedOf} {bytes(pool.usableBytes)} · {pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% {copy.pools.used}</p><p className="card-copy">Device-health: {stateLabel(pool.state)} · capaciteit: {severityLabel(pool.capacitySeverity)}</p><p className="card-copy">{pool.profile || copy.pools.noProfile} · {pool.redundancy || copy.pools.noRedundancy}</p></article>; }
function PoolDetail({ pool, source }: { pool: Pool; source: Snapshot['source'] }) { const members = pool.members ?? []; const errors = pool.errors ?? []; const operational = operationalStorageState(pool.state, pool.capacitySeverity); return <><header className="page-intro"><p className="eyebrow">{copy.pools.detailEyebrow}</p><h1>{pool.name}</h1><p className="intro">{copy.pools.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/pools">{copy.pools.back}</a></div></header><section className="card container-summary" aria-labelledby="pool-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.source}</p><h2 id="pool-summary-title">{source.id}</h2></div><Badge label={presentStatus(operational)} ready={operational === 'healthy' || operational === 'normal'} /></div><SourceStatusDetails source={source} fallbackReason={source.freshness === 'fresh' ? copy.pools.fresh : copy.pools.stale} /><p className="container-provenance">{pool.filesystem}</p></section><section className="card-grid" aria-label={copy.pools.metrics}><article className="card"><p className="card-kicker">{copy.pools.capacity}</p><h2>{pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</h2><p className="card-copy">{bytes(pool.freeBytes)} {copy.pools.free}</p></article><article className="card"><p className="card-kicker">{copy.pools.profile}</p><h2>{pool.profile || copy.pools.noProfile}</h2><p className="card-copy">{pool.redundancy || copy.pools.noRedundancy}</p></article><article className="card"><p className="card-kicker">{copy.pools.scrub}</p><h2>{pool.scrub ? presentStatus(pool.scrub.state) : capabilityLabel(pool.capabilities.scrub)}</h2><p className="card-copy">{pool.scrub ? pool.scrub.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '% · ' + copy.pools.errors + ': ' + pool.scrub.errors : copy.pools.noScrub}</p></article></section><section className="card" aria-labelledby="pool-capabilities-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.capabilities}</p><h2 id="pool-capabilities-title">{copy.pools.capabilityTitle}</h2></div></div><p className="card-copy">{copy.pools.members}: {capabilityLabel(pool.capabilities.members)} · {copy.pools.redundancy}: {capabilityLabel(pool.capabilities.redundancy)} · {copy.pools.filesystemErrors}: {capabilityLabel(pool.capabilities.filesystemErrors)} · {copy.pools.performance}: {capabilityLabel(pool.capabilities.performance)}</p><p className="card-copy">{copy.pools.ssdWear}: {capabilityLabel(pool.capabilities.ssdWear)} · {copy.pools.moverSignals}: {capabilityLabel(pool.capabilities.moverSignals)}</p></section><section className="card" aria-labelledby="pool-members-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.members}</p><h2 id="pool-members-title">{copy.pools.memberTitle}</h2></div></div>{members.length === 0 ? <p className="card-copy">{copy.pools.noMembers}</p> : <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.pools.name}</th><th>{copy.pools.role}</th><th>{copy.pools.state}</th><th>{copy.pools.errors}</th></tr></thead><tbody>{members.map((member) => <tr key={member.id}><th scope="row">{member.name}</th><td>{presentArrayRole(member.role)}</td><td><Badge label={presentStatus(member.state)} ready={member.state === 'online'} /></td><td>{member.errors}</td></tr>)}</tbody></table></div>}</section><section className="card" aria-labelledby="pool-errors-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.errors}</p><h2 id="pool-errors-title">{copy.pools.poolErrorTitle}</h2></div></div>{errors.length === 0 ? <p className="card-copy">{copy.pools.noErrors}</p> : <ul className="reason-list">{errors.map((error) => <li key={error.id}><strong>{error.kind}</strong>: {error.message} ({error.count})</li>)}</ul>}</section><details className="card technical-details"><summary>{copy.pools.history}</summary>{pool.scrubHistory?.length ? <ul className="inventory-list">{pool.scrubHistory.map((scrub) => <li key={scrub.id}><span><strong>{presentStatus(scrub.state)}</strong><small>{scrub.result || copy.pools.noResult} · {date(scrub.completedAt || scrub.startedAt)}</small></span><Badge label={scrub.errors > 0 ? copy.pools.attention : copy.pools.completed} ready={scrub.errors === 0} /></li>)}</ul> : <p className="card-copy">{copy.pools.noHistory}</p>}</details><p className="card-copy">{copy.pools.readOnly}</p></>; }
export function PoolPage({ id }: { id?: string }) { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [snapshot, setSnapshot] = useState<Snapshot | null>(null); const [selected, setSelected] = useState<Pool | null>(null); useEffect(() => { const controller = new AbortController(); fetch(id ? '/api/v1/pools/' + encodeURIComponent(id) : '/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('pools'); return response.json() as Promise<Snapshot & { pool?: Pool }>; }).then((data) => { setSnapshot(data); if (data.pool) setSelected(data.pool); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, [id]); if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.pools.loading}</h1></section>; if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.pools.errorTitle}</h1><p>{copy.pools.errorDetail}</p></section>; if (selected) return <PoolDetail pool={selected} source={snapshot.source} />; const fresh = snapshot.source?.freshness === 'fresh' && snapshot.source?.state !== 'unknown'; return <><header className="page-intro"><p className="eyebrow">{copy.pools.eyebrow}</p><h1>{copy.pools.title}</h1><p className="intro">{copy.pools.intro}</p></header><section className="card container-summary" aria-labelledby="pools-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.source}</p><h2 id="pools-summary-title">{snapshot.source?.id || copy.pools.unknown}</h2></div><Badge label={fresh ? copy.pools.available : copy.pools.unknown} ready={fresh} /></div><SourceStatusDetails source={snapshot.source ?? {}} fallbackReason={fresh ? copy.pools.fresh : copy.pools.stale} /><p className="container-provenance">{snapshot.total} {copy.pools.rows}</p></section><section className="card-grid" aria-label={copy.pools.cards}>{snapshot.pools.length ? snapshot.pools.map((pool) => <PoolCard key={pool.id} pool={pool} />) : <article className="card"><p className="card-copy">{copy.pools.empty}</p></article>}</section></>; }
+35
View File
@@ -0,0 +1,35 @@
import { useDeferredValue, useEffect, useRef, useState } from 'react';
import { copy } from './copy';
import { queryValue, replaceListQuery } from './listQuery';
import { presentReason, presentStatus } from './presentation';
type ProcessItem = { pid: number; name: string; state: string; runtimeSeconds: number; cpuPercent: number; memoryBytes: number; containerId?: string; containerName?: string };
type ProcessSnapshot = { source: { id: string; state: string; reason?: string }; processes: ProcessItem[]; total: number; nextCursor?: string };
function bytes(value: number): string { if (!Number.isFinite(value)) return '—'; const units = ['B', 'KB', 'MB', 'GB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
function Badge({ state }: { state: string }) { const ready = state === 'healthy'; return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{ready ? 'Gereed' : 'Onbekend'}</span>; }
export function ProcessPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<ProcessSnapshot | null>(null);
const [sort, setSort] = useState<'cpu' | 'memory'>(() => queryValue('sort', ['cpu', 'memory'], 'cpu') as 'cpu' | 'memory');
const [query, setQuery] = useState(() => queryValue('q'));
const [containerFilter, setContainerFilter] = useState(() => queryValue('container'));
const deferredQuery = useDeferredValue(query);
const deferredContainer = useDeferredValue(containerFilter);
const [cursor, setCursor] = useState(() => queryValue('after'));
const [history, setHistory] = useState<string[]>([]);
const pageStatus = useRef<HTMLSpanElement>(null);
const [reload, setReload] = useState(0);
useEffect(() => { const controller = new AbortController(); setState((current) => current === 'ready' ? 'ready' : 'loading'); const params = new URLSearchParams({ limit: '25', sort }); if (deferredQuery.trim()) params.set('q', deferredQuery.trim()); if (deferredContainer.trim()) params.set('container', deferredContainer.trim()); if (cursor) params.set('after', cursor); replaceListQuery({ q: deferredQuery.trim(), container: deferredContainer.trim(), sort: sort === 'cpu' ? '' : sort, after: cursor }); fetch('/api/v1/processes?' + params, { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('processes'); return response.json() as Promise<ProcessSnapshot>; }).then((data) => { setSnapshot(data); setState('ready'); if (cursor) requestAnimationFrame(() => pageStatus.current?.focus()); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, [sort, deferredQuery, deferredContainer, cursor, reload]);
const resetPage = () => { setCursor(''); setHistory([]); };
const previous = () => { const prior = [...history]; setCursor(prior.pop() ?? ''); setHistory(prior); };
const next = () => { if (!snapshot?.nextCursor) return; setHistory((values) => [...values, cursor]); setCursor(snapshot.nextCursor ?? ''); };
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.processes.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.processes.errorTitle}</h1><p>{copy.processes.errorDetail}</p><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>{copy.processes.retry}</button></section>;
return <>
<header className="page-intro"><p className="eyebrow">{copy.processes.eyebrow}</p><h1>{copy.processes.title}</h1><p className="intro">{copy.processes.intro}</p></header>
<section className="card process-summary" aria-labelledby="process-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.processes.source}</p><h2 id="process-summary-title">{snapshot.source?.id || copy.processes.unknown}</h2><p className="card-copy">{snapshot.source?.reason ? presentReason(snapshot.source.reason) : copy.processes.privacy}</p></div><Badge state={snapshot.source?.state || 'unknown'} /></div><p className="process-count">{snapshot.total} {copy.processes.rows} · {copy.processes.limitNote}</p></section>
<section className="card process-panel" aria-labelledby="process-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.processes.list}</p><h2 id="process-list-title">{copy.processes.top}</h2></div></div><form className="list-filters" onSubmit={(event) => event.preventDefault()}><label>{copy.processes.search}<input type="search" value={query} placeholder={copy.processes.searchPlaceholder} onChange={(event) => { setQuery(event.target.value); resetPage(); }} /></label><label>{copy.processes.containerFilter}<input value={containerFilter} placeholder={copy.processes.containerPlaceholder} onChange={(event) => { setContainerFilter(event.target.value); resetPage(); }} /></label><label>{copy.processes.sort}<select value={sort} onChange={(event) => { setSort(event.target.value as 'cpu' | 'memory'); resetPage(); }}><option value="cpu">{copy.processes.cpu}</option><option value="memory">{copy.processes.memory}</option></select></label></form>{snapshot.processes.length === 0 ? <p className="card-copy">{copy.processes.empty}</p> : <><div className="host-table-wrap desktop-data-view"><table className="host-table process-table"><thead><tr><th>PID</th><th>{copy.processes.name}</th><th>{copy.processes.cpu}</th><th>{copy.processes.memory}</th><th>{copy.processes.container}</th></tr></thead><tbody>{snapshot.processes.map((item) => <tr key={item.pid}><th scope="row">{item.pid}</th><td>{item.name}<small>{presentStatus(item.state)} · {Math.floor(item.runtimeSeconds / 60)} min</small></td><td>{item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</td><td>{bytes(item.memoryBytes)}</td><td>{item.containerName || copy.processes.unknown}</td></tr>)}</tbody></table></div><ul className="mobile-data-list" aria-label={copy.processes.mobileList}>{snapshot.processes.map((item) => <li key={item.pid}><strong>{item.name}</strong><span>PID {item.pid} · {presentStatus(item.state)}</span><dl><div><dt>{copy.processes.cpu}</dt><dd>{item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</dd></div><div><dt>{copy.processes.memory}</dt><dd>{bytes(item.memoryBytes)}</dd></div><div><dt>{copy.processes.container}</dt><dd>{item.containerName || copy.processes.unknown}</dd></div></dl></li>)}</ul></>}<nav className="list-pager" aria-label={copy.processes.page}><button className="button button--secondary" type="button" disabled={!cursor} onClick={previous}>{copy.processes.previous}</button><span ref={pageStatus} role="status" tabIndex={-1}>{copy.processes.page} {history.length + 1}</span><button className="button button--secondary" type="button" disabled={!snapshot.nextCursor} onClick={next}>{copy.processes.next}</button></nav></section>
</>;
}
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { formatDateTime } from './locale';
type ProbeCertificate = { expiresAt?: string; issuer?: string; subject?: string; hostnameValid?: boolean; verificationState?: string };
type ProbeResult = { id?: string; probeId: string; observedAt: string; completedAt?: string; state: string; responseTimeMs?: number; statusCode?: number; errorClass?: string; errorMessage?: string; certificate?: ProbeCertificate };
type ProbeConfig = { id: string; name: string; type: string; intervalSeconds: number; timeoutSeconds: number; enabled: boolean; followRedirects: boolean; verifyTls: boolean; revision: number };
type ServiceStatus = { id: string; entityId?: string; sourceId?: string; name: string; description?: string; state: string; reason?: string; lastResultAt?: string; lastSuccessAt?: string; lastFailureAt?: string; responseTimeMs?: number; availabilityPercent?: number; sampleCount: number; successfulSampleCount: number; history?: ProbeResult[]; probes?: ProbeConfig[]; certificate?: ProbeCertificate };
type ServiceSnapshot = { contractVersion: string; observedAt: string; capabilityState?: string; configurationState?: string; reason?: string; services: ServiceStatus[]; total: number };
type ServiceDetailResponse = { service: ServiceStatus };
type Dependency = { id: string; serviceId: string; dependsOnServiceId: string; sourceId?: string; relationType: string; confidence: number; confirmed: boolean };
type DependencyResponse = { serviceId: string; dependencies: Dependency[] };
type DependencyLoad = DependencyResponse & { available: boolean };
type ViewState = 'loading' | 'ready' | 'empty' | 'unauthorized' | 'error';
function navigate(path: string) {
window.history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function statusLabel(state: string): string {
switch (state) {
case 'up': return copy.services.up;
case 'degraded': return copy.services.degraded;
case 'down': return copy.services.down;
default: return copy.services.unknown;
}
}
function statusIcon(state: string): string {
switch (state) {
case 'up': return '✓';
case 'down': return '!';
case 'degraded': return '△';
default: return '?';
}
}
function StatusPill({ state }: { state: string }) {
const normalized = ['up', 'degraded', 'down', 'unknown'].includes(state) ? state : 'unknown';
return <span className={'service-status service-status--' + normalized}><span className="status-icon" aria-hidden="true">{statusIcon(normalized)}</span>{statusLabel(normalized)}</span>;
}
function formatDate(value?: string): string {
return formatDateTime(value);
}
function formatLatency(value?: number): string {
return value === undefined || !Number.isFinite(value) ? copy.services.notAvailable : `${Math.max(0, Math.round(value))} ms`;
}
function formatAvailability(value?: number): string {
return value === undefined || !Number.isFinite(value) ? copy.services.notAvailable : `${value.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`;
}
function reasonLabel(reason?: string): string {
switch (reason) {
case 'stale_probe': return copy.services.reasons.stale;
case 'no_probe_result': return copy.services.reasons.noResult;
case 'no_probe_configured': return copy.services.reasons.noProbe;
case 'probes_disabled': return copy.services.reasons.disabled;
case 'no_services_configured': return copy.services.reasons.notConfigured;
case 'source_unavailable': return copy.services.reasons.unavailable;
case 'status_not_expected': return copy.services.reasons.status;
case 'transport_error': return copy.services.reasons.transport;
case 'response_too_large': return copy.services.reasons.bodyLimit;
case 'unsupported': return copy.services.reasons.unsupported;
case 'none': return copy.services.reasons.none;
default: return copy.services.reasons.none;
}
}
function errorLabel(result: ProbeResult): string {
return result.errorClass ? reasonLabel(result.errorClass) : result.state === 'up' ? copy.services.success : copy.services.reasons.none;
}
function latestCertificate(history: ProbeResult[] = []): ProbeCertificate | undefined {
return history.find((result) => result.certificate)?.certificate;
}
function certificateStateLabel(state?: string): string {
switch (state) {
case 'valid': return copy.services.certificate.valid;
case 'attention': return copy.services.certificate.attention;
case 'invalid': return copy.services.certificate.invalid;
default: return copy.services.unknown;
}
}
function daysUntil(value?: string): number | undefined {
if (!value) return undefined;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return undefined;
return Math.ceil((time - Date.now()) / 86400000);
}
function ServiceLoading() { return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.services.loading}</h1></section>; }
function ServiceError({ unauthorized, retry }: { unauthorized: boolean; retry: () => void }) { return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">{unauthorized ? '!' : '×'}</span><h1>{unauthorized ? copy.services.unauthorizedTitle : copy.services.errorTitle}</h1><p>{unauthorized ? copy.services.unauthorizedDetail : copy.services.errorDetail}</p><button className="button" type="button" onClick={retry}>{copy.services.retry}</button></section>; }
function ServiceSummary({ snapshot }: { snapshot: ServiceSnapshot }) {
const state = snapshot.services.length === 0 || snapshot.services.every((item) => item.state === 'unknown') ? 'unknown' : snapshot.services.some((item) => item.state === 'down') ? 'down' : snapshot.services.some((item) => item.state === 'degraded') ? 'degraded' : 'up';
return <section className="card service-source" aria-labelledby="service-source-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.source}</p><h2 id="service-source-title">{snapshot.contractVersion || copy.services.unknown}</h2></div><StatusPill state={state} /></div><p className="card-copy">{snapshot.reason ? reasonLabel(snapshot.reason) : copy.services.sourceDetail}</p><p className="service-provenance">{copy.services.observed}: {formatDate(snapshot.observedAt)} · {snapshot.total} {copy.services.rows}</p></section>;
}
function ServiceMatrix({ services }: { services: ServiceStatus[] }) {
return <section className="card service-panel" aria-labelledby="service-matrix-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.matrix}</p><h2 id="service-matrix-title">{copy.services.matrixTitle}</h2></div><span className="service-count">{services.length} {copy.services.rows}</span></div><div className="service-table-wrap"><table className="service-table"><caption className="sr-only">{copy.services.matrixCaption}</caption><thead><tr><th scope="col">{copy.services.name}</th><th scope="col">{copy.services.state}</th><th scope="col">{copy.services.latency}</th><th scope="col">{copy.services.availability}</th><th scope="col">{copy.services.lastSuccess}</th><th scope="col">{copy.services.reason}</th></tr></thead><tbody>{services.map((item) => <tr key={item.id}><th scope="row"><a className="service-link" href={'/services/' + encodeURIComponent(item.id)} onClick={(event) => { event.preventDefault(); navigate('/services/' + encodeURIComponent(item.id)); }}>{item.name}<small>{item.id}</small></a></th><td><StatusPill state={item.state} /></td><td>{formatLatency(item.responseTimeMs)}</td><td>{formatAvailability(item.availabilityPercent)}</td><td>{formatDate(item.lastSuccessAt)}</td><td>{reasonLabel(item.reason)}</td></tr>)}</tbody></table></div><p className="card-copy service-read-only">{copy.services.readOnly}</p></section>;
}
function ServiceListPage({ snapshot, retry }: { snapshot: ServiceSnapshot; retry: () => void }) {
const unavailable = snapshot.capabilityState === 'unavailable';
return <><header className="page-intro"><p className="eyebrow">{copy.services.eyebrow}</p><h1>{copy.services.title}</h1><p className="intro">{copy.services.intro}</p></header><ServiceSummary snapshot={snapshot} />{snapshot.services.length === 0 ? <section className="card empty-state service-panel" aria-labelledby="service-empty-title"><h2 id="service-empty-title">{unavailable ? copy.services.unavailable : copy.services.empty}</h2><p className="card-copy">{unavailable ? copy.services.unavailableDetail : copy.services.emptyDetail}</p>{unavailable ? <button className="button button--secondary" type="button" onClick={retry}>{copy.services.retry}</button> : <a className="button" href="/onboarding" onClick={(event) => { event.preventDefault(); navigate('/onboarding'); }}>{copy.services.configure}</a>}</section> : <ServiceMatrix services={snapshot.services} />}</>;
}
function CertificateCard({ certificate }: { certificate?: ProbeCertificate }) {
if (!certificate) return <section className="card service-certificate" aria-labelledby="certificate-title"><div className="card-heading"><h2 id="certificate-title">{copy.services.certificate.title}</h2><StatusPill state="unknown" /></div><p className="card-copy">{copy.services.certificate.none}</p></section>;
const days = daysUntil(certificate.expiresAt);
const expiry = days === undefined ? copy.services.notAvailable : days < 0 ? copy.services.certificate.expired : `${days} ${days === 1 ? copy.services.certificate.day : copy.services.certificate.days}`;
return <section className="card service-certificate" aria-labelledby="certificate-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.certificate.kicker}</p><h2 id="certificate-title">{copy.services.certificate.title}</h2></div><span className="service-certificate-state">{certificateStateLabel(certificate.verificationState)}</span></div><dl className="service-details-grid"><div><dt>{copy.services.certificate.expires}</dt><dd>{formatDate(certificate.expiresAt)}<small>{expiry}</small></dd></div><div><dt>{copy.services.certificate.hostname}</dt><dd>{certificate.hostnameValid === true ? copy.services.certificate.valid : copy.services.certificate.invalid}</dd></div><div><dt>{copy.services.certificate.issuer}</dt><dd>{certificate.issuer || copy.services.notAvailable}</dd></div><div><dt>{copy.services.certificate.subject}</dt><dd>{certificate.subject || copy.services.notAvailable}</dd></div></dl></section>;
}
function ProbeConfigCard({ probes }: { probes: ProbeConfig[] }) {
return <section className="card service-panel" aria-labelledby="probe-config-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.config}</p><h2 id="probe-config-title">{copy.services.configTitle}</h2></div><span className="service-count">{probes.length} {copy.services.probes}</span></div><p className="card-copy">{copy.services.configCaption}</p>{probes.length === 0 ? <p className="card-copy">{copy.services.noProbes}</p> : <div className="service-table-wrap"><table className="service-table"><caption className="sr-only">{copy.services.configCaption}</caption><thead><tr><th scope="col">{copy.services.probe}</th><th scope="col">{copy.services.probeType}</th><th scope="col">{copy.services.interval}</th><th scope="col">{copy.services.timeout}</th><th scope="col">{copy.services.tls}</th><th scope="col">{copy.services.redirects}</th><th scope="col">{copy.services.state}</th></tr></thead><tbody>{probes.map((probe) => <tr key={probe.id}><th scope="row">{probe.name}<small>{probe.id}</small></th><td>{copy.services.probeTypes[probe.type as keyof typeof copy.services.probeTypes] ?? probe.type}</td><td>{probe.intervalSeconds} s</td><td>{probe.timeoutSeconds} s</td><td>{probe.verifyTls ? copy.services.yes : copy.services.no}</td><td>{probe.followRedirects ? copy.services.yes : copy.services.no}</td><td>{probe.enabled ? copy.services.enabled : copy.services.disabled}</td></tr>)}</tbody></table></div>}</section>;
}
function relationLabel(value: string): string {
switch (value) { case 'depends_on': return copy.services.dependsOn; case 'backs': return copy.services.backs; case 'exposes': return copy.services.exposes; default: return copy.services.relation; }
}
function DependencyCard({ dependencies, available }: { dependencies: Dependency[]; available: boolean }) {
const ordered = [...dependencies].sort((left, right) => left.dependsOnServiceId.localeCompare(right.dependsOnServiceId) || left.id.localeCompare(right.id));
return <section className="card service-panel" aria-labelledby="service-dependencies-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.relations}</p><h2 id="service-dependencies-title">{copy.services.relationTitle}</h2></div><span className="service-count">{available ? ordered.length : '—'} {copy.services.relationRows}</span></div><p className="card-copy">{copy.services.relationCaption}</p>{!available ? <p className="card-copy" role="status">{copy.services.relationsUnavailable}</p> : ordered.length === 0 ? <p className="card-copy">{copy.services.noRelations}</p> : <div className="service-table-wrap"><table className="service-table"><caption className="sr-only">{copy.services.relationCaption}</caption><thead><tr><th scope="col">{copy.services.relation}</th><th scope="col">{copy.services.upstream}</th><th scope="col">{copy.services.source}</th><th scope="col">{copy.services.confidence}</th><th scope="col">{copy.services.confirmation}</th></tr></thead><tbody>{ordered.map((dependency) => <tr key={dependency.id}><th scope="row">{relationLabel(dependency.relationType)}<small>{dependency.id}</small></th><td>{dependency.dependsOnServiceId}</td><td>{dependency.sourceId || copy.services.manual}</td><td>{Math.round(Math.max(0, Math.min(1, dependency.confidence)) * 100)}%</td><td>{dependency.confirmed ? copy.services.confirmed : copy.services.inferred}</td></tr>)}</tbody></table></div>}</section>;
}
function ServiceHistory({ history }: { history: ProbeResult[] }) {
return <section className="card service-panel" aria-labelledby="service-history-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.history}</p><h2 id="service-history-title">{copy.services.historyTitle}</h2></div><span className="service-count">{history.length} {copy.services.samples}</span></div>{history.length === 0 ? <p className="card-copy">{copy.services.noHistory}</p> : <div className="service-table-wrap"><table className="service-table service-history-table"><caption className="sr-only">{copy.services.historyCaption}</caption><thead><tr><th scope="col">{copy.services.observed}</th><th scope="col">{copy.services.probe}</th><th scope="col">{copy.services.state}</th><th scope="col">{copy.services.latency}</th><th scope="col">{copy.services.reason}</th></tr></thead><tbody>{history.map((result, index) => <tr key={(result.id || result.probeId) + '-' + index}><th scope="row">{formatDate(result.observedAt)}</th><td>{result.probeId}</td><td><StatusPill state={result.state} /></td><td>{formatLatency(result.responseTimeMs)}</td><td>{errorLabel(result)}</td></tr>)}</tbody></table></div>}</section>;
}
function ServiceDetailPage({ service, dependencies, dependenciesAvailable, retry }: { service: ServiceStatus; dependencies: Dependency[]; dependenciesAvailable: boolean; retry: () => void }) {
const certificate = useMemo(() => service.certificate ?? latestCertificate(service.history), [service.certificate, service.history]);
return <><header className="page-intro"><a className="back-link" href="/services" onClick={(event) => { event.preventDefault(); navigate('/services'); }}> {copy.services.back}</a><p className="eyebrow">{copy.services.detailEyebrow}</p><h1>{service.name}</h1><p className="intro">{service.description || copy.services.detailIntro}</p></header><section className="card service-detail-summary" aria-labelledby="service-detail-status"><div className="card-heading"><div><p className="card-kicker">{copy.services.currentState}</p><h2 id="service-detail-status">{statusLabel(service.state)}</h2></div><StatusPill state={service.state} /></div><p className="service-reason"><strong>{copy.services.reason}:</strong> {reasonLabel(service.reason)}</p><dl className="service-details-grid"><div><dt>{copy.services.latency}</dt><dd>{formatLatency(service.responseTimeMs)}</dd></div><div><dt>{copy.services.availability}</dt><dd>{formatAvailability(service.availabilityPercent)}</dd></div><div><dt>{copy.services.lastSuccess}</dt><dd>{formatDate(service.lastSuccessAt)}</dd></div><div><dt>{copy.services.lastFailure}</dt><dd>{formatDate(service.lastFailureAt)}</dd></div></dl><button className="button button--secondary" type="button" onClick={retry}>{copy.services.retry}</button></section><ProbeConfigCard probes={service.probes ?? []} /><DependencyCard dependencies={dependencies} available={dependenciesAvailable} /><ServiceHistory history={service.history ?? []} /><CertificateCard certificate={certificate} /><details className="card technical-details"><summary>{copy.services.technical}</summary><dl className="technical-grid"><dt>{copy.services.serviceId}</dt><dd>{service.id}</dd><dt>{copy.services.entityId}</dt><dd>{service.entityId || copy.services.notAvailable}</dd><dt>{copy.services.sourceId}</dt><dd>{service.sourceId || copy.services.notAvailable}</dd><dt>{copy.services.samples}</dt><dd>{service.sampleCount} · {service.successfulSampleCount} {copy.services.successSamples}</dd></dl><p className="card-copy">{copy.services.secretSafe}</p></details></>;
}
export function ServicePage({ id }: { id?: string }) {
const [state, setState] = useState<ViewState>('loading');
const [snapshot, setSnapshot] = useState<ServiceSnapshot | null>(null);
const [service, setService] = useState<ServiceStatus | null>(null);
const [dependencies, setDependencies] = useState<Dependency[]>([]);
const [dependenciesAvailable, setDependenciesAvailable] = useState(true);
const [reload, setReload] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState('loading');
const endpoint = id ? '/api/v1/services/' + encodeURIComponent(id) : '/api/v1/services?limit=100';
const serviceRequest = fetch(endpoint, { signal: controller.signal }).then((response) => {
if (response.status === 401) throw new Error('unauthorized');
if (!response.ok) throw new Error('services');
return response.json() as Promise<ServiceSnapshot | ServiceDetailResponse>;
});
const dependencyRequest: Promise<DependencyLoad> = id ? fetch('/api/v1/services/' + encodeURIComponent(id) + '/dependencies?limit=100', { signal: controller.signal }).then(async (response) => response.ok ? { ...(await response.json() as DependencyResponse), available: true } : { serviceId: id, dependencies: [], available: false }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') throw error; return { serviceId: id, dependencies: [], available: false }; }) : Promise.resolve({ serviceId: '', dependencies: [], available: true });
Promise.all([serviceRequest, dependencyRequest]).then(([data, dependencyData]) => {
if (id) {
const detail = data as ServiceDetailResponse;
if (!detail.service) throw new Error('services');
setService(detail.service);
setDependencies(dependencyData.dependencies ?? []);
setDependenciesAvailable(dependencyData.available);
setState('ready');
} else {
const list = data as ServiceSnapshot;
const normalized = { ...list, services: list.services ?? [] };
setSnapshot(normalized);
setState(normalized.services.length ? 'ready' : 'empty');
}
}).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState(error instanceof Error && error.message === 'unauthorized' ? 'unauthorized' : 'error');
});
return () => controller.abort();
}, [id, reload]);
const retry = () => setReload((value) => value + 1);
if (state === 'loading') return <ServiceLoading />;
if (state === 'unauthorized' || state === 'error') return <ServiceError unauthorized={state === 'unauthorized'} retry={retry} />;
if (id && service) return <ServiceDetailPage service={service} dependencies={dependencies} dependenciesAvailable={dependenciesAvailable} retry={retry} />;
if (!id && snapshot) return <ServiceListPage snapshot={snapshot} retry={retry} />;
return <ServiceError unauthorized={false} retry={retry} />;
}
+14
View File
@@ -0,0 +1,14 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentStoragePolicy } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type Share = { id: string; name: string; storagePolicy: { allocation?: string; cachePolicy?: string; primaryPool?: string; cachePool?: string }; usedBytes: number; sizeObservedAt?: string; sizeState: string; placements?: Array<{ poolId: string; bytes: number }>; growthHistory?: Array<{ observedAt: string; usedBytes: number; deltaBytes: number; rateBytesPerDay: number }> };
type Snapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; shares: Share[]; total: number; scan: { maxSharesPerRun: number; dueShareIds?: string[]; deferredCount: number; cacheTtlSeconds: number } };
function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; }
function date(value?: string): string { return formatDateTime(value); }
function Badge({ label, ready }: { label: string; ready: boolean }) { return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{label}</span>; }
function ShareCard({ share }: { share: Share }) { return <article className="card"><div className="card-heading"><div><p className="card-kicker">{presentStoragePolicy(share.storagePolicy.allocation, copy.shares.unknownPolicy)}</p><h2><a className="entity-link" href={'/shares/' + encodeURIComponent(share.id)}>{share.name}</a></h2></div><Badge label={share.sizeState === 'available' || share.sizeState === 'cached' ? copy.shares.available : copy.shares.unknown} ready={share.sizeState === 'available' || share.sizeState === 'cached'} /></div><p className="card-copy">{bytes(share.usedBytes)} · {share.storagePolicy.cachePool || copy.shares.noCachePool} · {share.storagePolicy.primaryPool || copy.shares.noPrimaryPool}</p><p className="card-copy">{copy.shares.observed}: {date(share.sizeObservedAt)} · {share.growthHistory?.length || 0} {copy.shares.growthPoints}</p></article>; }
function ShareDetail({ share, source }: { share: Share; source: Snapshot['source'] }) { const latest = share.growthHistory?.[share.growthHistory.length - 1]; return <><header className="page-intro"><p className="eyebrow">{copy.shares.detailEyebrow}</p><h1>{share.name}</h1><p className="intro">{copy.shares.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/shares">{copy.shares.back}</a></div></header><section className="card container-summary" aria-labelledby="share-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.shares.source}</p><h2 id="share-summary-title">{source.id}</h2></div><Badge label={share.sizeState === 'stale' ? copy.shares.unknown : copy.shares.available} ready={share.sizeState !== 'stale' && share.sizeState !== 'unknown'} /></div><SourceStatusDetails source={source} fallbackReason={source.freshness === 'fresh' ? copy.shares.fresh : copy.shares.stale} /><p className="container-provenance">{bytes(share.usedBytes)}</p></section><section className="card-grid" aria-label={copy.shares.metrics}><article className="card"><p className="card-kicker">{copy.shares.policy}</p><h2>{presentStoragePolicy(share.storagePolicy.allocation, copy.shares.unknownPolicy)}</h2><p className="card-copy">{presentStoragePolicy(share.storagePolicy.cachePolicy, copy.shares.unknownCachePolicy)}</p></article><article className="card"><p className="card-kicker">{copy.shares.relation}</p><h2>{share.storagePolicy.cachePool || copy.shares.noCachePool}</h2><p className="card-copy">{share.storagePolicy.primaryPool || copy.shares.noPrimaryPool}</p></article><article className="card"><p className="card-kicker">{copy.shares.growth}</p><h2>{latest ? (latest.rateBytesPerDay >= 0 ? '+' : '') + bytes(Math.abs(latest.rateBytesPerDay)) + '/d' : '—'}</h2><p className="card-copy">{share.growthHistory?.length || 0} {copy.shares.growthPoints}</p></article></section><section className="card" aria-labelledby="share-placements-title"><div className="card-heading"><div><p className="card-kicker">{copy.shares.placement}</p><h2 id="share-placements-title">{copy.shares.placementTitle}</h2></div></div>{share.placements?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.shares.pool}</th><th>{copy.shares.size}</th></tr></thead><tbody>{share.placements.map((placement) => <tr key={placement.poolId}><th scope="row">{placement.poolId}</th><td>{bytes(placement.bytes)}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.shares.noPlacements}</p>}</section><details className="card technical-details"><summary>{copy.shares.growthHistory}</summary>{share.growthHistory?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.shares.observed}</th><th>{copy.shares.size}</th><th>{copy.shares.change}</th></tr></thead><tbody>{share.growthHistory.map((point) => <tr key={point.observedAt}><th scope="row">{date(point.observedAt)}</th><td>{bytes(point.usedBytes)}</td><td>{point.deltaBytes >= 0 ? '+' : ''}{bytes(Math.abs(point.deltaBytes))}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.shares.noGrowth}</p>}</details><p className="card-copy">{copy.shares.readOnly}</p></>; }
export function SharePage({ id }: { id?: string }) { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [snapshot, setSnapshot] = useState<Snapshot | null>(null); const [selected, setSelected] = useState<Share | null>(null); useEffect(() => { const controller = new AbortController(); fetch(id ? '/api/v1/shares/' + encodeURIComponent(id) : '/api/v1/shares?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('shares'); return response.json() as Promise<Snapshot & { share?: Share }>; }).then((data) => { setSnapshot(data); if (data.share) setSelected(data.share); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, [id]); if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.shares.loading}</h1></section>; if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.shares.errorTitle}</h1><p>{copy.shares.errorDetail}</p></section>; if (selected) return <ShareDetail share={selected} source={snapshot.source} />; const fresh = snapshot.source?.freshness === 'fresh' && snapshot.source?.state !== 'unknown'; return <><header className="page-intro"><p className="eyebrow">{copy.shares.eyebrow}</p><h1>{copy.shares.title}</h1><p className="intro">{copy.shares.intro}</p></header><section className="card container-summary" aria-labelledby="shares-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.shares.source}</p><h2 id="shares-summary-title">{snapshot.source?.id || copy.shares.unknown}</h2></div><Badge label={fresh ? copy.shares.available : copy.shares.unknown} ready={fresh} /></div><SourceStatusDetails source={snapshot.source ?? {}} fallbackReason={fresh ? copy.shares.fresh : copy.shares.stale} /><p className="container-provenance">{snapshot.total} {copy.shares.rows}</p><p className="card-copy">{copy.shares.scan}: {snapshot.scan.deferredCount} {copy.shares.deferred}; {snapshot.scan.maxSharesPerRun} {copy.shares.perRun}</p></section><section className="card-grid" aria-label={copy.shares.cards}>{snapshot.shares.length ? snapshot.shares.map((share) => <ShareCard key={share.id} share={share} />) : <article className="card"><p className="card-copy">{copy.shares.empty}</p></article>}</section></>; }
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { consumeAuthNoticeFromUrl, currentReturnPath, loginHref, onUnauthenticated, startLogin, type AuthNotice, type AuthNoticeKind } from './auth';
const noticeCopy: Record<AuthNoticeKind, { title: string; detail: string; action: string }> = {
required: { title: copy.auth.requiredTitle, detail: copy.auth.requiredDetail, action: copy.auth.signIn },
expired: { title: copy.auth.expiredTitle, detail: copy.auth.expiredDetail, action: copy.auth.signInAgain },
failed: { title: copy.auth.failedTitle, detail: copy.auth.failedDetail, action: copy.auth.signInAgain },
cancelled: { title: copy.auth.cancelledTitle, detail: copy.auth.cancelledDetail, action: copy.auth.signIn },
};
// A login that came back broken outranks a plain 401 observed afterwards, so the
// banner keeps explaining the actual failure instead of flipping to a generic
// "please sign in".
const noticeRank: Record<AuthNoticeKind, number> = { required: 0, expired: 1, cancelled: 2, failed: 2 };
/**
* Primary sign-in affordance. Rendered as a real anchor so that the browser's
* own keyboard, focus and "open in new tab" behaviour applies, while the click
* handler keeps the return path in sync with the page the user is actually on.
*/
export function SignInButton({ returnPath, label = copy.auth.signIn, variant = 'primary' }: { returnPath?: string; label?: string; variant?: 'primary' | 'secondary' }) {
const target = returnPath ?? currentReturnPath();
return <a className={variant === 'primary' ? 'button' : 'button button--secondary'} href={loginHref(target)} onClick={(event) => { event.preventDefault(); startLogin(target); }}>{label}</a>;
}
/**
* Shell-level banner for authentication events that are not tied to one page: a
* 401 on any route, a session that expired mid-session, and a failed or
* cancelled login redirecting back into the app.
*/
export function AuthNoticeBanner() {
const [notice, setNotice] = useState<AuthNotice | null>(() => consumeAuthNoticeFromUrl());
const [dismissedKind, setDismissedKind] = useState<AuthNoticeKind | null>(null);
useEffect(() => onUnauthenticated((next) => setNotice((current) => (current && noticeRank[current.kind] >= noticeRank[next.kind]) ? current : next)), []);
if (!notice || dismissedKind === notice.kind) return null;
const text = noticeCopy[notice.kind];
return <div className="auth-banner" role="alert" aria-label={copy.auth.noticeLabel}>
<span className="auth-banner-icon" aria-hidden="true">!</span>
<div className="auth-banner-text"><strong>{text.title}</strong><span>{text.detail}</span></div>
<SignInButton returnPath={notice.returnPath} label={text.action} />
<button className="button button--secondary" type="button" onClick={() => setDismissedKind(notice.kind)}>{copy.auth.dismiss}</button>
</div>;
}
+29
View File
@@ -0,0 +1,29 @@
import { copy } from './copy';
import { formatDateTime, hasReceivedTimestamp } from './locale';
import { presentReason, presentStatus } from './presentation';
export type SourceStatus = {
id?: string;
state?: string;
freshness?: string;
observedAt?: string;
reason?: string;
};
function dataLabel(source: SourceStatus): string {
if (!hasReceivedTimestamp(source.observedAt)) return copy.sourceStatus.neverReceived;
if (source.freshness === 'fresh' && source.state !== 'unknown' && source.state !== 'unavailable') return copy.sourceStatus.fresh;
if (source.freshness === 'stale') return copy.sourceStatus.stale;
return copy.sourceStatus.unavailable;
}
export function SourceStatusDetails({ source, fallbackReason, className = '' }: { source: SourceStatus; fallbackReason?: string; className?: string }) {
const reasonCode = source.reason?.trim();
const observed = formatDateTime(source.observedAt);
return <div className={`source-status-details ${className}`.trim()}>
<p className="source-status-line"><span><strong>{copy.sourceStatus.status}:</strong> {presentStatus(source.state)}</span><span><strong>{copy.sourceStatus.data}:</strong> {dataLabel(source)}</span></p>
<p className="source-status-reason">{reasonCode ? presentReason(reasonCode) : fallbackReason || copy.presentation.reason.noDetail}</p>
<p className="source-status-observed"><strong>{copy.sourceStatus.observed}:</strong> {hasReceivedTimestamp(source.observedAt) ? <time dateTime={source.observedAt}>{observed}</time> : observed}</p>
{reasonCode && <details className="source-status-technical"><summary>{copy.sourceStatus.technical}</summary><dl><dt>{copy.sourceStatus.sourceId}</dt><dd>{source.id || '—'}</dd><dt>{copy.sourceStatus.reasonCode}</dt><dd><code>{reasonCode}</code></dd></dl></details>}
</div>;
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { StorageMapWidget, TemperatureHeatmap, type HeatmapPoint, type StorageMapNode } from './StorageVisuals';
import { SourceStatusDetails, type SourceStatus } from './SourceStatusDetails';
type Severity = 'normal' | 'attention' | 'critical' | 'unknown';
type Disk = { id: string; name: string; role: string; state: string; utilizationPercent: number; capacitySeverity?: Severity; thermalSeverity?: Severity; temperature?: { celsius?: number; status: string; observedAt?: string } };
type DiskSnapshot = { source: SourceStatus; disks: Disk[] };
type Pool = { id: string; name: string; filesystem: string; state: string; utilizationPercent: number; capacitySeverity?: Severity };
type PoolSnapshot = { source: SourceStatus; pools: Pool[] };
type ArrayMember = { id: string; name: string; role: string; state: string };
type ArraySnapshot = { source: SourceStatus; state: string; members: ArrayMember[] };
export type StorageData = { array: ArraySnapshot; disks: DiskSnapshot; pools: PoolSnapshot };
function availability(value: string): string { return value === 'online' || value === 'healthy' || value === 'operational' ? 'healthy' : value === 'unknown' ? 'unknown' : 'degraded'; }
function visualSeverity(...values: Array<string | undefined>): string {
if (values.includes('critical')) return 'critical';
if (values.some((value) => value === 'attention' || value === 'degraded' || value === 'faulted')) return 'degraded';
if (values.includes('unknown')) return 'unknown';
return 'healthy';
}
function signalLabel(value: string): string { return value === 'normal' || value === 'healthy' || value === 'online' ? 'normaal' : value === 'critical' ? 'kritiek' : value === 'attention' || value === 'degraded' ? 'aandacht' : 'onbekend'; }
export function buildStorageNodes(data: StorageData): StorageMapNode[] {
const members = new Map((data.array.members ?? []).map((member) => [member.id.toLowerCase(), member]));
const diskNodes = (data.disks.disks ?? []).slice(0, 64).map((disk) => {
const member = members.get(disk.id.toLowerCase());
if (member) members.delete(disk.id.toLowerCase());
const capacity = disk.capacitySeverity ?? 'unknown';
const thermal = disk.thermalSeverity ?? disk.temperature?.status ?? 'unknown';
return { id: 'disk-' + disk.id, label: disk.name, kind: member?.role ?? disk.role, state: visualSeverity(availability(disk.state), capacity, thermal), detail: `Beschikbaarheid ${signalLabel(disk.state)} · capaciteit ${signalLabel(capacity)} · temperatuur ${signalLabel(thermal)}`, href: '/disks/' + encodeURIComponent(disk.id) };
});
const unmatchedMembers = [...members.values()].slice(0, 64).map((member) => ({ id: 'array-' + member.id, label: member.name, kind: member.role, state: availability(member.state), detail: `Beschikbaarheid ${signalLabel(member.state)} · disktelemetrie onbekend`, href: '/array' }));
const poolNodes = (data.pools.pools ?? []).slice(0, 64).map((pool) => {
const capacity = pool.capacitySeverity ?? 'unknown';
return { id: 'pool-' + pool.id, label: pool.name, kind: `pool · ${pool.filesystem}`, state: visualSeverity(availability(pool.state), capacity), detail: `Device-health ${signalLabel(pool.state)} · capaciteit ${signalLabel(capacity)} (${pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% gebruikt)`, href: '/pools/' + encodeURIComponent(pool.id) };
});
return [...unmatchedMembers, ...poolNodes, ...diskNodes];
}
export function StoragePage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [data, setData] = useState<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(); }, []);
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} /></>;
}
+17
View File
@@ -0,0 +1,17 @@
import { copy } from './copy';
import { formatDateTime } from './locale';
import { presentStatus } from './presentation';
export type StorageMapNode = { id: string; label: string; kind: string; state: string; detail?: string; href?: string };
export type HeatmapPoint = { id: string; label: string; observedAt: string; value: number | null; status: string; href?: string };
function StateText({ state }: { state: string }) { return <span className="storage-visual-state"><span className={'status-dot status-dot--' + state} aria-hidden="true" />{presentStatus(state)}</span>; }
export function StorageMapWidget({ nodes, title, description, idPrefix = 'storage-map' }: { nodes: StorageMapNode[]; title: string; description: string; idPrefix?: string }) {
const bounded = nodes.slice(0, 128);
return <section className="card storage-visual" aria-labelledby={idPrefix + '-title'}><p className="card-kicker">{copy.storage.mapKicker}</p><h2 id={idPrefix + '-title'}>{title}</h2><p className="card-copy">{description}</p><ul className="storage-map-grid" aria-label={copy.storage.mapNodesLabel}>{bounded.map((node) => <li key={node.id} className="storage-map-node"><a href={node.href || '#'} aria-label={node.label + ': ' + presentStatus(node.state)}>{node.href ? <strong>{node.label}</strong> : <strong>{node.label}</strong>}<span>{node.kind}</span><StateText state={node.state} />{node.detail && <small>{node.detail}</small>}</a></li>)}</ul><details className="storage-accessible-summary"><summary>{copy.storage.accessibleSummary} ({bounded.length} items)</summary><div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.storage.entity}</th><th>{copy.storage.type}</th><th>{copy.storage.status}</th><th>{copy.storage.detail}</th></tr></thead><tbody>{bounded.map((node) => <tr key={node.id}><th scope="row">{node.href ? <a className="entity-link" href={node.href}>{node.label}</a> : node.label}</th><td>{node.kind}</td><td>{presentStatus(node.state)}</td><td>{node.detail || '—'}</td></tr>)}</tbody></table></div></details></section>;
}
export function TemperatureHeatmap({ points, title, description, idPrefix = 'temperature-heatmap' }: { points: HeatmapPoint[]; title: string; description: string; idPrefix?: string }) {
const bounded = points.slice(0, 256);
return <section className="card storage-visual" aria-labelledby={idPrefix + '-title'}><p className="card-kicker">{copy.storage.heatmap}</p><h2 id={idPrefix + '-title'}>{title}</h2><p className="card-copy">{description}</p><div className="storage-heatmap" aria-hidden="true">{bounded.map((point) => <span key={point.id + point.observedAt} className={'storage-heatmap-cell storage-heatmap-cell--' + point.status} title={point.label + ': ' + (point.value == null ? 'Onbekend' : point.value + ' °C')}>{point.value == null ? '?' : Math.round(point.value)}</span>)}</div><details className="storage-accessible-summary" open><summary>{copy.storage.accessibleSummary} en tabelalternatief ({bounded.length} metingen)</summary><div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.storage.disk}</th><th>{copy.storage.observed}</th><th>{copy.storage.temperature}</th><th>{copy.storage.status}</th></tr></thead><tbody>{bounded.map((point) => <tr key={point.id + point.observedAt}><th scope="row">{point.href ? <a className="entity-link" href={point.href}>{point.label}</a> : point.label}</th><td>{formatDateTime(point.observedAt)}</td><td>{point.value == null ? 'Onbekend' : point.value + ' °C'}</td><td>{presentStatus(point.status)}</td></tr>)}</tbody></table></div></details></section>;
}
+47
View File
@@ -0,0 +1,47 @@
import { useState } from 'react';
import { copy } from './copy';
import { plural, presentComponent, presentReason } from './presentation';
import { formatDateTime } from './locale';
import { backupPresentation, refreshSystemStatus, systemStateLabel as label, useSystemStatus } from './systemStatus';
function Badge({ state }: { state: string }) {
const ready = state === 'healthy';
return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{label(state)}</span>;
}
function age(seconds?: number): string {
if (seconds == null || !Number.isFinite(seconds)) return copy.systemStatus.notAvailable;
if (seconds < 60) return `${Math.max(0, Math.round(seconds))} ${copy.systemStatus.seconds}`;
if (seconds < 3600) return `${Math.round(seconds / 60)} ${copy.systemStatus.minutes}`;
if (seconds < 86400) { const hours = Math.round(seconds / 3600); return `${hours} ${plural(hours, copy.systemStatus.hourAgo, copy.systemStatus.hoursAgo)}`; }
const days = Math.round(seconds / 86400);
return `${days} ${plural(days, copy.systemStatus.dayAgo, copy.systemStatus.daysAgo)}`;
}
export function SystemStatusPage() {
const { state, status } = useSystemStatus();
const [backupAction, setBackupAction] = useState<'idle' | 'creating' | 'created' | 'error'>('idle');
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.systemStatus.loading}</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><h1>{copy.systemStatus.unauthorized}</h1><p>{copy.systemStatus.unauthorizedDetail}</p></section>;
if (state === 'forbidden') return <section className="state-page" role="alert"><h1>{copy.systemStatus.forbidden}</h1><p>{copy.systemStatus.forbiddenDetail}</p></section>;
if (state === 'error' || !status) return <section className="state-page" role="alert"><h1>{copy.systemStatus.errorTitle}</h1><p>{copy.systemStatus.errorDetail}</p></section>;
const release = status.release;
const backup = backupPresentation(status.backup);
const createBackup = async () => {
setBackupAction('creating');
try {
const response = await fetch('/api/v1/system/backups', { method: 'POST' });
if (!response.ok) throw new Error('backup create failed');
setBackupAction('created');
refreshSystemStatus();
} catch {
setBackupAction('error');
}
};
return <>
<header className="page-intro"><p className="eyebrow">{copy.systemStatus.eyebrow}</p><h1>{copy.systemStatus.title}</h1><p className="intro">{copy.systemStatus.intro}</p></header>
<section className="card system-status-summary" aria-labelledby="system-status-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.systemStatus.current}</p><h2 id="system-status-summary-title">{label(status.overallState)}</h2></div><Badge state={status.overallState} /></div><p className="card-copy">{copy.systemStatus.version}: {release?.version || status.version} · {copy.systemStatus.commit}: {release?.commit || copy.systemStatus.notAvailable} · {copy.systemStatus.migration}: {release?.migrationVersion || copy.systemStatus.notAvailable}</p><p className="card-copy">{copy.systemStatus.built}: {release?.builtAt ? formatDateTime(release.builtAt) : copy.systemStatus.notAvailable} · {copy.systemStatus.generated}: {formatDateTime(status.generatedAt)}</p></section>
<section className="card" aria-labelledby="system-components-title"><div className="card-heading"><div><p className="card-kicker">{copy.systemStatus.components}</p><h2 id="system-components-title">{copy.systemStatus.componentTitle}</h2></div></div><ul className="inventory-list">{status.components.map((component) => <li key={component.id}><span><strong>{presentComponent(component.id)}</strong><small>{presentReason(component.reason)}</small></span><Badge state={component.state} /></li>)}</ul></section>
<section className="card-grid"><article className="card" aria-labelledby="backup-status-title"><p className="card-kicker">{copy.systemStatus.backup}</p><h2 id="backup-status-title"><Badge state={backup.state} /></h2><p className="card-copy">{presentReason(backup.reason)} · {age(backup.ageSeconds)}{backup.verifiedAt ? ` · ${copy.systemStatus.verified}: ${formatDateTime(backup.verifiedAt)}` : ''}</p><p className="card-copy">{copy.systemStatus.backupFreshness}</p><button className="button button--secondary" type="button" disabled={backupAction === 'creating'} onClick={() => void createBackup()}>{backupAction === 'creating' ? copy.systemStatus.creatingBackup : copy.systemStatus.createBackup}</button>{backupAction === 'created' && <p className="card-copy" role="status">{copy.systemStatus.backupCreated}</p>}{backupAction === 'error' && <p className="card-copy" role="alert">{copy.systemStatus.backupCreateFailed}</p>}</article><article className="card" aria-labelledby="source-lag-title"><p className="card-kicker">{copy.systemStatus.sourceLag}</p><h2 id="source-lag-title">{status.sourceLag.length} {plural(status.sourceLag.length, copy.systemStatus.source, copy.systemStatus.sources)}</h2>{status.sourceLag.length === 0 ? <p className="card-copy">{copy.systemStatus.noSources}</p> : <ul className="compact-list">{status.sourceLag.map((source) => <li key={source.sourceId}><strong>{presentComponent(source.sourceId)}</strong>: {label(source.state)} · {age(source.ageSeconds)}</li>)}</ul>}</article></section>
</>;
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
type TopologyNode = { id: string; label: string; state: string; reason?: string; known: boolean; kind?: string; sourceId?: string };
type TopologyEdge = { id: string; from: string; to: string; relationType: string; sourceId?: string; confidence: number; confirmed: boolean; inferred: boolean };
export type TopologyData = { contractVersion: string; observedAt: string; capabilityState?: string; configurationState?: string; reason?: string; nodes: TopologyNode[]; edges: TopologyEdge[]; totalNodes: number; totalEdges: number; truncated: boolean };
type ViewState = 'loading' | 'error' | 'unauthorized' | 'ready';
function statusLabel(state: string): string {
if (state === 'up') return copy.services.up;
if (state === 'degraded') return copy.services.degraded;
if (state === 'down') return copy.services.down;
return copy.services.unknown;
}
function relationLabel(relation: string): string {
if (relation === 'backs') return copy.services.backs;
if (relation === 'exposes') return copy.services.exposes;
return copy.services.dependsOn;
}
function edgeClass(edge: TopologyEdge): string {
return edge.confirmed ? 'topology-edge topology-edge--confirmed' : 'topology-edge topology-edge--inferred';
}
function nodeClass(node: TopologyNode): string {
return node.known ? 'topology-node topology-node--known' : 'topology-node topology-node--unknown';
}
function ServiceLink({ node }: { node: TopologyNode }) {
if (!node.known) return <span className={nodeClass(node)}><strong>{node.label}</strong><small>{copy.topology.nodeUnknown}</small></span>;
if (node.kind === 'reverse_proxy') return <span className={nodeClass(node)}><strong>{node.label}</strong><small>{copy.topology.reverseProxy} · {node.sourceId || copy.topology.sourceUnknown}</small></span>;
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 }) {
const nodes = [...topology.nodes].sort((left, right) => left.id.localeCompare(right.id));
const edges = [...topology.edges].sort((left, right) => left.id.localeCompare(right.id));
const nodeByID = new Map(nodes.map((node) => [node.id, node]));
return <section className={'topology-widget' + (compact ? ' topology-widget--compact' : '')} aria-labelledby={compact ? undefined : 'topology-widget-title'}>
{!compact && <div className="card-heading"><div><p className="card-kicker">{copy.topology.widgetKicker}</p><h2 id="topology-widget-title">{copy.topology.widgetTitle}</h2></div><span className="topology-count">{nodes.length} {copy.topology.nodes} · {edges.length} {copy.topology.edges}</span></div>}
{!compact && <p className="topology-disclaimer">{copy.topology.noCausality}</p>}
<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>}
</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>
{edges.length === 0 ? <p className="card-copy">{copy.topology.noEdges}</p> : <ol className="topology-edge-list">{edges.map((edge) => {
const from = nodeByID.get(edge.from);
const to = nodeByID.get(edge.to);
return <li key={edge.id} className={edgeClass(edge)}><div className="topology-edge-route"><span>{from?.label ?? edge.from}</span><span aria-hidden="true"></span><span>{to?.label ?? edge.to}</span></div><div className="topology-edge-meta"><span>{relationLabel(edge.relationType)}</span><span>{Math.round(edge.confidence * 100)}% {edge.confirmed ? copy.services.confirmed : copy.services.inferred}</span><span>{edge.sourceId || copy.services.manual}</span></div></li>;
})}</ol>}
</section>
</div>
</section>;
}
export function TopologyPage() {
const [state, setState] = useState<ViewState>('loading');
const [topology, setTopology] = useState<TopologyData | null>(null);
const [reload, setReload] = useState(0);
const [query, setQuery] = useState('');
const [stateFilter, setStateFilter] = useState('all');
const [relationFilter, setRelationFilter] = useState('all');
useEffect(() => {
const controller = new AbortController();
setState('loading');
fetch('/api/v1/topology?limit=100', { signal: controller.signal }).then((response) => {
if (!response.ok) throw new Error(String(response.status));
return response.json() as Promise<TopologyData>;
}).then((data) => { setTopology({ ...data, nodes: data.nodes ?? [], edges: data.edges ?? [] }); setState('ready'); }).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
const status = error instanceof Error ? Number(error.message) : 0;
setState(status === 401 ? 'unauthorized' : 'error');
});
return () => controller.abort();
}, [reload]);
const filtered = useMemo(() => {
if (!topology) return null;
const normalized = query.trim().toLowerCase();
const nodes = topology.nodes.filter((node) => {
const textMatch = !normalized || (node.label + ' ' + node.id).toLowerCase().includes(normalized);
return textMatch && (stateFilter === 'all' || node.state === stateFilter);
});
const nodeIDs = new Set(nodes.map((node) => node.id));
const edges = topology.edges.filter((edge) => nodeIDs.has(edge.from) && nodeIDs.has(edge.to) && (relationFilter === 'all' || edge.relationType === relationFilter));
return { ...topology, nodes, edges };
}, [topology, query, stateFilter, relationFilter]);
if (state === 'loading') return <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>;
if (topology.nodes.length === 0) {
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>}</>;
}
+103
View File
@@ -0,0 +1,103 @@
import { copy } from './copy';
import type { EditorWidget } from './DashboardEditor';
export type ValidationErrors = Record<string, string>;
export type PreviewResult = {
type: string;
title: string;
state: string;
message: string;
limits?: { maxSeries: number; maxPoints: number; maxRows: number; requestedRows: number; appliedRows: number };
sample?: Record<string, unknown>;
};
type Props = {
widget: EditorWidget;
errors: ValidationErrors;
preview: PreviewResult | null;
previewState: string;
previewing: boolean;
previewError: string;
viewportLabel: string;
width: number;
maxWidth: number;
onChange: (widget: EditorWidget) => void;
onWidthChange: (width: number) => void;
onPreviewStateChange: (state: string) => void;
onPreview: () => void;
};
const configCopy = copy.editor.config;
function record(value: Record<string, unknown> | undefined): Record<string, unknown> {
return value ?? {};
}
function text(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function number(value: unknown, fallback = ''): string {
return typeof value === 'number' ? String(value) : fallback;
}
function fieldId(name: string): string {
return 'widget-config-' + name.replaceAll('.', '-');
}
export function WidgetConfigDrawer({ widget, errors, preview, previewState, previewing, previewError, viewportLabel, width, maxWidth, onChange, onWidthChange, onPreviewStateChange, onPreview }: Props) {
const data = record(widget.data);
const visualization = record(widget.visualization);
const behavior = record(widget.behavior);
const sourceType = text(data.sourceType, 'inventory');
const error = (name: string) => errors[name] ? <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;
return <aside className="editor-config-drawer card" aria-labelledby="widget-config-heading">
<p className="card-kicker">{configCopy.kicker}</p>
<h2 id="widget-config-heading">{widget.title || configCopy.fallbackTitle}</h2>
<p className="card-copy">{configCopy.intro}</p>
<div className="config-section">
<h3>{configCopy.general}</h3>
<label htmlFor={fieldId('title')}>{configCopy.title}<input id={fieldId('title')} value={text(widget.title)} maxLength={120} aria-invalid={Boolean(errors.title)} aria-describedby={errors.title ? fieldId('title') + '-error' : undefined} onChange={(event) => update('root', 'title', event.target.value)} /></label>
{error('title')}
<label htmlFor={fieldId('description')}>{configCopy.description}<textarea id={fieldId('description')} value={text(widget.description)} maxLength={500} rows={3} onChange={(event) => update('root', 'description', event.target.value)} /></label>
</div>
<div className="config-section">
<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')}</>}
<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>
<div className="config-section">
<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>
<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>
<p className="card-copy">{configCopy.widthHint}</p>
</div>
<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>
<div className="config-preview">
<h3>{configCopy.preview}</h3>
<label htmlFor={fieldId('preview-state')}>{configCopy.previewState}<select id={fieldId('preview-state')} value={previewState} onChange={(event) => onPreviewStateChange(event.target.value)}><option value="loading">{configCopy.previewStateLoading}</option><option value="empty">{configCopy.previewStateEmpty}</option><option value="error">{configCopy.previewStateError}</option><option value="stale">{configCopy.previewStateStale}</option></select></label>
<button className="button button--secondary" type="button" disabled={invalid || previewing} onClick={onPreview}>{previewing ? configCopy.previewLoading : configCopy.previewRefresh}</button>
{previewError && <p className="field-error" role="alert">{previewError}</p>}
{preview && <div className={'preview-state preview-state--' + preview.state} role="status" aria-live="polite"><strong>{preview.message}</strong><span>{configCopy.limitsPrefix}{preview.limits?.maxSeries ?? 0}{configCopy.limitsSeries}{preview.limits?.maxPoints ?? 0}{configCopy.limitsPoints}{preview.limits?.appliedRows ?? 0}{configCopy.limitsRows}</span></div>}
</div>
</aside>;
}
+130
View File
@@ -0,0 +1,130 @@
// Frontend half of the OIDC sign-in flow.
//
// The backend exposes `GET /auth/login` (302 to the identity provider) and
// `GET /auth/callback` (302 back into the app). Sign-in is therefore a full
// document navigation, never a fetch: a redirect to a third-party identity
// provider cannot be followed by XHR.
export const LOGIN_PATH = '/auth/login';
// Query parameter used to hand the backend a relative in-app path to return to.
export const RETURN_PARAM = 'redirect';
// Query parameters the backend may set when it redirects back after a failed or
// cancelled authorization. `error` is the OAuth 2.0 / OIDC standard name.
export const ERROR_PARAMS = ['reason', 'error'] as const;
export type AuthNoticeKind = 'required' | 'expired' | 'failed' | 'cancelled';
export type AuthNotice = { kind: AuthNoticeKind; returnPath: string };
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
/**
* Reduces an arbitrary value to a safe, relative, in-app path. Anything that
* could leave the origin (absolute URLs, protocol-relative `//host`, backslash
* variants, control characters) collapses to the overview route.
*/
export function safeReturnPath(value: string | null | undefined): string {
if (typeof value !== 'string' || value === '') return '/';
const candidate = value.trim();
if (!candidate.startsWith('/')) return '/';
if (candidate.startsWith('//') || candidate.startsWith('/\\')) return '/';
if (CONTROL_CHARACTERS.test(candidate)) return '/';
return candidate;
}
/** The current in-app location, including query and hash, as a safe return path. */
export function currentReturnPath(): string {
if (typeof window === 'undefined') return '/';
return safeReturnPath(window.location.pathname + window.location.search + window.location.hash);
}
/** Absolute-path href for the backend login entry point. */
export function loginHref(returnPath: string = currentReturnPath()): string {
const target = safeReturnPath(returnPath);
return target === '/' ? LOGIN_PATH : LOGIN_PATH + '?' + RETURN_PARAM + '=' + encodeURIComponent(target);
}
/** Leaves the SPA and hands control to the backend login endpoint. */
export function startLogin(returnPath: string = currentReturnPath()): void {
if (typeof window === 'undefined') return;
window.location.assign(loginHref(returnPath));
}
function noticeKind(raw: string): AuthNoticeKind {
const value = raw.toLowerCase();
if (value === 'access_denied' || value === 'cancelled' || value === 'canceled' || value === 'user_cancelled') return 'cancelled';
return 'failed';
}
/**
* Reads a failed/cancelled-login marker left in the URL by `/auth/callback` and
* removes it again, so a refresh or a shared link does not resurrect the notice.
*/
export function consumeAuthNoticeFromUrl(): AuthNotice | null {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
const present = ERROR_PARAMS.find((name) => (params.get(name) ?? '') !== '');
if (!present) return null;
const kind = noticeKind(params.get(present) ?? '');
ERROR_PARAMS.forEach((name) => params.delete(name));
const query = params.toString();
window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '') + window.location.hash);
return { kind, returnPath: currentReturnPath() };
}
type SessionListener = (notice: AuthNotice) => void;
const sessionListeners = new Set<SessionListener>();
let sawAuthenticatedResponse = false;
let watcherInstalled = false;
let sessionRevoked = false;
const sessionController = new AbortController();
/** Subscribe to authentication failures observed on any API call. */
export function onUnauthenticated(listener: SessionListener): () => void {
sessionListeners.add(listener);
return () => { sessionListeners.delete(listener); };
}
function emitUnauthenticated(): void {
// A 401 before any successful API call is an unauthenticated first visit; a
// 401 after one is a session that expired while the user was working.
const notice: AuthNotice = { kind: sawAuthenticatedResponse ? 'expired' : 'required', returnPath: currentReturnPath() };
[...sessionListeners].forEach((listener) => listener(notice));
}
function isSameOriginAPIRequest(input: RequestInfo | URL): boolean {
if (typeof window === 'undefined') return false;
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
const url = new URL(raw, window.location.href);
return url.origin === window.location.origin && (url.pathname === '/api' || url.pathname.startsWith('/api/'));
}
function revokeSession(): void {
if (sessionRevoked) return;
sessionRevoked = true;
sessionController.abort();
emitUnauthenticated();
}
export function apiRequestInit(input: RequestInfo | URL, init?: RequestInit): RequestInit | undefined {
if (!isSameOriginAPIRequest(input)) return init;
const signal = init?.signal ? AbortSignal.any([init.signal, sessionController.signal]) : sessionController.signal;
return { ...init, cache: 'no-store', signal };
}
/**
* Observes API responses in one place so that a 401 on any page can offer a
* sign-in affordance without every page having to know about authentication.
* Responses are passed through untouched; only the notification is added.
*/
export function installSessionWatcher(): void {
if (watcherInstalled || typeof window === 'undefined' || typeof window.fetch !== 'function') return;
watcherInstalled = true;
const original = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
if (!isSameOriginAPIRequest(input)) return original(input, init);
if (sessionRevoked) return new Response(null, { status: 401, statusText: 'Unauthorized' });
const response = await original(input, apiRequestInit(input, init));
if (response.status === 401) revokeSession();
else if (response.ok) sawAuthenticatedResponse = true;
return response;
};
}
File diff suppressed because one or more lines are too long
+24
View File
@@ -0,0 +1,24 @@
type DashboardVariable = { name?: unknown; default?: unknown };
// Resolve only exact `$name` references declared by the dashboard. The query
// planner remains the authority for accepted scope keys and values; unresolved
// references are preserved so they fail visibly instead of widening a query.
export function resolveDashboardScope(
rawScope: Record<string, unknown>,
variables: unknown[],
): Record<string, string> {
const defaults = new Map<string, string>();
for (const candidate of variables) {
if (!candidate || typeof candidate !== 'object') continue;
const variable = candidate as DashboardVariable;
if (typeof variable.name === 'string' && variable.name !== '' && typeof variable.default === 'string') {
defaults.set(variable.name, variable.default);
}
}
return Object.fromEntries(Object.entries(rawScope).flatMap(([key, value]) => {
if (typeof value !== 'string') return [];
const reference = /^\$([A-Za-z][A-Za-z0-9_-]{0,63})$/.exec(value);
if (!reference) return [[key, value]];
return [[key, defaults.get(reference[1]) ?? value]];
}));
}
+15
View File
@@ -0,0 +1,15 @@
export function queryValue(name: string, allowed?: readonly string[], fallback = ''): string {
if (typeof window === 'undefined') return fallback;
const value = new URLSearchParams(window.location.search).get(name) ?? fallback;
return !allowed || allowed.includes(value) ? value : fallback;
}
export function replaceListQuery(values: Record<string, string>): void {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(values)) {
if (value) params.set(key, value); else params.delete(key);
}
const query = params.toString();
window.history.replaceState({}, '', window.location.pathname + (query ? `?${query}` : ''));
}
+168
View File
@@ -0,0 +1,168 @@
export type LiveFreshness = 'fresh' | 'delayed' | 'stale' | 'unavailable';
export type LiveSample = { series: string; timestamp: string; value: number | null; freshness: LiveFreshness; labels?: Record<string, string> };
export type BufferedPoint = { timestamp: number; value: number | null; freshness: LiveFreshness; labels?: Record<string, string> };
function clonePoint(point: BufferedPoint): BufferedPoint {
return { ...point, labels: point.labels ? { ...point.labels } : undefined };
}
export class SeriesRingBuffer {
private readonly values: Array<BufferedPoint | undefined>;
private head = 0;
private count = 0;
readonly capacity: number;
constructor(capacity: number) {
this.capacity = capacity;
if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('Ring buffer capacity must be a positive integer.');
this.values = new Array<BufferedPoint | undefined>(capacity);
}
get length(): number { return this.count; }
append(point: BufferedPoint): void {
const index = this.count < this.capacity ? (this.head + this.count) % this.capacity : this.head;
this.values[index] = clonePoint(point);
if (this.count < this.capacity) this.count += 1;
else this.head = (this.head + 1) % this.capacity;
}
appendMany(points: readonly BufferedPoint[]): void { points.forEach((point) => this.append(point)); }
snapshot(): BufferedPoint[] {
const result: BufferedPoint[] = [];
for (let index = 0; index < this.count; index += 1) {
const point = this.values[(this.head + index) % this.capacity];
if (point) result.push(clonePoint(point));
}
return result;
}
clear(): void {
this.values.fill(undefined);
this.head = 0;
this.count = 0;
}
}
export function historicalSamplesFromData(data: unknown): LiveSample[] {
if (!data || typeof data !== 'object' || !Array.isArray((data as { result?: unknown[] }).result)) return [];
const result: LiveSample[] = [];
((data as { result: unknown[] }).result).forEach((item) => {
if (!item || typeof item !== 'object') return;
const record = item as { metric?: Record<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) => {
if (!Array.isArray(raw) || raw.length < 2) return;
const seconds = Number(raw[0]);
const value = Number(raw[1]);
if (!Number.isFinite(seconds)) return;
result.push({ series, timestamp: new Date(seconds * 1000).toISOString(), value: Number.isFinite(value) ? value : null, freshness: 'fresh', labels });
});
});
return result;
}
/** Series that stopped reporting for this long are dropped by `evictStale`. */
export const DEFAULT_SERIES_TTL_MS = 900_000;
/** Hard ceiling on distinct series keys; the least recently updated is dropped first. */
export const DEFAULT_MAX_SERIES = 64;
export class LiveSeriesStore {
private readonly series = new Map<string, SeriesRingBuffer>();
private readonly lastSeen = new Map<string, number>();
readonly capacity: number;
readonly maxSeries: number;
constructor(capacity = 240, maxSeries = DEFAULT_MAX_SERIES) {
this.capacity = capacity;
this.maxSeries = maxSeries;
if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('Series capacity must be a positive integer.');
if (!Number.isInteger(maxSeries) || maxSeries < 1) throw new RangeError('Series count limit must be a positive integer.');
}
get seriesCount(): number { return this.series.size; }
append(samples: readonly LiveSample[]): void {
samples.forEach((sample) => {
const timestamp = Date.parse(sample.timestamp);
if (!sample.series || Number.isNaN(timestamp)) return;
let buffer = this.series.get(sample.series);
if (!buffer) {
buffer = new SeriesRingBuffer(this.capacity);
this.series.set(sample.series, buffer);
}
buffer.append({ timestamp, value: sample.value, freshness: sample.freshness, labels: sample.labels });
this.lastSeen.set(sample.series, timestamp);
});
this.enforceSeriesLimit();
}
/**
* Drops series whose most recent point is older than `ttlMs`. Only the ring
* buffers were bounded before, so a long-running wallboard accumulated map
* keys for every series name it ever saw.
*/
evictStale(ttlMs = DEFAULT_SERIES_TTL_MS, now = Date.now()): number {
let removed = 0;
[...this.lastSeen.entries()].forEach(([key, seen]) => {
if (now - seen <= ttlMs) return;
this.series.delete(key);
this.lastSeen.delete(key);
removed += 1;
});
return removed;
}
private enforceSeriesLimit(): void {
if (this.series.size <= this.maxSeries) return;
const ordered = [...this.lastSeen.entries()].sort((a, b) => a[1] - b[1]);
for (const [key] of ordered) {
if (this.series.size <= this.maxSeries) break;
this.series.delete(key);
this.lastSeen.delete(key);
}
}
snapshot(): Record<string, BufferedPoint[]> {
const result: Record<string, BufferedPoint[]> = {};
this.series.forEach((buffer, key) => { result[key] = buffer.snapshot(); });
return result;
}
pointCount(): number {
let count = 0;
this.series.forEach((buffer) => { count += buffer.length; });
return count;
}
clear(): void { this.series.clear(); this.lastSeen.clear(); }
}
export interface ChartSeries {
key: string;
points: BufferedPoint[];
}
export class LiveChartAdapter {
private readonly store: LiveSeriesStore;
constructor(capacity = 240, maxSeries = DEFAULT_MAX_SERIES) { this.store = new LiveSeriesStore(capacity, maxSeries); }
append(samples: readonly LiveSample[]): void { this.store.append(samples); }
/** Drops series that stopped reporting; see `LiveSeriesStore.evictStale`. */
evictStale(ttlMs = DEFAULT_SERIES_TTL_MS, now = Date.now()): number { return this.store.evictStale(ttlMs, now); }
get seriesCount(): number { return this.store.seriesCount; }
snapshot(): ChartSeries[] {
return Object.entries(this.store.snapshot()).map(([key, points]) => ({ key, points }));
}
get pointCount(): number { return this.store.pointCount(); }
clear(): void { this.store.clear(); }
}
+308
View File
@@ -0,0 +1,308 @@
import type { MetricQueryRequest } from './metricClient';
import type { LiveSample } from './liveBuffer';
export type LiveStatus = 'subscribed' | 'resync-required' | 'paused' | 'unsubscribed';
export type LiveEvent =
| { type: 'samples'; subscriptionId: string; sequence: number; samples: LiveSample[] }
| { type: 'status'; subscriptionId: string; state: LiveStatus; detail?: string }
| { type: 'error'; subscriptionId?: string; code: string; message: string };
type SocketLike = {
readyState: number;
onopen: (() => void) | null;
onmessage: ((event: { data: unknown }) => void) | null;
onerror: (() => void) | null;
onclose: (() => void) | null;
send: (payload: string) => void;
close: () => void;
};
export type SocketFactory = (url: string) => SocketLike;
export type LiveListener = (event: LiveEvent) => void;
export type LiveSubscription = { key: string; unsubscribe: () => void };
const defaultOpenTimeoutMs = 5000;
const maxReconnectDelayMs = 10000;
const subscriptionReleaseGraceMs = 250;
const socketIdleCloseGraceMs = 10000;
const heartbeatIntervalMs = 30000;
export class LiveClientError extends Error {
readonly code: string;
constructor(code: string, message: string) { super(message); this.code = code; }
}
type SharedSubscription = {
key: string;
id: string;
request: MetricQueryRequest;
listeners: Set<LiveListener>;
releaseTimer: ReturnType<typeof setTimeout> | null;
sent: boolean;
lastSequence: number;
};
function sortedValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortedValue);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, sortedValue(item)]));
}
return value;
}
export function liveQueryKey(request: MetricQueryRequest): string {
// `from` and `to` seed the historical query only. Once subscribed, the live
// sampler continuously evaluates the semantic metric at `stepSeconds`;
// rotating to a dashboard with the same metric must therefore share the
// existing stream instead of opening a new socket for a newer seed window.
const { range, ...semantic } = request;
return JSON.stringify(sortedValue({ ...semantic, range: { stepSeconds: range.stepSeconds } }));
}
function defaultSocketFactory(url: string): SocketLike {
const parsed = new URL(url, window.location.href);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
return new WebSocket(parsed.toString()) as unknown as SocketLike;
}
export class LiveClient {
private readonly url: string;
private readonly factory: SocketFactory;
private socket: SocketLike | null = null;
private opening: Promise<void> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private idleCloseTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectAttempt = 0;
private heartbeatSequence = 0;
private sequence = 0;
private hidden = false;
private readonly subscriptions = new Map<string, SharedSubscription>();
private readonly byId = new Map<string, SharedSubscription>();
private readonly visibilityHandler: (() => void) | null;
constructor(url = '/api/v1/live', factory: SocketFactory = defaultSocketFactory) {
this.url = url;
this.factory = factory;
if (typeof document !== 'undefined') {
this.hidden = document.visibilityState === 'hidden';
this.visibilityHandler = () => {
this.hidden = document.visibilityState === 'hidden';
this.resubscribeAll();
};
document.addEventListener('visibilitychange', this.visibilityHandler);
} else {
this.visibilityHandler = null;
}
}
subscribe(request: MetricQueryRequest, listener: LiveListener): LiveSubscription {
this.cancelIdleClose();
const key = liveQueryKey(request);
let shared = this.subscriptions.get(key);
if (!shared) {
shared = { key, id: 'browser-' + (++this.sequence), request, listeners: new Set(), releaseTimer: null, sent: false, lastSequence: 0 };
this.subscriptions.set(key, shared);
this.byId.set(shared.id, shared);
}
if (shared.releaseTimer) clearTimeout(shared.releaseTimer);
shared.releaseTimer = null;
shared.listeners.add(listener);
let active = true;
void this.ensureOpen().then(() => {
if (active && this.subscriptions.get(key) === shared) this.sendSubscribe(shared);
}).catch((error: unknown) => {
this.notify(shared as SharedSubscription, { type: 'error', subscriptionId: shared?.id, code: error instanceof LiveClientError ? error.code : 'LIVE_CONNECTION_UNAVAILABLE', message: error instanceof Error ? error.message : 'Live verbinding is niet beschikbaar.' });
this.scheduleReconnect();
});
return { key, unsubscribe: () => {
if (!active) return;
active = false;
this.removeListener(shared as SharedSubscription, listener);
}};
}
/**
* Drops shared subscriptions that no longer have listeners. `removeListener`
* already does this on the happy path; this is the safety net for a component
* tree that unmounts without a matching unsubscribe (dashboard rotation on a
* wallboard, a widget that threw), so neither map grows for the lifetime of
* the process.
*/
releaseUnused(): void {
[...this.subscriptions.values()].forEach((shared) => {
if (shared.listeners.size > 0) return;
this.scheduleRelease(shared);
});
}
close(): void {
this.stopHeartbeat();
this.cancelIdleClose();
this.subscriptions.forEach((shared) => {
if (shared.releaseTimer) clearTimeout(shared.releaseTimer);
shared.releaseTimer = null;
});
this.subscriptions.clear();
this.byId.clear();
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
this.reconnectAttempt = 0;
const socket = this.socket;
this.socket = null;
this.opening = null;
socket?.close();
}
private removeListener(shared: SharedSubscription, listener: LiveListener): void {
shared.listeners.delete(listener);
if (shared.listeners.size > 0) return;
this.scheduleRelease(shared);
}
private scheduleRelease(shared: SharedSubscription): void {
if (shared.releaseTimer) return;
shared.releaseTimer = setTimeout(() => {
shared.releaseTimer = null;
if (shared.listeners.size > 0 || this.subscriptions.get(shared.key) !== shared) return;
this.subscriptions.delete(shared.key);
this.byId.delete(shared.id);
if (shared.sent && this.socket?.readyState === 1) {
this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
}
if (this.subscriptions.size === 0 && this.socket) this.scheduleIdleClose();
}, subscriptionReleaseGraceMs);
}
private scheduleIdleClose(): void {
if (this.idleCloseTimer || !this.socket) return;
this.idleCloseTimer = setTimeout(() => {
this.idleCloseTimer = null;
if (this.subscriptions.size === 0) this.close();
}, socketIdleCloseGraceMs);
}
private cancelIdleClose(): void {
if (this.idleCloseTimer) clearTimeout(this.idleCloseTimer);
this.idleCloseTimer = null;
}
private ensureOpen(): Promise<void> {
this.cancelIdleClose();
if (this.socket?.readyState === 1) return Promise.resolve();
if (this.opening) return this.opening;
const socket = this.factory(this.url);
this.socket = socket;
this.opening = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
socket.close();
reject(new LiveClientError('LIVE_CONNECTION_TIMEOUT', 'Live verbinding reageert niet.'));
}, defaultOpenTimeoutMs);
socket.onopen = () => {
clearTimeout(timer);
this.reconnectAttempt = 0;
this.startHeartbeat();
resolve();
this.byId.forEach((shared) => this.sendSubscribe(shared));
};
socket.onerror = () => { clearTimeout(timer); reject(new LiveClientError('LIVE_CONNECTION_UNAVAILABLE', 'Live verbinding is niet beschikbaar.')); };
socket.onclose = () => {
clearTimeout(timer);
this.stopHeartbeat();
if (this.socket === socket) this.socket = null;
this.byId.forEach((shared) => {
shared.sent = false;
shared.lastSequence = 0;
this.notify(shared, { type: 'status', subscriptionId: shared.id, state: 'resync-required', detail: 'Live verbinding wordt hersteld.' });
});
if (this.subscriptions.size > 0) this.scheduleReconnect();
};
socket.onmessage = (event) => this.handleMessage(event.data);
}).finally(() => {
if (this.opening) this.opening = null;
});
return this.opening;
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.subscriptions.size === 0) return;
const delay = Math.min(maxReconnectDelayMs, 1000 * (2 ** this.reconnectAttempt));
this.reconnectAttempt += 1;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.ensureOpen().catch(() => this.scheduleReconnect());
}, delay);
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.socket?.readyState !== 1 || this.subscriptions.size === 0) return;
this.heartbeatSequence += 1;
this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'ping', nonce: 'browser-' + this.heartbeatSequence }));
}, heartbeatIntervalMs);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
private effectiveInterval(shared: SharedSubscription): number {
const requested = Math.max(1, Math.min(300, Math.round(shared.request.range.stepSeconds)));
return this.hidden ? Math.min(300, Math.max(requested, requested * 5)) : requested;
}
private sendSubscribe(shared: SharedSubscription): void {
if (shared.sent || this.socket?.readyState !== 1) return;
shared.sent = true;
this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'subscribe', subscriptionId: shared.id, query: shared.request, intervalSeconds: this.effectiveInterval(shared) }));
}
private resubscribeAll(): void {
if (this.socket?.readyState !== 1) return;
this.byId.forEach((shared) => {
if (shared.sent) {
this.socket?.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
shared.sent = false;
shared.lastSequence = 0;
}
this.sendSubscribe(shared);
});
}
private requestResync(shared: SharedSubscription): void {
this.notify(shared, { type: 'status', subscriptionId: shared.id, state: 'resync-required', detail: 'Live data bevat een gat; synchronisatie wordt herhaald.' });
if (this.socket?.readyState === 1 && shared.sent) {
this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
shared.sent = false;
}
shared.lastSequence = 0;
this.sendSubscribe(shared);
}
private handleMessage(data: unknown): void {
if (typeof data !== 'string') return;
let message: Record<string, unknown>;
try { message = JSON.parse(data) as Record<string, unknown>; } catch { return; }
const id = typeof message.subscriptionId === 'string' ? message.subscriptionId : undefined;
const shared = id ? this.byId.get(id) : undefined;
if (message.type === 'samples' && shared && Array.isArray(message.samples) && typeof message.sequence === 'number') {
if (message.sequence <= shared.lastSequence) return;
if (shared.lastSequence > 0 && message.sequence > shared.lastSequence + 1) {
this.requestResync(shared);
return;
}
shared.lastSequence = message.sequence;
this.notify(shared, { type: 'samples', subscriptionId: shared.id, sequence: message.sequence, samples: message.samples as LiveSample[] });
} else if (message.type === 'status' && shared && typeof message.state === 'string') {
this.notify(shared, { type: 'status', subscriptionId: shared.id, state: message.state as LiveStatus, detail: typeof message.detail === 'string' ? message.detail : undefined });
} else if (message.type === 'error' && shared) {
this.notify(shared, { type: 'error', subscriptionId: shared.id, code: String(message.code ?? 'LIVE_ERROR'), message: String(message.message ?? 'Live fout.') });
}
}
private notify(shared: SharedSubscription, event: LiveEvent): void {
[...shared.listeners].forEach((listener) => listener(event));
}
}
+33
View File
@@ -0,0 +1,33 @@
export const UI_LOCALE = 'nl-BE';
export const UI_TIME_ZONE = 'Europe/Brussels';
const numberFormatter = new Intl.NumberFormat(UI_LOCALE);
const decimalFormatter = new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits: 1 });
const dateTimeFormatter = new Intl.DateTimeFormat(UI_LOCALE, { dateStyle: 'medium', timeStyle: 'short', timeZone: UI_TIME_ZONE });
export const NEVER_RECEIVED = 'Nooit ontvangen';
export function formatNumber(value: number, maximumFractionDigits?: number): string {
if (!Number.isFinite(value)) return '—';
return (maximumFractionDigits === undefined ? numberFormatter : new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits })).format(value);
}
export function formatDecimal(value: number): string {
return Number.isFinite(value) ? decimalFormatter.format(value) : '—';
}
export function formatDateTime(value?: string): string {
if (!hasReceivedTimestamp(value)) return NEVER_RECEIVED;
const date = new Date(value);
return dateTimeFormatter.format(date);
}
/** Rejects transport zero-values and invalid input before they reach a visible <time>. */
export function hasReceivedTimestamp(value?: string): value is string {
if (!value?.trim()) return false;
const date = new Date(value);
return Number.isFinite(date.valueOf()) && date.valueOf() > 0 && date.getUTCFullYear() > 1;
}
export function formatPercent(value?: number, maximumFractionDigits = 1): string {
return value === undefined || !Number.isFinite(value) ? '—' : new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits, style: 'percent' }).format(value / 100);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+40
View File
@@ -0,0 +1,40 @@
import { UI_LOCALE, UI_TIME_ZONE } from './locale';
export type MetricFreshness = 'fresh' | 'delayed' | 'stale' | 'unavailable';
export type MetricRangePreset = 'live' | '15m' | '1h' | '6h' | '24h' | '7d';
export type MetricRange = { from: string; to: string; stepSeconds: number };
export type MetricQueryRequest = { metric: string; scope?: Record<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 } };
export type MetricQueryResponse = { status: string; data: unknown; warnings?: string[]; provenance: { source: string; metric: string; catalogVersion: string; cacheKey: string }; sourceObservedAt: string; receivedAt: string; freshness: MetricFreshness; cacheHit: boolean; inspector?: MetricInspector };
export type MetricProblem = { code: string; detail: string; fields?: Record<string, string> };
export class MetricApiError extends Error {
constructor(readonly status: number, readonly problem?: MetricProblem) { super(problem?.detail ?? ((status >= 500 || status === 404 || status === 0) ? 'De metricbron is tijdelijk niet beschikbaar.' : 'Metricquery mislukt.')); }
get actionable(): string { if (this.status >= 500 || this.status === 404) return 'De metricbron is tijdelijk niet beschikbaar.'; if (this.problem?.code === 'QUERY_POINT_LIMIT' || this.problem?.code === 'QUERY_SERIES_LIMIT' || this.problem?.code === 'QUERY_COST_LIMIT') return 'Verklein de periode of beperk het aantal reeksen.'; return this.message; }
}
export class MetricClient {
// Resolve the ambient fetch at request time. Runtime widget modules are
// evaluated before App installs the shared session watcher; capturing the
// native function here would bypass that wrapper and can also invoke an
// unbound browser fetch implementation.
constructor(private readonly fetcher: typeof fetch = (input, init) => fetch(input, init)) {}
async queryRange(request: MetricQueryRequest, signal?: AbortSignal): Promise<MetricQueryResponse> {
const response = await this.fetcher('/api/v1/metrics/query-range', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), signal });
if (!response.ok) {
let problem: MetricProblem | undefined;
try { problem = await response.json() as MetricProblem; } catch { /* safe fallback below */ }
throw new MetricApiError(response.status, problem);
}
return await response.json() as MetricQueryResponse;
}
}
export function rangeForPreset(preset: MetricRangePreset, now = new Date()): MetricRange {
const seconds: Record<MetricRangePreset, number> = { live: 300, '15m': 900, '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800 };
const to = new Date(now.getTime());
const from = new Date(to.getTime() - seconds[preset] * 1000);
const stepSeconds = preset === 'live' ? 15 : Math.max(15, Math.ceil(seconds[preset] / 4000));
return { from: from.toISOString(), to: to.toISOString(), stepSeconds };
}
export function displayTime(iso: string, locale = UI_LOCALE): string { const date = new Date(iso); return Number.isNaN(date.getTime()) ? 'Onbekende tijd' : new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeStyle: 'short', timeZone: UI_TIME_ZONE }).format(date); }
+57
View File
@@ -0,0 +1,57 @@
export type SignalTone = 'healthy' | 'attention' | 'critical' | 'stale' | 'unknown';
export type SignalSource = {
state?: string;
freshness?: string;
};
export type SignalContainer = {
state?: string;
health?: string;
intentionalStop?: boolean;
};
export const signalToneRank: Record<SignalTone, number> = {
critical: 0,
attention: 1,
stale: 2,
unknown: 3,
healthy: 4,
};
export function signalToneFromState(state: string | undefined): SignalTone {
const normalized = state?.trim().toLowerCase();
if (normalized === 'critical' || normalized === 'faulted' || normalized === 'down' || normalized === 'failed' || normalized === 'error') return 'critical';
if (normalized === 'attention' || normalized === 'degraded' || normalized === 'warning' || normalized === 'restarting' || normalized === 'paused' || normalized === 'removing') return 'attention';
if (normalized === 'stale') return 'stale';
if (normalized === 'healthy' || normalized === 'normal' || normalized === 'up' || normalized === 'running' || normalized === 'fresh') return 'healthy';
return 'unknown';
}
export function worstSignalTone(tones: SignalTone[]): SignalTone {
if (tones.length === 0) return 'unknown';
return tones.reduce<SignalTone>((worst, tone) => signalToneRank[tone] < signalToneRank[worst] ? tone : worst, 'healthy');
}
/** A successful HTTP response is not healthy when its provenance is stale or unknown. */
export function sourceSignalTone(source: SignalSource | undefined): SignalTone {
if (!source) return 'unknown';
const freshness = source.freshness?.trim().toLowerCase();
if (freshness === 'stale') return 'stale';
if (freshness !== 'fresh') return 'unknown';
return signalToneFromState(source.state);
}
/** Mirrors the backend application-state projection without treating an intentional stop as a failure. */
export function containerSignalTone(container: SignalContainer): SignalTone {
const state = container.state?.trim().toLowerCase();
const health = container.health?.trim().toLowerCase();
if (state === 'running') {
if (health === 'healthy') return 'healthy';
if (health === 'unhealthy') return 'attention';
return 'unknown';
}
if (state === 'restarting' || state === 'paused' || state === 'removing') return 'attention';
if (state === 'exited' || state === 'dead' || state === 'stopped') return container.intentionalStop ? 'unknown' : 'critical';
return 'unknown';
}
+181
View File
@@ -0,0 +1,181 @@
import { copy } from './copy';
const statusLabels: Record<string, string> = {
healthy: copy.presentation.status.healthy,
operational: copy.presentation.status.operational,
available: copy.presentation.status.available,
running: copy.presentation.status.running,
paused: copy.presentation.status.paused,
starting: copy.presentation.status.starting,
exited: copy.presentation.status.stopped,
stopped: copy.presentation.status.stopped,
restarting: copy.presentation.status.restarting,
down: copy.presentation.status.down,
up: copy.presentation.status.up,
online: copy.presentation.status.online,
offline: copy.presentation.status.offline,
missing: copy.presentation.status.missing,
faulted: copy.presentation.status.faulted,
sleeping: copy.presentation.status.sleeping,
idle: copy.presentation.status.idle,
completed: copy.presentation.status.completed,
failed: copy.presentation.status.failed,
cached: copy.presentation.status.cached,
passed: 'Geslaagd',
unsupported: 'Niet ondersteund',
valid: 'Geldig',
invalid: 'Ongeldig',
unhealthy: copy.presentation.status.unhealthy,
attention: copy.presentation.status.attention,
degraded: copy.presentation.status.degraded,
warning: copy.presentation.status.warning,
info: copy.presentation.status.info,
error: copy.presentation.status.error,
critical: copy.presentation.status.critical,
unknown: copy.presentation.status.unknown,
unavailable: copy.presentation.status.unavailable,
disabled: copy.presentation.status.disabled,
normal: copy.presentation.status.healthy,
stale: copy.presentation.status.stale,
fresh: copy.presentation.status.fresh,
pending: copy.presentation.status.pending,
firing: copy.presentation.status.firing,
acknowledged: copy.presentation.status.acknowledged,
resolved: copy.presentation.status.resolved,
silenced: copy.presentation.status.silenced,
suppressed: copy.presentation.status.suppressed,
maintenance: copy.presentation.status.maintenance,
active: copy.presentation.status.active,
scheduled: copy.presentation.status.scheduled,
expired: copy.presentation.status.expired,
revoked: copy.presentation.status.revoked,
};
const reasonLabels: Record<string, string> = {
source_health_unknown: copy.presentation.reason.sourceHealthUnknown,
source_unavailable: copy.presentation.reason.sourceUnavailable,
not_configured: copy.presentation.reason.notConfigured,
last_run_failed: copy.presentation.reason.lastRunFailed,
last_run_succeeded: copy.presentation.reason.lastRunSucceeded,
no_recent_sample: copy.presentation.reason.noRecentSample,
source_stale: copy.presentation.reason.stale,
stale_probe: copy.presentation.reason.stale,
stale: copy.presentation.reason.stale,
stale_source: copy.presentation.reason.stale,
filesystem_root_not_configured: copy.presentation.reason.filesystemRootNotConfigured,
unavailable: copy.presentation.reason.sourceUnavailable,
insufficient_history: copy.presentation.reason.insufficientHistory,
host_unreachable: copy.presentation.reason.hostUnreachable,
dns_failure: copy.presentation.reason.dnsFailure,
source_unavailable_dependency: copy.presentation.reason.sourceUnavailable,
backup_verified: copy.presentation.reason.backupVerified,
backup_stale: copy.presentation.reason.backupStale,
backup_verification_failed: copy.presentation.reason.backupVerificationFailed,
authenticated_session: copy.presentation.reason.authenticatedSession,
authenticated_session_not_observed: copy.presentation.reason.authenticatedSessionNotObserved,
database_ready: copy.presentation.reason.databaseReady,
source_sampled: copy.presentation.reason.sourceSampled,
provider_health_not_sampled: copy.presentation.reason.providerNotSampled,
source_health_not_sampled: copy.presentation.reason.sourceNotSampled,
source_not_configured: copy.presentation.reason.notConfigured,
delivery_health_not_sampled: copy.presentation.reason.notificationsNotSampled,
probe_heartbeat_not_recorded: copy.presentation.reason.probesNotRecorded,
heartbeat_not_recorded: copy.presentation.reason.workerNotRecorded,
no_verified_backup: copy.presentation.reason.noVerifiedBackup,
none: copy.presentation.reason.noDetail,
};
const componentLabels: Record<string, string> = {
api: 'API', database: copy.presentation.component.database, worker: copy.presentation.component.worker,
prometheus: 'Prometheus', query: copy.presentation.component.query, unraid: 'Unraid',
storage: copy.presentation.component.storage, backup: copy.presentation.component.backup,
notifications: copy.presentation.component.notifications, oidc: copy.presentation.component.oidc,
probes: copy.presentation.component.probes,
};
const metricLabels: Record<string, string> = {
'host.cpu.utilization': copy.presentation.metric.hostCpu,
'host.memory.utilization': copy.presentation.metric.hostMemory,
'container.cpu.utilization': copy.presentation.metric.containerCpu,
'container.memory.used': copy.presentation.metric.containerMemory,
'storage.disk.temperature': copy.presentation.metric.diskTemperature,
'storage.disk.temperature.maximum': copy.presentation.metric.maximumDiskTemperature,
'storage.pool.utilization': copy.presentation.metric.poolUtilization,
'service.response_time': copy.presentation.metric.serviceResponseTime,
'service.availability': copy.presentation.metric.serviceAvailability,
'service.availability.minimum': copy.presentation.metric.minimumServiceAvailability,
};
export function presentStatus(value?: string): string {
const normalized = value?.trim().toLowerCase() ?? '';
return statusLabels[normalized] ?? copy.presentation.status.unknown;
}
const operationalRank: Record<string, number> = { healthy: 0, normal: 0, attention: 1, degraded: 2, unknown: 3, faulted: 4, critical: 4 };
/** Keeps device health and capacity separate while making the worst signal primary. */
export function operationalStorageState(deviceState?: string, capacityState?: string): string {
const device = deviceState?.trim().toLowerCase() || 'unknown';
const capacity = capacityState?.trim().toLowerCase() || 'unknown';
return (operationalRank[capacity] ?? operationalRank.unknown) > (operationalRank[device] ?? operationalRank.unknown) ? capacity : device;
}
export function presentReason(value?: string): string {
const normalized = value?.trim().toLowerCase() ?? '';
if (!normalized) return copy.presentation.reason.noDetail;
if (reasonLabels[normalized]) return reasonLabels[normalized];
if (normalized.startsWith('container_')) return `${copy.presentation.reason.containerPrefix} ${presentStatus(normalized.slice(10)).toLowerCase()}.`;
if (normalized.startsWith('service_')) return `${copy.presentation.reason.servicePrefix} ${presentStatus(normalized.slice(8)).toLowerCase()}.`;
if (/^[a-z0-9]+(?:[._-][a-z0-9]+)+$/.test(normalized)) return copy.presentation.reason.technical;
return value?.trim() || copy.presentation.reason.noDetail;
}
export function presentComponent(value: string): string {
return componentLabels[value.trim().toLowerCase()] ?? copy.presentation.component.other;
}
export function presentMetric(value: string): string {
return metricLabels[value] ?? copy.presentation.metric.other;
}
const entityLabels: Record<string, string> = { host: 'Host', container: 'Container', 'container-service': 'Compose-service', 'container-instance': 'Containerinstantie', application: 'Applicatie', 'application-project': 'Compose-project', 'application-instance': 'Zelfstandige applicatie', service: 'Service', probe: 'Servicecontrole', disk: 'Schijf', pool: 'Pool', share: 'Share', array: 'Array', process: 'Proces', network: 'Netwerk' };
export function presentEntityType(value?: string): string { return entityLabels[value?.trim().toLowerCase() ?? ''] ?? 'Onderdeel'; }
const inventoryFieldLabels: Record<string, string> = { runtimeState: 'Runtime-status', health: 'Gezondheid', restartCount: 'Herstarts', intentionalStop: 'Bewust gestopt', metricsAvailable: 'Metrics beschikbaar', lifecycleAvailable: 'Lifecycle beschikbaar', image: 'Image', project: 'Compose-project', composeService: 'Compose-service', groupingMode: 'Groepering', componentCount: 'Componenten' };
export function presentInventoryField(value: string): string { return inventoryFieldLabels[value] ?? 'Bronkenmerk'; }
const relationLabels: Record<string, string> = { depends_on: 'is afhankelijk van', depends: 'is afhankelijk van', backs: 'ondersteunt', exposes: 'biedt aan', contains: 'bevat', member_of: 'is lid van', runs_on: 'draait op', connected_to: 'is verbonden met' };
export function presentRelationType(value?: string): string { return relationLabels[value?.trim().toLowerCase() ?? ''] ?? 'heeft een relatie met'; }
const arrayRoleLabels: Record<string, string> = { data: 'Gegevensschijf', parity: 'Pariteit', cache: 'Cache', member: 'Lid' };
export function presentArrayRole(value?: string): string { return arrayRoleLabels[value?.trim().toLowerCase() ?? ''] ?? 'Schijf'; }
const storagePolicyLabels: Record<string, string> = { highwater: 'Hoogwater', 'high-water': 'Hoogwater', fillup: 'Opvullen', 'fill-up': 'Opvullen', mostfree: 'Meeste vrije ruimte', 'most-free': 'Meeste vrije ruimte', yes: 'Voorkeur voor cache', no: 'Alleen primaire opslag', prefer: 'Cache heeft voorkeur', only: 'Alleen cache' };
export function presentStoragePolicy(value?: string, fallback = 'Onbekend beleid'): string { return storagePolicyLabels[value?.trim().toLowerCase() ?? ''] ?? fallback; }
const eventLabels: Record<string, string> = {
'container.state_changed': 'Containerstatus gewijzigd', 'container.health_changed': 'Containergezondheid gewijzigd', 'container.restart': 'Container herstart', 'container.intentional_stop_changed': 'Bewuste stop gewijzigd',
'array.degraded': 'Array vraagt aandacht', 'array.missing': 'Arraylid ontbreekt', 'array.recovered': 'Array hersteld', 'array.parity_changed': 'Paritystatus gewijzigd',
'pool.degraded': 'Pool vraagt aandacht', 'pool.faulted': 'Pool defect', 'pool.recovered': 'Pool hersteld', 'pool.scrub_failed': 'Poolscrub mislukt',
};
export function presentEventType(value?: string): string { return eventLabels[value?.trim().toLowerCase() ?? ''] ?? 'Operationele wijziging'; }
const eventSummaries: Record<string, string> = {
'container.state_changed': 'De runtime-status van de container is gewijzigd.', 'container.health_changed': 'De gerapporteerde containergezondheid is gewijzigd.', 'container.restart': 'De container is opnieuw gestart.', 'container.intentional_stop_changed': 'De markering voor een bewuste stop is gewijzigd.',
'array.degraded': 'De array meldt een toestand die aandacht vereist.', 'array.missing': 'De array meldt een ontbrekend lid.', 'array.recovered': 'De array is terug operationeel.', 'array.parity_changed': 'De paritystatus of het aantal parityfouten is gewijzigd.',
'pool.degraded': 'De pool meldt een toestand die aandacht vereist.', 'pool.faulted': 'De pool meldt een defecte toestand.', 'pool.recovered': 'De pool is hersteld.', 'pool.scrub_failed': 'De laatste poolscrub is mislukt.',
};
export function presentEventSummary(type?: string, _summary?: string): string { return eventSummaries[type?.trim().toLowerCase() ?? ''] ?? 'Een bron heeft een operationele wijziging gemeld.'; }
export function presentUnit(value: string): string {
if (value === 'percent') return '%';
if (value === 'bytes') return copy.presentation.unit.bytes;
if (value === 'celsius') return '°C';
if (value === 'seconds') return copy.presentation.unit.seconds;
if (value === 'ratio') return copy.presentation.unit.ratio;
return copy.presentation.unit.value;
}
export function plural(count: number, singular: string, pluralForm: string): string {
return count === 1 ? singular : pluralForm;
}
+15
View File
@@ -0,0 +1,15 @@
export type RoutePath = string;
export const routeFromLocation = (pathname: string): RoutePath => {
if (pathname.startsWith('/dashboards/') && pathname.length > '/dashboards/'.length) return pathname;
if (pathname.startsWith('/containers/') && pathname.length > '/containers/'.length) return pathname;
if (pathname.startsWith('/services/') && pathname.length > '/services/'.length) return pathname;
if (pathname.startsWith('/disks/') && pathname.length > '/disks/'.length) return pathname;
if (pathname.startsWith('/pools/') && pathname.length > '/pools/'.length) return pathname;
if (pathname.startsWith('/shares/') && pathname.length > '/shares/'.length) return pathname;
if (pathname.startsWith('/applications/') && pathname.length > '/applications/'.length) return pathname;
if (pathname.startsWith('/incidents/') && pathname.length > '/incidents/'.length) return pathname;
if (pathname.startsWith('/inventory/') && pathname.length > '/inventory/'.length) return pathname;
const supported = ['/', '/topology', '/network', '/host', '/array', '/disks', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/applications', '/inventory', '/dashboards', '/wallboard', '/alerts', '/events', '/incidents', '/settings', '/status', '/onboarding', '/loading', '/error', '/unauthorized', '/404'];
return supported.includes(pathname) ? pathname : '/404';
};
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentComponent, presentReason } from './presentation';
export type ComponentStatus = { id: string; state: string; reason: string; lastSuccessAt?: string };
export type SourceLag = { sourceId: string; state: string; reason: string; ageSeconds?: number };
export type BackupStatus = { state: string; reason: string; ageSeconds?: number; verifiedAt?: string; lastSuccessAt?: string };
export type SystemStatus = {
version: string;
release?: { version: string; commit: string; builtAt?: string; migrationVersion: string };
generatedAt: string;
overallState: string;
components: ComponentStatus[];
backup: BackupStatus;
sourceLag: SourceLag[];
};
export type SystemStatusState = 'loading' | 'ready' | 'error' | 'unauthorized' | 'forbidden';
export type SystemStatusSnapshot = { state: SystemStatusState; status: SystemStatus | null; fetchedAt: number };
/**
* ADR-0008: a snapshot older than this is treated as missing telemetry. It is
* generous relative to the refresh interval so that a single skipped refresh
* does not flip the badge, but it guarantees a wallboard that lost its backend
* degrades to Unknown instead of freezing on a green badge.
*/
export const STALE_AFTER_MS = 180_000;
export const BACKUP_STALE_AFTER_SECONDS = 24 * 60 * 60;
const REFRESH_MS = 30_000;
const listeners = new Set<(snapshot: SystemStatusSnapshot) => void>();
let snapshot: SystemStatusSnapshot = { state: 'loading', status: null, fetchedAt: 0 };
let controller: AbortController | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
function publish(next: SystemStatusSnapshot): void {
snapshot = next;
[...listeners].forEach((listener) => listener(snapshot));
}
async function load(): Promise<void> {
// A newer request always wins; the older one is aborted and its result
// discarded even if it happened to resolve first.
controller?.abort();
const active = new AbortController();
controller = active;
try {
const response = await fetch('/api/v1/system/status', { signal: active.signal });
if (controller !== active) return;
if (response.status === 401) {
publish({ state: 'unauthorized', status: null, fetchedAt: Date.now() });
return;
}
if (response.status === 403) {
publish({ state: 'forbidden', status: null, fetchedAt: Date.now() });
return;
}
if (!response.ok) throw new Error('status');
const value = await response.json() as SystemStatus;
if (controller !== active) return;
publish({ state: 'ready', status: value, fetchedAt: Date.now() });
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (controller !== active) return;
// ADR-0008: a failed refresh must never leave a previously healthy snapshot
// behind as if it were current.
publish({ state: 'error', status: null, fetchedAt: Date.now() });
} finally {
if (controller === active) controller = null;
}
}
let requestedAt = 0;
/** Starts a request only when nothing recent is in flight or cached. */
function loadIfStale(): void {
const now = Date.now();
if (controller && now - requestedAt < REFRESH_MS) return;
if (snapshot.fetchedAt !== 0 && now - snapshot.fetchedAt < REFRESH_MS) return;
requestedAt = now;
void load();
}
/** Forces a refresh, e.g. from a retry button. */
export function refreshSystemStatus(): void {
publish({ state: 'loading', status: null, fetchedAt: 0 });
requestedAt = Date.now();
void load();
}
/** Clears the singleton between isolated DOM tests; production code never calls this. */
export function resetSystemStatusForTests(): void {
if (timer) clearInterval(timer);
timer = null;
controller?.abort();
controller = null;
listeners.clear();
snapshot = { state: 'loading', status: null, fetchedAt: 0 };
requestedAt = 0;
}
/**
* One shared `/api/v1/system/status` reader. Several surfaces (overview,
* sidebar, dashboard header, status page) need the same aggregate, and a
* rotating wallboard remounts them constantly; a single polled store keeps that
* to one bounded request per interval and cleans up when the last consumer goes.
*/
export function useSystemStatus(): SystemStatusSnapshot {
const [value, setValue] = useState<SystemStatusSnapshot>(snapshot);
useEffect(() => {
listeners.add(setValue);
setValue(snapshot);
loadIfStale();
if (!timer) timer = setInterval(() => { requestedAt = Date.now(); void load(); }, REFRESH_MS);
return () => {
listeners.delete(setValue);
if (listeners.size > 0) return;
if (timer) clearInterval(timer);
timer = null;
controller?.abort();
controller = null;
};
}, []);
return value;
}
export function systemStateLabel(state: string): string {
if (state === 'healthy') return copy.systemStatus.healthy;
if (state === 'disabled') return copy.systemStatus.disabled;
if (state === 'degraded') return copy.systemStatus.degraded;
return copy.systemStatus.unknown;
}
export function componentStatus(status: SystemStatus | null, id: string): ComponentStatus | null {
return status?.components.find((component) => component.id === id) ?? null;
}
/** Fails closed when an allegedly healthy backup is old or has no usable age. */
export function backupPresentation(backup: BackupStatus | undefined, now = Date.now()): BackupStatus {
if (!backup) return { state: 'unknown', reason: 'no_verified_backup' };
let ageSeconds = backup.ageSeconds;
if (ageSeconds == null || !Number.isFinite(ageSeconds)) {
const observed = Date.parse(backup.lastSuccessAt ?? backup.verifiedAt ?? '');
ageSeconds = Number.isNaN(observed) ? undefined : Math.max(0, (now - observed) / 1000);
}
if (backup.state !== 'healthy') return { ...backup, ageSeconds };
if (ageSeconds == null) return { ...backup, state: 'unknown', reason: 'no_verified_backup', ageSeconds };
if (ageSeconds > BACKUP_STALE_AFTER_SECONDS) return { ...backup, state: 'degraded', reason: 'backup_stale', ageSeconds };
return { ...backup, ageSeconds };
}
function isStale(snapshotValue: SystemStatusSnapshot, now: number): boolean {
const generated = Date.parse(snapshotValue.status?.generatedAt ?? '');
if (Number.isNaN(generated)) return true;
return now - generated > STALE_AFTER_MS || now - snapshotValue.fetchedAt > STALE_AFTER_MS;
}
export type AggregateStatus = { state: string; label: string; tone: 'ready' | 'unknown'; detail: string; stale: boolean };
/**
* Maps a snapshot to what the UI may claim. ADR-0008: only a `ready` snapshot
* whose payload says `healthy` and whose observation is fresh may render as
* healthy. Loading, error, unauthorized, forbidden, unknown, missing and stale
* all render as Unknown, and `degraded`/`disabled` keep the Unknown tone because
* neither is a healthy system.
*/
export function aggregateStatus(snapshotValue: SystemStatusSnapshot, now = Date.now()): AggregateStatus {
if (snapshotValue.state === 'loading') {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.statusLoadingDetail, stale: false };
}
if (snapshotValue.state === 'unauthorized') {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.unauthorizedDetail, stale: false };
}
if (snapshotValue.state === 'forbidden') {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.forbiddenDetail, stale: false };
}
if (snapshotValue.state === 'error' || !snapshotValue.status) {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.unavailableDetail, stale: false };
}
const stale = isStale(snapshotValue, now);
if (stale) {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.staleDetail, stale: true };
}
const state = snapshotValue.status.overallState;
if (state === 'healthy') {
return { state, label: copy.systemStatus.healthy, tone: 'ready', detail: copy.overview.healthyDetail, stale: false };
}
if (state === 'degraded') {
return { state, label: copy.systemStatus.degraded, tone: 'unknown', detail: copy.overview.degradedDetail, stale: false };
}
if (state === 'disabled') {
return { state, label: copy.systemStatus.disabled, tone: 'unknown', detail: copy.overview.disabledDetail, stale: false };
}
const connectedSources = snapshotValue.status.sourceLag?.length ?? 0;
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: connectedSources > 0 ? copy.overview.partialDetail : copy.overview.unknownDetail, stale: false };
}
/** The optimistic overview heading is reserved for a fully healthy, issue-free view. */
export function overviewTitle(status: AggregateStatus, problemCount: number, operationalAttention = false): string {
if (status.state === 'healthy' && problemCount === 0 && !operationalAttention) return copy.overview.title;
if (status.state === 'degraded' || problemCount > 0 || operationalAttention) return copy.overview.attentionTitle;
return copy.overview.unknownTitle;
}
/** Bounded list of non-healthy signals, used for the degraded overview summary. */
export function statusProblems(status: SystemStatus | null): Array<{ id: string; label: string; reason: string }> {
if (!status) return [];
const required = new Set(['database', 'worker', 'prometheus', 'query', 'unraid', 'storage']);
const actionableComponents = (status.components ?? []).filter((component) => component.state !== 'healthy' && (component.state !== 'disabled' || required.has(component.id)));
const componentIDs = new Set(actionableComponents.map((component) => component.id));
const components = actionableComponents
.map((component) => ({ id: 'component:' + component.id, label: presentComponent(component.id), reason: presentReason(component.reason) }));
const sources = (status.sourceLag ?? []).filter((source) => source.state !== 'healthy' && !componentIDs.has(source.sourceId))
.map((source) => ({ id: 'source:' + source.sourceId, label: presentComponent(source.sourceId), reason: presentReason(source.reason) }));
const backupStatus = backupPresentation(status.backup);
const backup = backupStatus.state !== 'healthy' && backupStatus.state !== 'disabled'
? [{ id: 'backup', label: copy.systemStatus.backup, reason: presentReason(backupStatus.reason) }]
: [];
return [...components, ...sources, ...backup].slice(0, 10);
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { MetricQueryRequest } from './metricClient';
import { LiveClient, liveQueryKey, type LiveEvent, type LiveSubscription } from './liveClient';
import { LiveChartAdapter, type LiveSample } from './liveBuffer';
type LiveState = 'idle' | 'connecting' | 'live' | 'error';
type LiveMetricResult = { state: LiveState; pointCount: number; series: ReturnType<LiveChartAdapter['snapshot']>; error: string | null };
const noSamples: LiveSample[] = [];
/**
* Cheap identity for a sample batch. The previous implementation serialised the
* whole array into the effect dependency list, which ran on every render — and
* the hook re-renders every 16 ms while streaming, over arrays of up to
* 20 series x 4000 points. Sampling the ends is O(1) and recomputed only when
* the array reference actually changes.
*/
function samplesKey(samples: readonly LiveSample[]): string {
if (samples.length === 0) return '0';
const first = samples[0];
const last = samples[samples.length - 1];
return samples.length + '|' + first.series + '|' + first.timestamp + '|' + first.value + '|' + last.series + '|' + last.timestamp + '|' + last.value;
}
export function useLiveMetric(client: LiveClient, request: MetricQueryRequest | null, initialSamples: LiveSample[] = noSamples, capacity = 240): LiveMetricResult {
const adapter = useRef<LiveChartAdapter | null>(null);
const [state, setState] = useState<LiveState>('idle');
const [error, setError] = useState<string | null>(null);
const [, render] = useState(0);
const frame = useRef<ReturnType<typeof setTimeout> | null>(null);
if (!adapter.current) adapter.current = new LiveChartAdapter(capacity);
const requestKey = request ? liveQueryKey(request) : '';
const initialKey = useMemo(() => samplesKey(initialSamples), [initialSamples]);
// Always seed from the latest array, so an unchanged fingerprint can never
// reintroduce a stale batch.
const latestSamples = useRef(initialSamples);
latestSamples.current = initialSamples;
// Historical seed data can arrive after the live subscription has opened.
// Refreshing that seed must not tear down and recreate the WebSocket: on a
// rotating wallboard that produced a close/open race for every dashboard.
useEffect(() => {
const buffer = adapter.current;
if (!buffer) return undefined;
buffer.clear();
buffer.append(latestSamples.current);
return undefined;
}, [requestKey, initialKey, capacity]);
useEffect(() => {
const buffer = adapter.current;
if (!buffer) return undefined;
setError(null);
if (!request) {
setState('idle');
return undefined;
}
setState('connecting');
let subscription: LiveSubscription | null = null;
const handle = (event: LiveEvent) => {
if (event.type === 'samples') {
buffer.append(event.samples);
setState('live');
if (!frame.current) frame.current = setTimeout(() => { frame.current = null; render((value) => value + 1); }, 16);
} else if (event.type === 'status') {
if (event.state === 'resync-required') buffer.clear();
setState(event.state === 'subscribed' ? 'live' : event.state === 'unsubscribed' ? 'idle' : 'connecting');
if (event.state === 'resync-required') setError(null);
} else {
setState('error');
setError(event.message);
}
};
subscription = client.subscribe(request, handle);
// Series keys that stopped reporting must not accumulate for the lifetime of
// a wallboard session.
const sweep = setInterval(() => { buffer.evictStale(); }, 60000);
return () => {
subscription?.unsubscribe();
clearInterval(sweep);
if (frame.current) clearTimeout(frame.current);
frame.current = null;
buffer.clear();
client.releaseUnused();
};
}, [client, requestKey, capacity]);
const current = adapter.current;
return { state, pointCount: current?.pointCount ?? 0, series: current?.snapshot() ?? [], error };
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { MetricApiError, MetricClient, type MetricQueryRequest, type MetricQueryResponse } from './metricClient';
type MetricQueryState = { status: 'idle' | 'loading' | 'success' | 'error'; response: MetricQueryResponse | null; error: MetricApiError | null };
const idle: MetricQueryState = { status: 'idle', response: null, error: null };
export function useMetricQuery(client: MetricClient, request: MetricQueryRequest | null): MetricQueryState {
const [state, setState] = useState<MetricQueryState>(idle);
const requestKey = request ? JSON.stringify(request) : '';
useEffect(() => {
if (!request) { setState(idle); return undefined; }
const controller = new AbortController();
setState({ status: 'loading', response: null, error: null });
client.queryRange(request, controller.signal).then((response) => {
if (!controller.signal.aborted) setState({ status: 'success', response, error: null });
}).catch((error: unknown) => {
if (controller.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) return;
setState({ status: 'error', response: null, error: error instanceof MetricApiError ? error : new MetricApiError(0) });
});
return () => controller.abort();
}, [client, requestKey]);
return state;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+28
View File
@@ -0,0 +1,28 @@
export const wallboardColumns = 24;
export const wallboardRowsPerSlide = 13;
export type WallboardPlacement = { columnStart: number; columnSpan: number; rowStart: number; rowSpan: number };
function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(maximum, Math.max(minimum, Math.floor(number)));
}
export function wallboardSlideIndex(y: unknown): number {
const row = boundedInteger(y, 0, 0, Number.MAX_SAFE_INTEGER);
return Math.floor(row / wallboardRowsPerSlide);
}
export function wallboardPlacement(layout: Record<string, unknown>): WallboardPlacement {
const columnStart = boundedInteger(layout.x, 0, 0, wallboardColumns - 1) + 1;
const row = boundedInteger(layout.y, 0, 0, Number.MAX_SAFE_INTEGER) % wallboardRowsPerSlide;
const requestedWidth = boundedInteger(layout.w, 6, 1, wallboardColumns);
const requestedHeight = boundedInteger(layout.h, 4, 1, wallboardRowsPerSlide);
return {
columnStart,
columnSpan: Math.min(requestedWidth, wallboardColumns - columnStart + 1),
rowStart: row + 1,
rowSpan: Math.min(requestedHeight, wallboardRowsPerSlide - row),
};
}
+129
View File
@@ -0,0 +1,129 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import path from 'node:path';
const generatedAt = new Date().toISOString();
async function mockAPI(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/api/v1/system/status') {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
version: '1', generatedAt, overallState: 'degraded',
components: [{ id: 'database', state: 'healthy', reason: 'ok' }, { id: 'prometheus', state: 'unknown', reason: 'source_stale' }],
backup: { state: 'disabled', reason: 'not_configured' },
sourceLag: [{ sourceId: 'reverse-proxy', state: 'unknown', reason: 'source_unavailable', ageSeconds: 180 }],
}),
});
return;
}
if (path === '/api/v1/dashboards') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
}
if (path === '/api/v1/host') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({
identity: { name: 'mobile-fixture' },
cpu: { totalPercent: 42, perCore: Array.from({ length: 16 }, (_, index) => index + 1) },
memory: { utilizationPercent: 61 }, source: { state: 'healthy', freshness: 'fresh' },
}) });
return;
}
if (path === '/api/v1/containers') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { state: 'healthy', freshness: 'fresh' }, containers: [{ id: 'proxy', state: 'running', health: 'healthy' }], total: 1 }) });
return;
}
if (path === '/api/v1/pools') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { state: 'healthy', freshness: 'fresh' }, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', utilizationPercent: 63 }], total: 1 }) });
return;
}
if (path === '/api/v1/services') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'proxy', name: 'Proxy', state: 'up' }], total: 1 }) });
return;
}
if (path === '/api/v1/incidents') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [{ id: 'incident-1', title: 'Bronvertraging', severity: 'warning', startedAt: generatedAt }] }) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: JSON.stringify({ code: 'NOT_FOUND' }) });
});
}
async function expectNoCriticalA11yViolations(page: Page): Promise<void> {
const results = await new AxeBuilder({ page }).analyze();
const severe = results.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
}
test.beforeEach(async ({ page }) => {
await mockAPI(page);
});
test('overview exposes degraded/unknown state and passes axe', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard has a dedicated route test.');
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText(/Onbekend|Verminderd/).first()).toBeVisible();
if (testInfo.project.name === 'mobile-chromium') {
await expect(page.locator('.desktop-navigation')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toHaveCSS('position', 'fixed');
await expect(page.locator('.mobile-primary-list .nav-link')).toHaveCount(4);
const mobileTargets = await page.locator('.mobile-primary-list .nav-link').evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
expect(mobileTargets.every((height) => height >= 44)).toBe(true);
await expect(page.locator('.signal-path-stage')).toHaveCount(6);
await expect(page.locator('.signal-path-inspector')).toBeVisible();
const incidentBox = await page.locator('.incident-queue').boundingBox();
const signalBox = await page.locator('.signal-path-panel').boundingBox();
expect(incidentBox?.y).toBeLessThan(signalBox?.y ?? 0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
} else {
await expect(page.locator('.desktop-navigation')).toBeVisible();
const rail = await page.locator('.sidebar').boundingBox();
const commandHeader = await page.locator('.context-bar').boundingBox();
expect(rail?.width).toBeLessThanOrEqual(72);
expect(commandHeader?.height).toBe(56);
await expect(page.locator('.overview-kpi')).toHaveCount(4);
await expect(page.locator('.source-health-strip')).toHaveAttribute('tabindex', '0');
await expect(page.locator('.nav-group')).toHaveCount(6);
await expect(page.locator('.nav-group').first()).toHaveAttribute('open', '');
await expect(page.locator('.nav-group').filter({ hasText: 'Infrastructuur' })).not.toHaveAttribute('open', '');
}
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toBeVisible();
await expectNoCriticalA11yViolations(page);
if (process.env.PULSE_E2E_REAL_BASE_URL || process.env.PULSE_CAPTURE_VISUALS) {
const evidenceDirectory = process.env.PULSE_CAPTURE_VISUALS ? 'M14-02' : 'M11-10';
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await page.screenshot({ path: path.resolve('../../artifacts/evidence', evidenceDirectory, `overview-${testInfo.project.name}.png`), fullPage: true });
}
});
test('grouped navigation reaches infrastructure in two actions', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard heeft geen productnavigatie.');
await page.goto('/');
if (testInfo.project.name === 'mobile-chromium') {
await page.getByText('Meer', { exact: true }).click();
await page.getByRole('link', { name: /Disks/ }).click();
} else {
await page.locator('.nav-group').filter({ hasText: 'Infrastructuur' }).locator('summary').click();
await page.getByRole('link', { name: /Disks/ }).click();
}
await expect(page).toHaveURL(/\/disks$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
test('wallboard remains read-only, bounded and accessible', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'wallboard-chromium', 'Wallboard is verified at 1920x1080.');
await page.goto('/wallboard');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText(/Geen dashboards|Wallboard/).first()).toBeVisible();
await expect(page.locator('.sidebar')).toHaveCount(0);
await expect(page.getByRole('button', { name: /Bewerken|Exporteren/ })).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1)).toBe(true);
await expectNoCriticalA11yViolations(page);
});
@@ -0,0 +1,94 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const alerts = Array.from({ length: 25 }, (_, index) => ({
id: `alert-${String(index + 1).padStart(2, '0')}`,
state: index === 1 ? 'acknowledged' : 'firing',
retainedState: 'firing',
ruleName: `Melding ${String(index + 1).padStart(2, '0')}`,
severity: index % 5 === 0 ? 'critical' : 'attention',
entityName: index % 2 === 0 ? 'Tower' : 'Database',
reason: 'threshold_exceeded',
revision: index + 1,
updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
}));
test('alertwerkruimte prioriteert operatie en opent configuratie doelgericht', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'De alertwerkruimte gebruikt de desktop-, tablet- en mobiele shell.');
let operationHeaders: Record<string, string> | undefined;
await page.route('**/api/v1/**', async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/system/status') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'test', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) });
return;
}
if (pathname === '/api/v1/alert-rules') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
}
if (pathname === '/api/v1/metrics/catalog') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ metrics: [{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }] }) });
return;
}
if (pathname === '/api/v1/alerts' && request.method() === 'GET') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: alerts }) });
return;
}
if (pathname.startsWith('/api/v1/alerts/') && request.method() === 'POST') {
operationHeaders = request.headers();
await route.fulfill({ contentType: 'application/json', body: '{}' });
return;
}
if (pathname === '/api/v1/alert-silences' || pathname === '/api/v1/maintenance-windows') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
});
await page.goto('/alerts');
await expect(page.getByRole('heading', { name: 'Meldingen en incidenten' })).toBeVisible();
await expect(page.getByRole('button', { name: /Actief 25/ })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: /Kritiek actief 5/ })).toBeVisible();
const rows = page.locator('.alert-operation-list-items > li');
await expect(rows).toHaveCount(20);
await expect(rows.first()).toContainText('Kritiek');
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
const acknowledge = page.getByRole('button', { name: 'Erkennen' }).first();
page.once('dialog', (dialog) => dialog.dismiss());
await acknowledge.click();
expect(operationHeaders).toBeUndefined();
page.once('dialog', (dialog) => dialog.accept());
await acknowledge.click();
await expect.poll(() => operationHeaders?.['if-match']).toBe('1');
expect(operationHeaders?.['idempotency-key']).toBeTruthy();
await page.getByRole('button', { name: /Alertregels Detectie en drempels/ }).click();
await expect(page).toHaveURL(/section=rules/);
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Actieve en recente meldingen' })).toHaveCount(0);
await page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ }).click();
await expect(page).toHaveURL(/section=controls/);
await expect(page.getByRole('heading', { name: 'Tijdelijke onderdrukking en onderhoud' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
await page.reload();
await expect(page.getByRole('heading', { name: 'Tijdelijke onderdrukking en onderhoud' })).toBeVisible();
await expect(page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ })).toHaveAttribute('aria-current', 'page');
await page.getByRole('button', { name: /Actieve meldingen Prioriteiten en erkenning/ }).click();
await expect(page).not.toHaveURL(/section=/);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollHeight / window.innerHeight)).toBeLessThan(10);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
if (testInfo.project.name === 'mobile-chromium') {
const heights = await page.locator('.alert-section-nav button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
expect(heights.every((height) => height >= 44)).toBe(true);
}
if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `alerts-${testInfo.project.name}.png`), fullPage: true });
}
});
+26
View File
@@ -0,0 +1,26 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('persisted capacity history produces one qualified explainable forecast', async ({ page }) => {
test.skip(!enabled, 'Requires an isolated real Pulse stack.');
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
const response = await page.request.get('/api/v1/forecasts');
expect(response.ok()).toBeTruthy();
const snapshot = await response.json() as { qualifiedCount: number; items: Array<{ entityId: string; dataPoints: number; confidence: string; projectedAt?: string }> };
expect(snapshot.qualifiedCount).toBe(1);
expect(snapshot.items[0]).toMatchObject({ dataPoints: 3, confidence: 'medium' });
expect(snapshot.items[0].projectedAt).toBeTruthy();
await page.goto('/capacity');
await expect(page.getByText(/1 gekwalificeerde prognoses/)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Media forecast' })).toBeVisible();
await expect(page.getByText('Mediane dagelijkse groei', { exact: true })).toBeVisible();
await expect(page.getByText('Gemiddelde betrouwbaarheid')).toBeVisible();
await expect(page.getByText('42 dagen', { exact: false })).toBeVisible();
await expect(page.getByText('0 B / 0 B')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
});
@@ -0,0 +1,29 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
const coreRoutes = ['/', '/host', '/containers', '/storage', '/services', '/alerts', '/incidents', '/inventory'];
test('core routes remain accessible, bounded and visually stable', async ({ page }, testInfo) => {
test.skip(!enabled, 'Requires the isolated server-built Pulse stack.');
const errors: string[] = [];
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', (error) => errors.push(error.message));
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
const routes = testInfo.project.name === 'wallboard-chromium' ? ['/wallboard'] : coreRoutes;
for (const route of routes) {
await page.goto(route);
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), `${route} has document overflow`).toBe(true);
if (route === '/wallboard') expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1), `${route} has vertical overflow`).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious'), `${route} axe findings`).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS && (route === '/' || route === '/services' || route === '/wallboard')) {
const name = route === '/' ? 'overview' : route.slice(1);
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-10', `${name}-${testInfo.project.name}.png`), fullPage: true });
}
}
expect(errors, errors.join('\n')).toEqual([]);
});
@@ -0,0 +1,91 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import path from 'node:path';
const dashboard = { id: 'polish-dashboard', slug: 'operations', name: 'Netwerkoperaties', description: 'Actuele netwerkbelasting en operationele wijzigingen.', scope: 'system', revision: 4, currentVersion: 7 };
const widgets = [
{
id: 'network', title: 'Netwerkbelasting', type: 'timeseries',
data: { sourceType: 'semantic-metric', metric: 'host.network.receive', aggregation: 'avg' },
visualization: { unit: 'bytesPerSecond', decimals: 0, legend: true },
behavior: { locked: false, hidden: false, liveIntervalSeconds: 30 },
layouts: { desktop: { x: 0, y: 0, w: 9, h: 5, visible: true } },
},
{
id: 'events', title: 'Recente wijzigingen', type: 'event-timeline',
data: { sourceType: 'events', limit: 12 }, behavior: { locked: false, hidden: false, liveIntervalSeconds: 30 },
layouts: { desktop: { x: 9, y: 0, w: 9, h: 5, visible: true } },
},
];
async function mockDashboard(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const requestPath = new URL(route.request().url()).pathname;
const body = requestPath === '/api/v1/dashboards/polish-dashboard'
? { dashboard, version: { document: { schemaVersion: 2, widgets, variables: [], settings: { defaultTimeRange: '1h' } } } }
: requestPath === '/api/v1/metrics/query-range'
? { status: 'success', data: { result: [{ metric: { __name__: 'host_network_receive', host: 'tower', interface: 'eth0' }, values: [[1786420800, '1200'], [1786420815, '1500']] }] }, provenance: { source: 'prometheus', metric: 'host.network.receive', catalogVersion: '1', cacheKey: 'test' }, sourceObservedAt: '2026-08-11T04:01:00Z', receivedAt: '2026-08-11T04:01:01Z', freshness: 'fresh', cacheHit: false }
: requestPath === '/api/v1/events'
? { items: [{ id: 'event-1', type: 'container.restart', severity: 'warning', summary: 'container.restart', occurredAt: '2026-08-11T04:00:00Z' }] }
: requestPath === '/api/v1/system/status'
? { version: '1', generatedAt: '2026-08-11T04:01:00Z', overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'database_ready' }], backup: { state: 'healthy', reason: 'backup_verified' }, sourceLag: [] }
: {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
}
async function expectNoSeriousAxeViolations(page: Page): Promise<void> {
const results = await new AxeBuilder({ page }).analyze();
const violations = results.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious');
expect(violations, violations.map((item) => `${item.id}: ${item.help}`).join('\n')).toEqual([]);
}
test('dashboard and editor use human labels, safe modes and accessible keyboard controls', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'The editor acceptance proof uses the desktop canvas.');
await mockDashboard(page);
await page.goto('/dashboards/polish-dashboard');
await expect(page.getByRole('heading', { level: 1, name: 'Netwerkoperaties' })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: 'Dashboardwidgets' })).toBeAttached();
await expect(page.getByRole('heading', { level: 3, name: 'Netwerkbelasting' })).toBeVisible();
await expect(page.getByRole('list', { name: 'Legenda' })).toContainText('tower · eth0');
await expect(page.locator('.metric-chart-line')).toHaveAttribute('d', 'M 28.000 192.000 L 628.000 12.000');
await expect(page.getByText('Container herstart', { exact: false })).toBeVisible();
await expect(page.getByText('container.restart', { exact: true })).toHaveCount(0);
await expect(page.locator('body')).not.toContainText('{"__name__"');
await expectNoSeriousAxeViolations(page);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/dashboard-human-labels.png'), fullPage: true });
await page.getByRole('button', { name: 'Bewerken' }).click();
await expect(page.getByRole('heading', { level: 1, name: 'Dashboard aanpassen' })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: 'Dashboardindeling' })).toBeAttached();
const advanced = page.getByText('Dashboardvariabelen, sjablonen en gegevensoverdracht', { exact: true });
await expect(advanced).toBeVisible();
await expect(page.getByRole('heading', { name: 'Import, export en templates' })).toBeHidden();
await expect(page.locator('.editor-widget-actions').first()).toContainText('Omhoog');
await expect(page.locator('.editor-widget-actions').first()).toContainText('Omlaag');
await expect(page.locator('.editor-widget-actions').first()).toContainText('Breedte');
await expect(page.getByText('Weergavemodus')).toHaveCount(0);
const editorWidgets = page.locator('.editor-widget');
await expect(editorWidgets.locator('h3')).toHaveText(['Netwerkbelasting', 'Recente wijzigingen']);
const firstWidget = await editorWidgets.first().boundingBox();
expect(firstWidget).not.toBeNull();
await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 30);
await page.mouse.down();
await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 75, { steps: 4 });
await page.mouse.up();
await expect(editorWidgets.locator('h3')).toHaveText(['Recente wijzigingen', 'Netwerkbelasting']);
const resize = page.getByRole('slider', { name: 'Breedte aanpassen: Netwerkbelasting' });
await expect(resize).toHaveAttribute('aria-valuenow', '9');
await resize.focus();
await page.keyboard.press('ArrowRight');
await expect(resize).toHaveAttribute('aria-valuenow', '10');
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-keyboard-and-disclosure.png'), fullPage: true });
await advanced.click();
await expect(page.getByRole('heading', { level: 2, name: 'Import, export en templates' })).toBeVisible();
await expectNoSeriousAxeViolations(page);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-advanced-open.png'), fullPage: true });
});
@@ -0,0 +1,40 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('default dashboard and wallboard expose usable real sources', async ({ page }) => {
test.skip(!enabled, 'Run against an isolated server smoke stack.');
test.setTimeout(90_000);
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
const failures: string[] = [];
page.on('pageerror', (error) => failures.push(error.message));
page.on('response', (response) => {
const path = new URL(response.url()).pathname;
if (path.startsWith('/api/') && response.status() >= 400) failures.push(`${response.status()} ${path}`);
});
await page.goto('/dashboards/11111111-1111-4111-8111-111111111111');
await expect(page.getByRole('heading', { level: 1, name: 'Overzicht' })).toBeVisible();
await expect(page.locator('.widget-card--runtime')).toHaveCount(5);
await expect(page.locator('.widget-placeholder')).toHaveCount(0);
await expect.poll(() => failures, { timeout: 5_000 }).toEqual([]);
await expect(page.locator('.metric-chart')).toBeVisible();
await expect(page.getByText('Disk 1').first()).toBeVisible();
await expect(page.getByText('pulse', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Inventaris succesvol bijgewerkt')).toBeVisible();
await expect(page.locator('.widget-card--runtime[data-runtime-state="usable"]')).toHaveCount(5);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
expect((await new AxeBuilder({ page }).analyze()).violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
await page.goto('/wallboard?refresh=300&interval=300');
await expect(page.getByRole('heading', { name: 'Operationeel wallboard' })).toBeVisible();
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden');
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1)).toBe(true);
await expect(page.locator('.metric-chart')).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
expect((await new AxeBuilder({ page }).analyze()).violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
expect(failures).toEqual([]);
});
+74
View File
@@ -0,0 +1,74 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const items = Array.from({ length: 100 }, (_, index) => ({
id: `event-${String(index + 1).padStart(3, '0')}`,
type: index % 2 === 0 ? 'service.down' : 'container.restart',
severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
entityId: `entity-${String(index % 5).padStart(2, '0')}`,
sourceId: 'source-unraid',
occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
}));
test('100 events blijven compact, filterbaar en toetsenbordnavigeerbaar', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'De eventwerkruimte gebruikt de desktop-, tablet- en mobiele shell.');
let eventRequests = 0;
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/api/v1/events') {
eventRequests += 1;
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items }) });
return;
}
if (pathname === '/api/v1/system/status') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
});
await page.goto('/events');
await expect(page.getByRole('heading', { name: 'Gebeurtenissen' })).toBeVisible();
const rows = page.locator('.event-list > li');
await expect(rows).toHaveCount(20);
const baselineRequests = eventRequests;
expect(baselineRequests).toBeLessThanOrEqual(2);
const criticalSummary = page.getByRole('button', { name: /Kritieke gebeurtenissen/i });
await expect(criticalSummary).toBeVisible();
await expect(criticalSummary).toContainText('10');
expect(await page.evaluate(() => document.documentElement.scrollHeight / window.innerHeight)).toBeLessThan(10);
await page.getByLabel('Ernst').selectOption('critical');
await expect(rows).toHaveCount(10);
await page.getByLabel('Soort').selectOption('service.down');
await expect(rows).toHaveCount(10);
await page.getByLabel('Onderdeel').selectOption('entity-00');
await expect(rows).toHaveCount(10);
await page.getByLabel('Zoeken').fill('Gebeurtenis 091');
await expect(rows).toHaveCount(1);
expect(eventRequests).toBe(baselineRequests);
await page.getByRole('button', { name: 'Filters wissen' }).click();
const next = page.getByRole('button', { name: 'Volgende pagina' });
await next.focus();
await page.keyboard.press('Enter');
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
await expect(page).toHaveURL(/page=2/);
await expect(rows).toHaveCount(20);
await expect(rows.first()).toContainText('event-021');
expect(eventRequests).toBe(baselineRequests);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
if (testInfo.project.name === 'mobile-chromium') {
const pagerHeights = await page.locator('.list-pager .button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
expect(pagerHeights.every((height) => height >= 44)).toBe(true);
}
if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `events-${testInfo.project.name}.png`), fullPage: true });
}
});
+30
View File
@@ -0,0 +1,30 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('real inventory exposes effective overrides, provenance and relations', async ({ page }, testInfo) => {
test.skip(!enabled, 'Requires the isolated real PostgreSQL stack and inventory fixture.');
const login = await page.request.get('/auth/test-login');
expect(login.ok()).toBeTruthy();
await page.goto('/inventory');
await page.getByRole('searchbox', { name: 'Zoeken' }).fill('pulse-api');
const entity = page.getByRole('link', { name: /Pulse API · handmatig/ });
await expect(entity).toBeVisible();
await expect(entity).toContainText('1 bronnen · 2 feiten · 1 relaties · 2 correcties');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
await entity.click();
await expect(page.getByRole('heading', { level: 1, name: 'Pulse API · handmatig' })).toBeVisible();
await expect(page.getByText('itworx/pulse:pinned', { exact: true })).toBeVisible();
await expect(page.getByText('Handmatige correctie').first()).toBeVisible();
await expect(page.getByText('Verouderd', { exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: /PostgreSQL.*depends_on.*bevestigd/ })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
const violations = await new AxeBuilder({ page }).analyze();
expect(violations.violations.filter((item) => ['serious', 'critical'].includes(item.impact ?? ''))).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-05', `inventory-${testInfo.project.name}.png`), fullPage: true });
});
+104
View File
@@ -0,0 +1,104 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const containers = Array.from({ length: 150 }, (_, index) => ({
id: `container-${String(index + 1).padStart(3, '0')}`,
name: `container-${String(index + 1).padStart(3, '0')}`,
image: 'example/pulse:read-only', state: index % 17 === 0 ? 'exited' : 'running', health: index % 13 === 0 ? 'unhealthy' : 'healthy',
intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 3600, restartCount: 0, exitCode: 0,
cpuPercent: index / 10, memoryBytes: 1024 * (index + 1), memoryLimitBytes: 1024 * 1024,
networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0,
}));
const processes = Array.from({ length: 60 }, (_, index) => ({ pid: index + 1, name: `worker-${String(index + 1).padStart(2, '0')}`, state: 'running', runtimeSeconds: 300, cpuPercent: index, memoryBytes: 2048 + index, containerName: index % 2 ? 'pulse' : 'database' }));
const entities = Array.from({ length: 60 }, (_, index) => ({ id: `entity-${index + 1}`, entityType: 'container', canonicalName: `container.${index + 1}`, displayName: `Entity ${String(index + 1).padStart(2, '0')}`, status: 'operational', factCount: 2, overrideCount: 0, relationCount: 1, sourceCount: 1, staleFactCount: 0 }));
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/containers?**', async (route) => {
const url = new URL(route.request().url());
const query = (url.searchParams.get('q') ?? '').toLowerCase();
const state = url.searchParams.get('state') ?? '';
const health = url.searchParams.get('health') ?? '';
const after = Number(url.searchParams.get('after') ?? '0');
const limit = Number(url.searchParams.get('limit') ?? '25');
const filtered = containers.filter((item) => (!query || item.name.includes(query)) && (!state || item.state === state) && (!health || item.health === health));
const items = filtered.slice(after, after + limit);
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { id: 'target-scale', state: 'healthy' }, total: filtered.length, containers: items, nextCursor: after + limit < filtered.length ? String(after + limit) : '' }) });
});
await page.route('**/api/v1/processes?**', async (route) => {
const url = new URL(route.request().url());
const query = (url.searchParams.get('q') ?? '').toLowerCase();
const container = (url.searchParams.get('container') ?? '').toLowerCase();
const after = Number(url.searchParams.get('after') ?? '0');
const filtered = processes.filter((item) => (!query || item.name.includes(query)) && (!container || item.containerName.includes(container)));
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { id: 'target-scale', state: 'healthy' }, total: filtered.length, processes: filtered.slice(after, after + 25), nextCursor: after + 25 < filtered.length ? String(after + 25) : '' }) });
});
await page.route('**/api/v1/entities?**', async (route) => {
const url = new URL(route.request().url());
const query = (url.searchParams.get('q') ?? '').toLowerCase();
const after = Number(url.searchParams.get('after') ?? '0');
const filtered = entities.filter((item) => !query || (item.displayName + item.canonicalName).toLowerCase().includes(query));
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: filtered.slice(after, after + 25), hasMore: after + 25 < filtered.length, nextCursor: after + 25 < filtered.length ? String(after + 25) : '' }) });
});
});
test('process and inventory filters survive navigation with mobile-first cards', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
await page.goto('/processes?q=worker&container=pulse&sort=memory');
await expect(page.getByRole('heading', { name: 'Topprocessen' })).toBeVisible();
await expect(page.getByLabel('Zoeken')).toHaveValue('worker');
await expect(page.getByLabel('Container')).toHaveValue('pulse');
await expect(page).toHaveURL(/sort=memory/);
if (testInfo.project.name === 'mobile-chromium') await expect(page.locator('.mobile-data-list > li:visible')).toHaveCount(25);
else await expect(page.locator('.desktop-data-view tbody tr:visible')).toHaveCount(25);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
await page.goto('/inventory?q=Entity&type=container&status=operational&order=desc');
await expect(page.getByRole('heading', { name: 'Wat Pulse kan zien' })).toBeVisible();
await expect(page.getByLabel('Zoeken')).toHaveValue('Entity');
await expect(page.getByLabel('Type')).toHaveValue('container');
await expect(page.getByRole('textbox', { name: 'Status' })).toHaveValue('operational');
await expect(page.locator('.inventory-entity-list > li')).toHaveCount(25);
await page.getByRole('button', { name: 'Volgende pagina' }).click();
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
await expect(page).toHaveURL(/after=25/);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
test('all 150 containers remain reachable with shareable filters and bounded mobile cards', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
const started = Date.now();
await page.goto('/containers');
await expect(page.getByRole('heading', { name: 'Containers' })).toBeVisible();
const visibleRows = testInfo.project.name === 'mobile-chromium' ? page.locator('.mobile-data-list > li:visible') : page.locator('.desktop-data-view tbody tr:visible');
await expect(visibleRows).toHaveCount(25);
expect(Date.now() - started).toBeLessThan(3000);
const seen = new Set<string>();
for (let pageNumber = 1; pageNumber <= 6; pageNumber += 1) {
await expect(visibleRows).toHaveCount(25);
for (const value of await visibleRows.locator('a').allTextContents()) seen.add(value.trim());
if (pageNumber < 6) {
const next = page.getByRole('button', { name: 'Volgende pagina' });
await next.click();
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
}
}
expect(seen.size).toBe(150);
expect(seen.has('container-150')).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
if (testInfo.project.name === 'mobile-chromium') {
const controls = await page.locator('.list-pager button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
expect(controls.every((height) => height >= 44)).toBe(true);
}
await page.getByLabel('Zoeken').fill('container-150');
await expect(visibleRows).toHaveCount(1);
await expect(page).toHaveURL(/q=container-150/);
await page.reload();
await expect(visibleRows).toHaveCount(1);
await expect(visibleRows.getByText('container-150')).toBeVisible();
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-08', `large-containers-${testInfo.project.name}.png`), fullPage: true });
});
@@ -0,0 +1,55 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const rule = {
id: '20000000-0000-4000-8000-000000000001', schemaVersion: 1, name: 'Hoge hostbelasting', enabled: true, severity: 'critical', scope: {},
condition: { inputType: 'metric', metric: 'host.cpu.utilization', operator: '>', threshold: 90, recoveryThreshold: 80, aggregation: 'avg', windowSeconds: 60 },
evaluationIntervalSeconds: 30, pendingSeconds: 60, resolveSeconds: 120, cooldownSeconds: 300,
unknownBehavior: 'retain-firing-as-unknown', groupBy: [], suppressWhen: ['host.unreachable'],
message: { titleKey: 'alerts.rule.title', bodyKey: 'alerts.rule.body' }, revision: 1, currentVersion: 1,
};
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/system/status', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'test', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) }));
await page.route('**/api/v1/alert-rules?**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [rule] }) }));
await page.route('**/api/v1/metrics/catalog', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ metrics: [{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }] }) }));
await page.route('**/api/v1/alert-silences**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
await page.route('**/api/v1/maintenance-windows**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
await page.route('**/api/v1/alerts?**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
});
test('alert editor uses Dutch guided choices and rejects an invalid draft', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'De alert-editor is geen wallboardroute.');
await page.goto('/alerts');
await expect(page.getByRole('heading', { name: 'Meldingen en incidenten' })).toBeVisible();
await page.getByRole('button', { name: /Alertregels Detectie en drempels/ }).click();
await expect(page.getByText('Kritiek · v1')).toBeVisible();
await expect(page.getByRole('combobox', { name: /Meting/ })).toHaveValue('host.cpu.utilization');
await expect(page.getByRole('option', { name: 'CPU-gebruik van de host (%)' })).toBeAttached();
await expect(page.getByText('host.cpu.utilization')).toHaveCount(0);
await expect(page.getByText('host.unreachable')).not.toBeVisible();
await page.getByRole('button', { name: 'Nieuwe regel' }).click();
const save = page.getByRole('button', { name: 'Regel opslaan' });
await expect(save).toBeDisabled();
await page.locator('#alert-rule-name').fill('CPU-waarschuwing');
await page.getByRole('combobox', { name: /Meting/ }).selectOption('host.cpu.utilization');
await expect(save).toBeEnabled();
await page.locator('#alert-rule-recovery-threshold').fill('90');
await expect(save).toBeDisabled();
await page.getByRole('combobox', { name: /Signaalbron/ }).selectOption('event');
await expect(page.getByRole('combobox', { name: /Meting/ })).toHaveCount(0);
await expect(page.locator('#alert-rule-threshold')).toHaveValue('3');
await expect(save).toBeEnabled();
await page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ }).click();
await expect(page.locator('#silence-matcher')).toHaveValue('critical');
await expect(page.locator('#silence-matcher')).toContainText('Kritiek');
await expect(page.locator('#maintenance-selector')).toContainText('Host');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-09', `alert-editor-${testInfo.project.name}.png`), fullPage: true });
});
@@ -0,0 +1,85 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const systemStatus = {
version: '1.3.0',
release: { version: '1.3.0', commit: 'abc1234', builtAt: '2026-08-21T12:00:00Z', migrationVersion: '0024' },
generatedAt: new Date().toISOString(),
overallState: 'healthy',
components: [],
backup: { state: 'healthy', reason: 'backup_verified', ageSeconds: 30 * 60 * 60, verifiedAt: '2026-08-20T06:00:00Z' },
sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh', ageSeconds: 5 }],
};
const onboarding = {
state: { completed: true, step: 'completed', dashboardChoice: 'default', rulesChoice: 'default', dashboardId: 'overview', rulesReady: true },
capabilities: [
{ id: 'auth', state: 'ready', detail: 'Aanmelding geconfigureerd.' },
{ id: 'database', state: 'ready', detail: 'Database beschikbaar.' },
{ id: 'prometheus', state: 'ready', detail: 'Meetgegevens beschikbaar.' },
{ id: 'unraid', state: 'ready', detail: 'Unraid-bron beschikbaar.' },
],
resume: false,
};
test('beheerhub, afgeronde onboarding en backupouderdom blijven taakgericht en waarheidsgetrouw', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Beheerflows gebruiken de desktop-, tablet- en mobiele shell.');
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/api/v1/system/status') {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 403, contentType: 'application/problem+json', body: '{}' });
return;
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ...systemStatus, generatedAt: new Date().toISOString() }) });
return;
}
if (pathname === '/api/v1/onboarding') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(onboarding) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
});
await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'Pulse configureren' })).toBeVisible();
const hub = page.getByRole('region', { name: 'Beheerfuncties' });
await expect(hub.getByRole('link')).toHaveCount(6);
await expect(hub.getByRole('link', { name: /Systeemstatus en backup/ })).toHaveAttribute('href', '/status');
await expect(hub.getByRole('link', { name: /Eerste configuratie/ })).toHaveAttribute('href', '/onboarding');
await expect(hub.getByRole('link', { name: /Alertregels/ })).toHaveAttribute('href', '/alerts?section=rules');
await expect(hub.getByRole('link', { name: /Stiltes en onderhoud/ })).toHaveAttribute('href', '/alerts?section=controls');
await expect(hub.getByText('Backupactie: beheerder')).toBeVisible();
await expect(hub.getByText('Wijzigen: operator')).toBeVisible();
if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `settings-${testInfo.project.name}.png`), fullPage: true });
}
await hub.getByRole('link', { name: /Systeemstatus en backup/ }).click();
const backup = page.getByRole('article').filter({ has: page.getByText('Backupstatus', { exact: true }) });
await expect(page.getByRole('heading', { name: 'Pulse-systeemstatus' })).toBeVisible();
await expect(backup.locator('.status-badge')).toContainText('Aandacht');
await expect(backup.getByText(/backup is verlopen/i)).toBeVisible();
await expect(backup.getByText(/1 dag geleden/)).toBeVisible();
await expect(backup.getByText(/ouder dan 24 uur/)).toBeVisible();
await backup.getByRole('button', { name: 'Maak geverifieerde backup' }).click();
await expect(backup.getByRole('alert')).toContainText('Alleen beheerders');
await page.goto('/settings');
await page.getByRole('region', { name: 'Beheerfuncties' }).locator('a[href="/onboarding"]').click();
await expect(page.getByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
await expect(page.getByRole('radio')).toHaveCount(0);
await page.getByRole('button', { name: 'Herconfiguratie openen' }).click();
await expect(page.getByRole('radio')).toHaveCount(4);
await page.getByRole('button', { name: 'Annuleren' }).click();
await expect(page.getByRole('radio')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
if (testInfo.project.name === 'mobile-chromium') {
const linkHeights = await page.locator('.settings-hub-card a').evaluateAll((links) => links.map((link) => link.getBoundingClientRect().height));
expect(linkHeights.every((height) => height >= 44)).toBe(true);
}
});
@@ -0,0 +1,39 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const now = new Date().toISOString();
test('mobile incident command mode is prioritized, bounded and accessible', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile incident mode uses the supported 390x844 viewport.');
await page.route('**/api/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname;
const body = path === '/api/v1/system/status' ? {
version: '1', generatedAt: now, overallState: 'degraded', components: [],
backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
} : path === '/api/v1/incidents/incident-1' ? {
incident: {
id: 'incident-1', correlationKey: 'cachepool', title: 'Cachepool bijna vol',
summary: 'Cachepool is 92% gebruikt en groeit sneller dan verwacht.', severity: 'critical',
status: 'open', startedAt: now, correlationMethod: 'temporal-window', confidence: .84,
revision: 2, updatedAt: now, ownerUserId: '', alerts: [
{ alertId: 'alert-1', rationale: 'Capaciteitsdrempel van 90% overschreden', confidence: .84, correlationMethod: 'temporal-window', manual: false, createdAt: now },
], notes: [{ id: 'note-1', incidentId: 'incident-1', author: 'Pulse', body: 'Mover is niet actief.', createdAt: now }],
},
} : {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/incidents/incident-1');
await expect(page.getByRole('heading', { level: 1, name: 'Cachepool bijna vol' })).toBeVisible();
await expect(page.locator('.incident-command-strip')).toContainText('Kritiek');
await expect(page.locator('.incident-command-strip')).toContainText('84%');
await expect(page.locator('.incident-timeline')).toContainText('Capaciteitsdrempel van 90% overschreden');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
const targets = await page.locator('button, .mobile-navigation a, .mobile-more summary').evaluateAll((items) => items.filter((item) => {
const style = getComputedStyle(item); return style.display !== 'none' && style.visibility !== 'hidden';
}).map((item) => item.getBoundingClientRect().height));
expect(targets.every((height) => height >= 44)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: '../../artifacts/evidence/M14-04/mobile-incident-command.png', fullPage: true });
});
+198
View File
@@ -0,0 +1,198 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('real stack serves collector data through database, API and UI', async ({ page }) => {
test.skip(!enabled, 'Run through scripts/integration-smoke.ps1 with an isolated real stack.');
test.setTimeout(210_000);
const login = await page.request.get('/auth/test-login');
expect(login.ok()).toBeTruthy();
const issuedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'pulse_session');
expect(issuedSession, 'mock login issues the same HttpOnly session contract as OIDC').toBeDefined();
expect(issuedSession?.httpOnly).toBe(true);
for (const endpoint of ['/healthz', '/readyz']) {
const response = await page.request.get(endpoint);
expect(response.status(), endpoint).toBe(200);
expect(response.headers()['content-type'], endpoint).toContain('text/plain');
expect((await response.text()).toLowerCase(), endpoint).not.toContain('<!doctype html>');
}
for (const endpoint of ['/metrics', '/debug/pprof/']) {
const response = await page.request.get(endpoint);
expect(response.status(), endpoint).toBe(404);
expect((await response.text()).toLowerCase(), endpoint).not.toContain('<!doctype html>');
}
const endpointChecks = [
'/api/v1/system/status', '/api/v1/host', '/api/v1/processes?limit=10',
'/api/v1/containers?limit=10', '/api/v1/array', '/api/v1/disks?limit=10',
'/api/v1/pools?limit=10', '/api/v1/shares?limit=10', '/api/v1/services?limit=10',
'/api/v1/network', '/api/v1/topology?limit=10', '/api/v1/applications?limit=10', '/api/v1/events?limit=10',
'/api/v1/entities?limit=10', '/api/v1/dashboards?limit=10', '/api/v1/alert-rules?limit=10',
'/api/v1/alerts?limit=10', '/api/v1/incidents?limit=10', '/api/v1/onboarding',
];
for (const endpoint of endpointChecks) {
const response = await page.request.get(endpoint);
expect(response.status(), endpoint).toBe(200);
}
const hostResponse = await page.request.get('/api/v1/host');
const host = await hostResponse.json() as Record<string, unknown>;
expect(JSON.stringify(host)).toContain('smoke-host');
expect(JSON.stringify(host)).toContain('fresh');
const containers = await (await page.request.get('/api/v1/containers?limit=10')).json() as { containers?: Array<{ name: string; state: string; health: string; metricsAvailable?: boolean; lifecycleAvailable?: boolean }> };
const smokeContainer = containers.containers?.find((item) => item.name === 'smoke-api');
expect(smokeContainer).toMatchObject({ state: 'running', health: 'healthy', metricsAvailable: false, lifecycleAvailable: false });
const applications = await (await page.request.get('/api/v1/applications?limit=10')).json() as { applications?: Array<{ name: string; status: string }> };
expect(applications.applications?.find((item) => item.name === 'smoke')).toMatchObject({ status: 'healthy' });
await page.goto('/containers');
await expect(page.getByText(/metingen niet beschikbaar/).first()).toBeVisible();
const systemStatus = await (await page.request.get('/api/v1/system/status')).json() as {
components?: Array<{ id: string; state: string; reason: string }>;
sourceLag?: Array<{ sourceId: string; state: string }>;
};
const unraidComponent = systemStatus.components?.find((item) => item.id === 'unraid');
expect(unraidComponent, 'unraid runtime component').toBeDefined();
expect(unraidComponent?.state, 'unraid is derived from every fresh bounded agent capability').toBe('healthy');
expect(unraidComponent?.reason, 'unraid no longer uses API-local configuration').toBe('source_sampled');
const storageComponent = systemStatus.components?.find((item) => item.id === 'storage');
expect(storageComponent, 'storage runtime component').toMatchObject({ state: 'healthy', reason: 'source_sampled' });
expect(systemStatus.sourceLag?.find((source) => source.sourceId === 'unraid')?.state).toBe('healthy');
const onboarding = await (await page.request.get('/api/v1/onboarding')).json() as { capabilities?: Array<{ id: string; state: string }> };
expect(onboarding.capabilities?.find((capability) => capability.id === 'unraid')?.state).toBe('ready');
const dashboardId = '61000000-0000-4000-8000-000000000001';
const dashboard = {
schemaVersion: 2, id: dashboardId, slug: 'real-stack-metric', name: 'Real-stack CPU', description: 'Metric planner browser gate.', scope: 'system',
variables: [{ name: 'server', type: 'server', label: 'Server', default: 'smoke-host' }],
widgets: [{
id: '62000000-0000-4000-8000-000000000001', type: 'timeseries', title: 'CPU live', description: 'Catalog-bounded metric.',
data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: '$server' }, aggregation: 'avg', transformations: [] },
visualization: { unit: 'percent', decimals: 1, legend: true, showSparkline: false, min: 0, max: 100, thresholds: [] },
behavior: { locked: true, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, liveIntervalSeconds: 2, independentTimeRange: null },
layouts: { desktop: { x: 0, y: 0, w: 18, h: 8, visible: true }, tablet: { x: 0, y: 0, w: 8, h: 8, visible: true }, mobile: { x: 0, y: 0, w: 1, h: 8, visible: true }, wallboard: { x: 0, y: 0, w: 24, h: 12, visible: true } },
}],
settings: { defaultTimeRange: 'live', live: true, refreshSeconds: 10, rotationSeconds: 30 },
};
const dashboardResponse = await page.request.post('/api/v1/dashboards', { data: dashboard });
expect([201, 409], await dashboardResponse.text()).toContain(dashboardResponse.status());
const peerDashboard = {
...dashboard,
id: '61000000-0000-4000-8000-000000000002',
slug: 'real-stack-memory',
name: 'Real-stack geheugen',
widgets: dashboard.widgets.map((widget) => ({
...widget,
id: '62000000-0000-4000-8000-000000000002',
title: 'Geheugen live',
data: { ...widget.data, metric: 'host.memory.utilization' },
})),
};
const peerDashboardResponse = await page.request.post('/api/v1/dashboards', { data: peerDashboard });
expect([201, 409], await peerDashboardResponse.text()).toContain(peerDashboardResponse.status());
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const websocketOpened = await page.evaluate(() => new Promise<boolean>((resolve) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}/api/v1/live`);
const timeout = window.setTimeout(() => { socket.close(); resolve(false); }, 5_000);
socket.addEventListener('open', () => { window.clearTimeout(timeout); socket.close(); resolve(true); }, { once: true });
socket.addEventListener('error', () => { window.clearTimeout(timeout); resolve(false); }, { once: true });
}));
expect(websocketOpened, 'same-origin WebSocket upgrade through nginx').toBe(true);
const runtimeFailures: string[] = [];
page.on('pageerror', (error) => runtimeFailures.push(error.message));
page.on('response', (response) => {
if (new URL(response.url()).pathname.startsWith('/api/') && response.status() >= 400) {
runtimeFailures.push(`${response.status()} ${response.url()}`);
}
});
const routes = ['/', '/host', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/topology', '/network', '/applications', '/inventory', '/dashboards', '/alerts', '/incidents', '/settings', '/status', '/onboarding'];
for (const route of routes) {
await page.goto(route);
await expect(page.getByRole('heading', { level: 1 }).first(), route).toBeVisible();
await expect(page.locator('.state-page[role="alert"]'), route).toHaveCount(0);
}
expect(runtimeFailures).toEqual([]);
await page.addInitScript((id) => window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'), dashboardId);
const metricResponse = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/v1/metrics/query-range');
await page.goto('/dashboards/' + dashboardId);
await expect(page.getByRole('heading', { name: 'Real-stack CPU' })).toBeVisible();
expect((await metricResponse).status(), 'catalog-bounded query through deployed UI').toBe(200);
await expect(page.getByRole('heading', { name: 'CPU live' })).toBeVisible();
expect(runtimeFailures).toEqual([]);
// A rotating wallboard fetches the next dashboard document before its live
// subscription is ready. Exercise a response well beyond the 250 ms
// subscription-release grace and prove the bounded idle transport is reused
// across multiple rotations instead of closing and reopening.
const dashboardList = await (await page.request.get('/api/v1/dashboards?limit=100')).json() as { items?: Record<string, unknown>[] };
const wallboardDashboardIds = (dashboardList.items ?? []).map((item) => String(item.id ?? item.ID ?? '')).filter(Boolean);
expect(wallboardDashboardIds.length).toBeGreaterThanOrEqual(2);
await page.addInitScript(() => {
const NativeWebSocket = window.WebSocket;
const counters = { opened: 0, closed: 0 };
Object.defineProperty(window, '__pulseSocketLifecycle', { value: counters, configurable: true });
class TrackedWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
super(url, protocols);
counters.opened += 1;
this.addEventListener('close', () => { counters.closed += 1; }, { once: true });
}
}
window.WebSocket = TrackedWebSocket;
});
await page.addInitScript((ids) => {
ids.forEach((id) => window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'));
}, wallboardDashboardIds);
let delayedDashboardLoads = 0;
await page.route('**/api/v1/dashboards/**', async (route) => {
delayedDashboardLoads += 1;
await new Promise((resolve) => setTimeout(resolve, 2_000));
try {
await route.continue();
} catch (error) {
// Going offline can settle an intentionally delayed request before the
// handler resumes. That is the failure mode under test, not a harness
// failure; every other routing error must still fail the flow.
if (!(error instanceof Error) || !error.message.includes('Route is already handled')) throw error;
}
});
await page.goto('/wallboard?interval=10&refresh=300');
await expect(page.getByRole('heading', { name: 'Operationeel wallboard' })).toBeVisible();
const visibleDashboard = page.locator('#dashboard-view-title');
await expect(visibleDashboard).toBeVisible();
await page.context().setOffline(true);
// Cross a rotation while both HTTP and WebSocket transport are unavailable.
// The wallboard must retain its last verified document instead of replacing
// operational context with a blank loading/error page.
await page.waitForTimeout(12_000);
await expect(visibleDashboard).toBeVisible();
await expect(visibleDashboard).not.toHaveText('');
await page.context().setOffline(false);
// The integration API has a 30-second idle session TTL. Staying on this page
// for 65 seconds after recovery crosses it more than twice. Dashboard refreshes must renew
// the HttpOnly cookie server-side without reopening the live transport.
await page.waitForTimeout(65_000);
expect(delayedDashboardLoads, 'initial document plus at least five rotations').toBeGreaterThanOrEqual(6);
const renewedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'pulse_session');
expect(renewedSession, 'active wallboard retains its server session').toBeDefined();
expect(renewedSession?.value).toBe(issuedSession?.value);
expect(renewedSession?.expires ?? 0, 'idle deadline was renewed beyond its initial expiry').toBeGreaterThan(issuedSession?.expires ?? 0);
const statusAfterMultipleTTLs = await page.request.get('/api/v1/system/status');
expect(statusAfterMultipleTTLs.status(), 'authenticated API after multiple idle TTLs').toBe(200);
const socketLifecycle = await page.evaluate(() => (window as unknown as { __pulseSocketLifecycle: { opened: number; closed: number } }).__pulseSocketLifecycle);
expect(socketLifecycle).toEqual({ opened: 1, closed: 0 });
expect(runtimeFailures).toEqual([]);
await page.unroute('**/api/v1/dashboards/**');
await page.goto('/host');
await expect(page.getByText('smoke-host').first()).toBeVisible();
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious')).toEqual([]);
});
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import path from 'node:path';
test('release metadata and verified backup are production truthful', async ({ page }, testInfo) => {
test.skip(!process.env.PULSE_E2E_REAL_BASE_URL, 'Requires the isolated server-built Pulse stack.');
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
const created = await page.request.post('/api/v1/system/backups');
expect(created.ok()).toBeTruthy();
const response = await page.request.get('/api/v1/system/status');
expect(response.ok()).toBeTruthy();
const status = await response.json() as {
version: string;
release: { version: string; commit: string; builtAt?: string; migrationVersion: string };
backup: { state: string; reason: string; verifiedAt?: string; ageSeconds?: number };
};
expect(status.version).not.toBe('development');
expect(status.release).toMatchObject({ version: 'm11.11-test', commit: 'm1111testcommit' });
expect(status.release.builtAt).toBe('2026-08-12T03:00:00Z');
expect(status.release.migrationVersion).toMatch(/^\d{4}_.+/);
expect(status.backup).toMatchObject({ state: 'healthy', reason: 'backup_verified' });
expect(status.backup.verifiedAt).toBeTruthy();
expect(status.backup.ageSeconds).toBeGreaterThanOrEqual(0);
await page.goto('/status');
await expect(page.getByText(/m11\.11-test/)).toBeVisible();
await expect(page.getByText(/De backup is geverifieerd/)).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({
path: path.resolve('../../artifacts/evidence/M11-11', `release-backup-${testInfo.project.name}.png`),
fullPage: true,
});
});
@@ -0,0 +1,69 @@
import { expect, test, type Page } from '@playwright/test';
import path from 'node:path';
const observedAt = new Date().toISOString();
async function mockResponsiveData(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
const body = pathname === '/api/v1/system/status' ? {
version: '1', generatedAt: observedAt, overallState: 'degraded',
components: [
{ id: 'database', state: 'healthy', reason: 'ok' },
{ id: 'prometheus', state: 'unknown', reason: 'source_stale' },
],
backup: { state: 'disabled', reason: 'not_configured' },
sourceLag: [{ sourceId: 'prometheus', state: 'unknown', reason: 'source_stale', ageSeconds: 900 }],
} : pathname === '/api/v1/host' ? {
identity: { name: 'responsive-fixture' }, cpu: { totalPercent: 42, perCore: [42, 38] },
memory: { utilizationPercent: 61 }, source: { state: 'healthy', freshness: 'fresh' },
} : pathname === '/api/v1/containers' ? { source: { state: 'healthy', freshness: 'fresh' }, containers: [], total: 0 }
: pathname === '/api/v1/pools' ? { source: { state: 'healthy', freshness: 'fresh' }, pools: [], total: 0 }
: pathname === '/api/v1/services' ? { capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 }
: pathname === '/api/v1/incidents' ? { items: [] }
: {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
}
test.beforeEach(async ({ page }) => mockResponsiveData(page));
test('mobile primary navigation and operational reasons remain readable at 390px', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile evidence uses the supported 390px viewport.');
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const labels = page.locator('.mobile-primary-list .nav-link span:last-child');
await expect(labels).toHaveCount(4);
const metrics = await labels.evaluateAll((nodes) => nodes.map((node) => {
const box = node.getBoundingClientRect();
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, font: Number.parseFloat(getComputedStyle(node).fontSize) };
}));
for (let index = 1; index < metrics.length; index += 1) expect(metrics[index - 1].right).toBeLessThanOrEqual(metrics[index].left);
expect(metrics.every((metric) => metric.font >= 12 && metric.bottom - metric.top <= 16)).toBe(true);
const reason = page.locator('.action-queue small').first();
await expect(reason).toBeVisible();
await expect(reason).toHaveCSS('white-space', 'normal');
const overflow = await page.evaluate(() => [...document.querySelectorAll<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)
.slice(0, 12));
expect(overflow).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-06/responsive-mobile-390.png'), fullPage: true });
});
test('tablet sidebar stays compact and content uses the remaining viewport', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'tablet-chromium', 'Tablet evidence uses the supported 1024px viewport.');
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const sidebar = await page.locator('.sidebar').boundingBox();
const workspace = await page.locator('.app-workspace').boundingBox();
const queue = await page.locator('.action-queue').boundingBox();
const dataPlane = await page.locator('.signal-path-panel').boundingBox();
expect(sidebar?.width).toBeLessThanOrEqual(208);
expect(workspace?.width).toBeGreaterThanOrEqual(816);
expect(queue?.width).toBeGreaterThan(300);
expect(dataPlane?.width).toBeGreaterThan(500);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1024);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-06/responsive-tablet-1024.png'), fullPage: true });
});
@@ -0,0 +1,30 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test.beforeEach(async ({ page }) => {
test.skip(!enabled, 'Requires an isolated real Pulse stack.');
const login = await page.request.get('/auth/test-login');
expect(login.ok()).toBeTruthy();
});
test('real services and inventory topology retain explicit production states', async ({ page }) => {
await page.goto('/services');
await expect(page.getByRole('heading', { name: 'Bereikbaarheid en historie' })).toBeVisible();
await expect(page.getByText('Voor deze service is nog geen probe geconfigureerd.').first()).toBeVisible();
await page.goto('/topology');
await expect(page.getByRole('heading', { name: 'Relaties en services' })).toBeVisible();
await expect(page.getByRole('region', { name: 'Relaties', exact: true }).getByText('Ondersteunt')).toBeVisible();
await expect(page.getByText(/dependency-test-a-/).first()).toBeVisible();
await page.goto('/network');
const dns = page.getByRole('heading', { name: 'DNS' }).locator('../..');
await expect(dns.getByText('Niet geconfigureerd')).toBeVisible();
await expect(dns.getByText('Voor dit signaal is nog geen veilige probe geconfigureerd.')).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
});
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
test('expired wallboard session is revoked once without periodic 401 churn', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'wallboard-chromium', 'The long-running wallboard owns this session boundary.');
let apiRequests = 0;
await page.route('**/api/v1/**', async (route) => {
apiRequests += 1;
await route.fulfill({ status: 401, contentType: 'application/problem+json', body: JSON.stringify({ code: 'UNAUTHENTICATED' }) });
});
await page.goto('/wallboard?interval=10&refresh=10');
await expect(page.getByRole('heading', { level: 1, name: 'Geen toegang' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Aanmelden' })).toBeVisible();
await expect.poll(() => apiRequests).toBeGreaterThan(0);
const boundaryRequests = apiRequests;
// Development StrictMode mounts the four bootstrap readers twice. The
// production bundle issues one batch; neither mode may start a second one.
expect(boundaryRequests).toBeLessThanOrEqual(8);
await page.waitForTimeout(12_000);
expect(apiRequests).toBe(boundaryRequests);
await expect(page.getByRole('heading', { level: 1, name: 'Geen toegang' })).toBeVisible();
});
+130
View File
@@ -0,0 +1,130 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import path from 'node:path';
const observedAt = new Date().toISOString();
async function mockSignalFlow(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
const body = pathname === '/api/v1/system/status' ? {
version: '1', generatedAt: observedAt, overallState: 'degraded',
components: [
{ id: 'database', state: 'healthy', reason: 'ok' },
{ id: 'worker', state: 'healthy', reason: 'ok' },
{ id: 'prometheus', state: 'degraded', reason: 'source_stale' },
{ id: 'unraid', state: 'healthy', reason: 'ok' },
{ id: 'storage', state: 'healthy', reason: 'ok' },
],
backup: { state: 'healthy', reason: 'ok', ageSeconds: 3600 },
sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
} : pathname === '/api/v1/host' ? {
identity: { name: 'tower' }, cpu: { totalPercent: 68.4, perCore: [72, 64, 66, 71] },
memory: { utilizationPercent: 71.2 }, source: { state: 'healthy', freshness: 'fresh' },
} : pathname === '/api/v1/containers' ? {
source: { state: 'healthy', freshness: 'fresh' }, total: 6,
containers: [
{ id: 'one', state: 'running', health: 'healthy' }, { id: 'two', state: 'running', health: 'healthy' },
{ id: 'three', state: 'running', health: 'healthy' }, { id: 'four', state: 'running', health: 'healthy' },
{ id: 'five', state: 'running', health: 'healthy' }, { id: 'six', state: 'restarting', health: 'unknown' },
],
} : pathname === '/api/v1/pools' ? {
source: { state: 'healthy', freshness: 'fresh' }, total: 2,
pools: [
{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 63.8 },
{ id: 'array', name: 'Array', state: 'healthy', capacitySeverity: 'attention', utilizationPercent: 86.1 },
],
} : pathname === '/api/v1/services' ? {
capabilityState: 'available', configurationState: 'configured', total: 3,
services: [
{ id: 'proxy', name: 'Reverse proxy', state: 'up' },
{ id: 'auth', name: 'Authentik', state: 'up' },
{ id: 'media', name: 'Media', state: 'degraded' },
],
} : pathname === '/api/v1/incidents' ? {
items: [{ id: 'incident-1', title: 'Prometheus-bron loopt achter', severity: 'warning', startedAt: observedAt }],
} : pathname === '/api/v1/dashboards' ? { items: [] } : {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
}
test.beforeEach(async ({ page }) => mockSignalFlow(page));
test('operational signal path is diagnostic, keyboard reachable and responsive', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'The wallboard keeps its dedicated bounded composition.');
const runtimeErrors: string[] = [];
page.on('pageerror', (error) => runtimeErrors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') runtimeErrors.push(message.text()); });
await page.goto('/');
const panel = page.locator('.signal-path-panel');
await expect(panel).toBeVisible();
await expect(panel.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
const stages = panel.locator('.signal-path-stage button');
await expect(stages).toHaveCount(6);
await expect(stages.first()).toHaveAttribute('aria-pressed', 'true');
await stages.nth(1).focus();
await page.keyboard.press('Enter');
await expect(stages.nth(1)).toHaveAttribute('aria-pressed', 'true');
await expect(panel.locator('.signal-path-inspector')).toContainText('68,4%');
await expect(panel.locator('.signal-path-inspector')).toContainText('71,2%');
await expect(panel.getByRole('button', { name: 'Open host' })).toBeVisible();
const stageHeights = await stages.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
expect(stageHeights.every((height) => height >= 44)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
if (testInfo.project.name === 'desktop-chromium') {
const severe = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
}
if (process.env.PULSE_SOL_ULTRA_CAPTURE) {
await page.evaluate(() => { window.scrollTo(0, 0); (document.activeElement as HTMLElement | null)?.blur(); });
const mobile = testInfo.project.name === 'mobile-chromium';
await page.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual', `overview-${testInfo.project.name}.png`), fullPage: !mobile });
if (mobile) {
await page.addStyleTag({ content: '.context-bar, .mobile-navigation { display: none !important; }' });
await panel.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual/overview-mobile-chromium-signal.png') });
}
}
expect(runtimeErrors).toEqual([]);
if (testInfo.project.name === 'desktop-chromium') {
await panel.getByRole('button', { name: 'Open host' }).click();
await expect(page).toHaveURL(/\/host$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
}
});
test('failed overview resources remain unknown and recover through the shared retry', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'The data-state contract is viewport independent.');
let poolCalls = 0;
let allowPoolRecovery = false;
await page.route('**/api/v1/pools?**', async (route) => {
poolCalls += 1;
if (!allowPoolRecovery) {
await route.fulfill({ status: 503, contentType: 'application/problem+json', body: JSON.stringify({ code: 'SOURCE_UNAVAILABLE' }) });
return;
}
await route.fallback();
});
await page.goto('/');
const storage = page.locator('.signal-path-stage').filter({ hasText: 'Opslag' });
await expect(storage).toHaveAttribute('data-tone', 'unknown');
await expect(storage).toContainText('Niet beschikbaar');
await expect(page.locator('.overview-kpi').filter({ hasText: 'Hoogste poolgebruik' }).locator('strong')).toHaveText('—');
await expect(page.locator('.capacity-plane')).toContainText('Deze overzichtsbron kon niet worden geladen');
await expect(page.getByRole('heading', { level: 1, name: 'Aandacht vereist' })).toBeVisible();
allowPoolRecovery = true;
await page.getByRole('button', { name: 'Opnieuw laden' }).click();
await expect(storage).toContainText('Aandacht');
await expect(storage).not.toContainText('Niet beschikbaar');
expect(poolCalls).toBeGreaterThanOrEqual(2);
});
test('signal animation yields to reduced-motion preference', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'Reduced-motion CSS is viewport independent.');
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
const animation = await page.locator('.signal-path-stage--healthy').first().evaluate((element) => getComputedStyle(element, '::before').animationDuration);
expect(Number.parseFloat(animation)).toBeLessThanOrEqual(0.001);
});
@@ -0,0 +1,55 @@
import { expect, test, type Page } from '@playwright/test';
const zeroTimestamp = '0001-01-01T00:00:00Z';
async function mockSourceStatusData(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
const source = { id: 'unraid', state: 'healthy', freshness: 'stale', observedAt: zeroTimestamp, reason: 'source_stale' };
const body = pathname === '/api/v1/host' ? {
source,
identity: { name: 'Tower', version: '7.2.0' },
uptimeSeconds: 3600,
cpu: { totalPercent: 12, perCore: [12], iowaitPercent: 0 },
load: { one: 0.1, five: 0.2, fifteen: 0.3 },
memory: { totalBytes: 1024, availableBytes: 512, usedBytes: 512, utilizationPercent: 50, swapTotalBytes: 0, swapUsedBytes: 0, swapUtilizationPercent: 0 },
filesystems: [], network: [], time: { synchronized: true, offsetSeconds: 0, state: 'healthy' },
status: { state: 'unknown', reasons: [{ code: 'filesystem_root_not_configured', message: 'technical' }] },
observedAt: zeroTimestamp, receivedAt: zeroTimestamp,
warnings: ['filesystem_root_not_configured'],
} : pathname === '/api/v1/array' ? {
source, state: 'unknown', parity: { present: false, state: 'unknown', errors: 0 }, members: [],
} : pathname === '/api/v1/disks' ? {
source: { ...source, id: 'unraid-disks', reason: 'filesystem_root_not_configured' }, total: 0, disks: [],
} : pathname === '/api/v1/pools' ? {
source: { ...source, id: 'unraid-pools' }, total: 0, pools: [],
} : pathname === '/api/v1/applications' ? {
source, total: 0, applications: [],
} : pathname === '/api/v1/system/status' ? {
version: '1', generatedAt: zeroTimestamp, overallState: 'unknown', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
} : {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
}
test.beforeEach(async ({ page }) => mockSourceStatusData(page));
test('source status is human-readable and technically progressive on core routes', async ({ page }) => {
for (const route of ['/host', '/storage', '/applications']) {
await page.goto(route);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText('Nooit ontvangen').first()).toBeVisible();
await expect(page.getByText(/laatste meting is verouderd/i).first()).toBeVisible();
const visibleText = await page.locator('body').innerText();
expect(visibleText).not.toContain('source_stale');
expect(visibleText).not.toContain('filesystem_root_not_configured');
expect(visibleText).not.toMatch(/1 jan(?:uari)? 1/i);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
}
await page.goto('/host');
const technical = page.locator('.source-status-technical').first();
await expect(technical).not.toHaveAttribute('open');
await technical.getByText('Technische broninformatie').click();
await expect(technical.getByText('source_stale')).toBeVisible();
});
@@ -0,0 +1,32 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('storage map keeps physical identity and signal severities truthful', async ({ page }) => {
test.skip(!enabled, 'Run against an isolated real stack with the M11-03 storage fixture.');
const login = await page.request.get('/auth/test-login');
expect(login.ok()).toBeTruthy();
const disks = await (await page.request.get('/api/v1/disks?limit=100')).json() as { disks: Array<{ id: string; state: string; capacitySeverity: string; thermalSeverity: string }> };
expect(disks.disks.find((disk) => disk.id === 'disk-10')).toMatchObject({ state: 'online', capacitySeverity: 'critical', thermalSeverity: 'normal' });
expect(disks.disks.find((disk) => disk.id === 'cache')).toMatchObject({ state: 'online', capacitySeverity: 'attention', thermalSeverity: 'critical' });
const pools = await (await page.request.get('/api/v1/pools?limit=100')).json() as { pools: Array<{ id: string; state: string; capacitySeverity: string }> };
expect(pools.pools.find((pool) => pool.id === 'cache')).toMatchObject({ state: 'healthy', capacitySeverity: 'attention' });
await page.goto('/storage');
await expect(page.getByRole('heading', { level: 1, name: 'Opslagoverzicht' })).toBeVisible();
const visualNodes = page.locator('.storage-map-node');
await expect(visualNodes).toHaveCount(3);
await expect(visualNodes.filter({ hasText: 'disk10' })).toHaveCount(1);
await expect(visualNodes.filter({ hasText: 'disk10' })).toContainText('capaciteit kritiek');
await expect(visualNodes.filter({ hasText: 'cache' })).toHaveCount(2);
await expect(page.locator('.storage-heatmap-cell')).toHaveCount(2);
await expect(page.locator('.storage-heatmap-cell--critical')).toHaveCount(1);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
});
@@ -0,0 +1,62 @@
import { expect, test } from '@playwright/test';
const dashboard = { id: 'wallboard-dashboard', slug: 'operations', name: 'Operaties', description: 'Kritieke infrastructuur en recente gebeurtenissen.', scope: 'system', revision: 1, currentVersion: 1 };
const widgets = [
['system', 'Serverstatus', 0, 0, 6, 4, 'inventory', { entityType: 'server' }],
['cpu', 'CPU en belasting', 6, 0, 10, 6, 'text', {}],
['storage', 'Array en pools', 0, 6, 12, 7, 'text', {}],
['apps', 'Applicaties', 12, 6, 12, 7, 'text', {}],
['events', 'Recente gebeurtenissen', 0, 13, 24, 6, 'events', {}],
].map(([id, title, x, y, w, h, sourceType, scope]) => ({
id, title, type: id === 'events' ? 'event-timeline' : 'stat',
data: { sourceType, scope, limit: 12 }, behavior: { liveIntervalSeconds: 30 },
layouts: { wallboard: { x, y, w, h, visible: true }, desktop: { x: 0, y: 0, w: 6, h: 4, visible: true } },
}));
test('wallboard rotates bounded 1080p slides with truthful transport and data status', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'wallboard-chromium', 'Requires the 1920x1080 wallboard viewport.');
let dashboardReads = 0;
await page.route('**/api/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/api/v1/dashboards') {
dashboardReads += 1;
// React development mode performs an initial StrictMode re-read. Fail the
// first scheduled refresh, not either of the initial bootstrap reads.
if (dashboardReads === 3) {
await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary_unavailable' }) });
return;
}
}
const body = path === '/api/v1/dashboards' ? { items: [dashboard] }
: path === '/api/v1/dashboards/wallboard-dashboard' ? { dashboard, version: { document: { widgets, variables: [] } } }
: path === '/api/v1/system/status' ? { version: '1', generatedAt: new Date().toISOString(), overallState: 'degraded', components: [{ id: 'storage', state: 'degraded', reason: 'capacity_critical' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh' }] }
: path === '/api/v1/services' ? { services: [{ id: 'up', state: 'up' }, { id: 'down', state: 'down' }] }
: path === '/api/v1/incidents' ? { items: [{ id: 'incident-1' }] }
: path === '/api/v1/events' ? { items: [{ id: 'event-1', type: 'service.down', severity: 'critical', summary: 'Service niet beschikbaar', occurredAt: new Date().toISOString() }] }
: {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
const startedAt = Date.now();
await page.goto('/wallboard?interval=15&refresh=10');
await expect(page.getByRole('heading', { level: 1, name: 'Operationeel wallboard' })).toBeVisible();
expect(Date.now() - startedAt).toBeLessThan(3_000);
await expect(page.getByText('Slide 1 / 2')).toBeVisible();
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden');
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
await expect(page.locator('.wallboard-priority')).toContainText('OpslagVerstoord');
await expect(page.locator('.wallboard-priority')).toContainText('Services1 problemen');
await expect(page.locator('.wallboard-priority')).toContainText('Incidenten1 open');
await expect(page.getByRole('button', { name: /Bewerken|Exporteren/ })).toHaveCount(0);
expect(await page.evaluate(() => ({ horizontal: document.documentElement.scrollWidth - innerWidth, vertical: document.documentElement.scrollHeight - innerHeight }))).toEqual({ horizontal: 0, vertical: 0 });
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: `../../artifacts/evidence/M14-04/wallboard-slide-1-${testInfo.project.name}.png` });
await expect(page.getByText('Slide 2 / 2')).toBeVisible({ timeout: 18_000 });
await expect(page.getByRole('heading', { level: 3, name: 'Recente gebeurtenissen' })).toBeVisible();
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Bron niet beschikbaar', { timeout: 12_000 });
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden', { timeout: 12_000 });
await expect(page.getByText('Slide 2 / 2')).toBeVisible();
expect(await page.evaluate(() => ({ horizontal: document.documentElement.scrollWidth - innerWidth, vertical: document.documentElement.scrollHeight - innerHeight }))).toEqual({ horizontal: 0, vertical: 0 });
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: `../../artifacts/evidence/M14-04/wallboard-slide-2-${testInfo.project.name}.png` });
});
+15
View File
@@ -0,0 +1,15 @@
import '@testing-library/jest-dom/vitest';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
+126
View File
@@ -0,0 +1,126 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertRulesPage } from '../../src/AlertRulesPage';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState({}, '', '/alerts'); });
describe('begeleide alertregelbewerking', () => {
it('houdt de werkruimte gesloten wanneer het regelscontract toegang weigert', async () => {
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
if (String(input).includes('/alert-rules?')) return Promise.resolve(new Response('{}', { status: 403 }));
return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}));
render(<AlertRulesPage />);
expect(await screen.findByRole('heading', { name: 'Geen toegang tot alertregels' })).toBeVisible();
expect(screen.queryByRole('navigation', { name: 'Werkruimte voor meldingen' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Actieve en recente meldingen' })).not.toBeInTheDocument();
});
it('laadt de metriccatalogus en blokkeert een ongeldige regel', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [
{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' },
] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (url.includes('/alert-silences') || url.includes('/maintenance-windows') || url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
render(<AlertRulesPage />);
await user.click((await screen.findByText('Alertregels')).closest('button')!);
const save = await screen.findByRole('button', { name: 'Regel opslaan' });
expect(save).toBeDisabled();
const metric = screen.getByRole('combobox', { name: /Meting/ }) as HTMLSelectElement;
expect(metric).toHaveTextContent('CPU-gebruik van de host (%)');
expect(screen.queryByText('host.cpu.utilization')).not.toBeInTheDocument();
await user.type(document.querySelector<HTMLInputElement>('#alert-rule-name')!, 'Hoge hostbelasting');
await user.selectOptions(metric, 'host.cpu.utilization');
expect(save).toBeEnabled();
const technical = screen.getByText('Technische regelgegevens').closest('details');
expect(technical).not.toHaveAttribute('open');
expect(screen.getByLabelText('Host niet bereikbaar')).not.toBeChecked();
await user.click(screen.getByLabelText('Host niet bereikbaar'));
expect(screen.getByLabelText('Host niet bereikbaar')).toBeChecked();
await user.click(screen.getByText('Stiltes en onderhoud').closest('button')!);
expect(document.querySelector('#silence-matcher')).toHaveTextContent('Kritiek');
expect(document.querySelector('#maintenance-selector')).toHaveTextContent('Host');
expect(window.location.search).toBe('?section=controls');
});
it('laat bestaande niet-metrische regels met typeafhankelijke validatie bewerken', async () => {
const eventRule = {
id: '81111111-1111-4111-8111-111111111111', schemaVersion: 1, name: 'Container bevindt zich in een herstartlus', enabled: true, severity: 'degraded', scope: {},
condition: { inputType: 'event', operator: '>=', threshold: 3, aggregation: 'count', windowSeconds: 900 }, evaluationIntervalSeconds: 30, pendingSeconds: 0, resolveSeconds: 900, cooldownSeconds: 0,
unknownBehavior: 'become-unknown', groupBy: [], suppressWhen: ['host.unreachable'], message: { titleKey: 'alerts.restart.title', bodyKey: 'alerts.restart.body' }, revision: 1, currentVersion: 1,
};
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [eventRule] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}));
render(<AlertRulesPage />);
await userEvent.setup().click((await screen.findByText('Alertregels')).closest('button')!);
expect(await screen.findByDisplayValue('Container bevindt zich in een herstartlus')).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /Signaalbron/ })).toHaveValue('event');
expect(screen.queryByRole('combobox', { name: /Meting/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Regel opslaan' })).toBeEnabled();
});
it('zet kritieke actieve meldingen vooraan en bewaart confirmatie, revisie en idempotentie', async () => {
const alerts = Array.from({ length: 25 }, (_, index) => ({
id: `alert-${String(index + 1).padStart(2, '0')}`,
state: index === 1 ? 'acknowledged' : 'firing',
retainedState: 'firing',
ruleName: `Melding ${String(index + 1).padStart(2, '0')}`,
severity: index % 5 === 0 ? 'critical' : 'attention',
entityName: 'Tower',
reason: 'threshold_exceeded',
revision: index + 1,
updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
}));
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: alerts }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
return Promise.resolve(new Response(JSON.stringify({ alert: alerts[0] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
});
vi.stubGlobal('fetch', fetchMock);
const confirm = vi.fn(() => false);
vi.stubGlobal('confirm', confirm);
const user = userEvent.setup();
render(<AlertRulesPage />);
const critical = (await screen.findByText('Kritiek actief')).closest('button')!;
expect(screen.getByText('Actief').closest('button')).toHaveAttribute('aria-pressed', 'true');
await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(20));
expect(document.querySelector('.alert-operation-list-items li')).toHaveTextContent('Kritiek');
await user.click(critical);
await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(5));
const acknowledge = screen.getAllByRole('button', { name: 'Erkennen' })[0];
await user.click(acknowledge);
expect(confirm).toHaveBeenCalledWith('Deze melding erkennen? De evaluatie en geschiedenis blijven behouden.');
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false);
confirm.mockReturnValue(true);
await user.click(acknowledge);
const operation = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(operation?.[1]?.headers).toMatchObject({ 'If-Match': '1' });
expect((operation?.[1]?.headers as Record<string, string>)['Idempotency-Key']).toBeTruthy();
});
});
+269
View File
@@ -0,0 +1,269 @@
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from '../../src/App';
import { resetSystemStatusForTests } from '../../src/systemStatus';
afterEach(() => {
cleanup();
resetSystemStatusForTests();
vi.unstubAllGlobals();
window.history.replaceState({}, '', '/');
});
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
}
const healthySource = { state: 'healthy', freshness: 'fresh' };
describe('Stitch command overview', () => {
it('shows real source values while failed sources remain explicitly unavailable', async () => {
window.history.replaceState({}, '', '/');
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({
version: '1', generatedAt: now, overallState: 'healthy', components: [
{ id: 'database', state: 'healthy', reason: 'ok' },
{ id: 'prometheus', state: 'healthy', reason: 'ok' },
], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
});
if (url === '/api/v1/host') return json({
identity: { name: 'tower-lab' },
cpu: { totalPercent: 37.5, perCore: [25, 50] },
memory: { utilizationPercent: 62.25 },
source: healthySource,
});
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [{ id: 'running', state: 'RUNNING', health: 'healthy' }, { id: 'stopped', state: 'stopped', health: 'unknown' }], total: 2 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'svc-1', name: 'API', state: 'up' }], total: 1 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
if (url.startsWith('/api/v1/pools')) return json({ error: 'source unavailable' }, 503);
return json({ error: 'unexpected request' }, 404);
}));
render(<App />);
expect((await screen.findAllByText('37,5%'))[0]).toBeVisible();
expect(document.querySelector('.instrument-band')).toBeInTheDocument();
expect(document.querySelector('.data-plane')).toBeInTheDocument();
expect(document.querySelector('.focus-panel')).toBeInTheDocument();
expect(document.querySelector('.context-inspector')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
const unavailableStorage = screen.getByRole('button', { name: /Opslag: Niet beschikbaar/ });
expect(unavailableStorage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
const staleSource = screen.getByRole('button', { name: /Bronnen: Aandacht.*0\/1/ });
expect(staleSource.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'attention');
expect(screen.getAllByText('62,3%')[0]).toBeVisible();
expect(screen.getByText('tower-lab')).toBeVisible();
expect(screen.getByRole('button', { name: /Workloads: Kritiek.*1\/2/ })).toBeVisible();
expect(screen.queryByText('0 pools')).not.toBeInTheDocument();
const storageCard = screen.getByRole('heading', { name: 'Capaciteit en toestand' }).closest('article');
expect(storageCard).not.toBeNull();
expect(within(storageCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
expect(screen.getByText('Opslag: Niet beschikbaar')).toBeVisible();
expect(screen.getByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
});
it('suppresses current workload claims when retained container data is stale', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: { state: 'unknown', freshness: 'stale' }, containers: [{ id: 'retained', state: 'running', health: 'healthy' }], total: 1 });
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Workloads: Verouderd.*actieve containers: —/ })).toBeVisible();
const workloadCard = screen.getByRole('heading', { name: 'Containers' }).closest('article');
expect(workloadCard).not.toBeNull();
expect(within(workloadCard!).getByText(/laatst bekende waarden worden niet als actueel getoond/i)).toBeVisible();
expect(within(workloadCard!).queryByText(/1 van 1 containers zijn actief/)).not.toBeInTheDocument();
});
it('settles independent resources while a single endpoint is still pending', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'api', name: 'API', state: 'up' }], total: 1 });
if (url.startsWith('/api/v1/incidents')) return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
});
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Host: Gezond.*30%.*40%/ })).toBeVisible();
expect(screen.getByRole('button', { name: /Incidenten: Wordt geladen/ })).toBeVisible();
expect(screen.getByRole('heading', { level: 1, name: 'Status nog niet bevestigd' })).toBeVisible();
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
expect(incidentCard).not.toBeNull();
expect(within(incidentCard!).getByText(/wordt geladen; er wordt nog geen toestand verondersteld/)).toBeVisible();
});
it('does not present an unavailable incident feed as an empty healthy feed', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ code: 'UNAVAILABLE' }, 503);
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Incidenten: Niet beschikbaar/ })).toBeVisible();
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
expect(incidentCard).not.toBeNull();
expect(within(incidentCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
expect(within(incidentCard!).queryByText('Er zijn geen open incidenten geregistreerd.')).not.toBeInTheDocument();
});
it('distinguishes forbidden resources from an expired session', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ code: 'FORBIDDEN' }, 403);
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/pools')) return json({ code: 'FORBIDDEN' }, 403);
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Bronnen: Geen toegang/ })).toBeVisible();
expect(await screen.findByRole('button', { name: /Opslag: Geen toegang/ })).toBeVisible();
expect(screen.getByText('Opslag: Geen toegang')).toBeVisible();
expect(screen.getAllByText(/account heeft geen toegang tot deze overzichtsbron/).length).toBeGreaterThan(0);
expect(screen.queryByRole('link', { name: /Aanmelden/ })).not.toBeInTheDocument();
});
it('prioriteert kritieke poolcapaciteit boven gezonde device-health', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'ssd', name: 'ssd', state: 'healthy', capacitySeverity: 'critical', utilizationPercent: 98.8 }], total: 1 });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
if (url === '/api/v1/host') return json({ source: healthySource });
return json({}, 404);
}));
render(<App />);
expect(await screen.findByText('ssd: Kritiek')).toBeVisible();
expect(screen.getByText(/kritieke capaciteitsgrens is overschreden/)).toBeVisible();
expect(screen.getByText('Kritiek · device-health gezond')).toBeVisible();
expect(await screen.findByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
await waitFor(() => expect(screen.getByRole('button', { name: /Opslag: Kritiek/ })).toHaveAttribute('aria-pressed', 'true'));
});
it('retries every overview resource from the shared retry action', async () => {
const user = userEvent.setup();
const now = new Date().toISOString();
let poolCalls = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
if (url.startsWith('/api/v1/pools')) {
poolCalls += 1;
if (poolCalls === 1) return json({ code: 'POOLS_UNAVAILABLE' }, 503);
return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
}
return json({}, 404);
}));
render(<App />);
expect(await screen.findByText('Opslag: Niet beschikbaar')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Opnieuw laden' }));
expect(await screen.findByRole('button', { name: /Opslag: Gezond/ })).toBeVisible();
expect(poolCalls).toBe(2);
expect(screen.queryByText('Opslag: Niet beschikbaar')).not.toBeInTheDocument();
});
it('reads the bounded container target scale and marks truncated services partial', async () => {
const now = new Date().toISOString();
const containers = Array.from({ length: 150 }, (_, index) => ({ id: `container-${String(index).padStart(3, '0')}`, state: 'running', health: 'healthy' }));
const services = Array.from({ length: 100 }, (_, index) => ({ id: `service-${String(index).padStart(3, '0')}`, name: `Service ${index}`, state: 'up' }));
let containerCalls = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const raw = String(input);
const url = new URL(raw, 'http://pulse.test');
if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.pathname === '/api/v1/containers') {
containerCalls += 1;
const offset = Number(url.searchParams.get('after') ?? 0);
const page = containers.slice(offset, offset + 100);
return json({ source: healthySource, containers: page, total: containers.length, nextCursor: offset + page.length < containers.length ? String(offset + page.length) : undefined });
}
if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'configured', services, total: 150 });
if (url.pathname === '/api/v1/incidents') return json({ items: [] });
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Workloads: Gezond.*150\/150/ })).toBeVisible();
const serviceStage = screen.getByRole('button', { name: /Services: Gedeeltelijke data.*≥100\/150/ });
expect(serviceStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
expect(screen.getByText('Services: Gedeeltelijke data')).toBeVisible();
expect(containerCalls).toBe(2);
});
it('bounds container paging and does not inflate totals with overlapping rows', async () => {
const now = new Date().toISOString();
let containerCalls = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const raw = String(input);
const url = new URL(raw, 'http://pulse.test');
if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.pathname === '/api/v1/containers') {
containerCalls += 1;
const start = url.searchParams.get('after') === '100' ? 99 : url.searchParams.get('after') === '200' ? 199 : 0;
const items = Array.from({ length: 100 }, (_, index) => ({ id: `container-${start + index}`, state: 'running', health: 'healthy' }));
const nextCursor = containerCalls === 1 ? '100' : containerCalls === 2 ? '200' : '300';
return json({ source: healthySource, containers: items, total: 400, nextCursor });
}
if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [], total: 0 });
if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.pathname === '/api/v1/incidents') return json({ items: [] });
return json({}, 404);
}));
render(<App />);
const workloadStage = await screen.findByRole('button', { name: /Workloads: Gedeeltelijke data.*≥299\/400/ });
expect(workloadStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
expect(screen.getByText('Workloads: Gedeeltelijke data')).toBeVisible();
expect(containerCalls).toBe(3);
});
});
@@ -0,0 +1,43 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApplicationPage } from '../../src/ApplicationPage';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
describe('application status projection', () => {
it('uses attention for failures and unknown for incomplete evidence', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
source: { id: 'agent+services', state: 'healthy', freshness: 'fresh' },
total: 2,
applications: [
{ id: 'a', name: 'attention-app', status: 'DOWN', overridden: false, components: [] },
{ id: 'b', name: 'unknown-app', status: 'unknown', overridden: false, components: [] },
],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ApplicationPage />);
const attention = (await screen.findByText('attention-app')).closest('li');
const unknown = screen.getByText('unknown-app').closest('li');
expect(attention).not.toBeNull();
expect(unknown).not.toBeNull();
expect(within(attention!).getByText('Aandacht').closest('.status-badge')).toHaveClass('status-badge--attention');
expect(within(unknown!).getByText('Onbekend').closest('.status-badge')).toHaveClass('status-badge--unknown');
});
it('vertaalt staleness en toont nooit een jaar-1-waarneming als echte tijd', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
source: { id: 'agent+services', state: 'healthy', freshness: 'stale', observedAt: '0001-01-01T00:00:00Z', reason: 'source_stale' },
total: 0,
applications: [],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ApplicationPage />);
expect(await screen.findByText(/laatste meting is verouderd/i)).toBeVisible();
expect(screen.getByText('Nooit ontvangen')).toBeVisible();
expect(screen.queryByText(/1 jan 1/i)).not.toBeInTheDocument();
expect(screen.getByText('source_stale')).not.toBeVisible();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ArrayPage } from '../../src/ArrayPage';
afterEach(() => vi.unstubAllGlobals());
describe('ArrayPage', () => {
it('renders a bounded empty state when an older snapshot contains null collections', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
contractVersion: '1',
source: { id: 'array', state: 'unknown', freshness: 'unavailable' },
state: 'unknown',
parity: { present: false, state: 'unknown', errors: 0 },
members: null,
history: null,
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ArrayPage />);
expect(await screen.findByRole('heading', { level: 1, name: 'Array en parity' })).toBeVisible();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CapacityPage } from '../../src/CapacityPage';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('CapacityPage forecast qualification', () => {
it('does not count an insufficient assessment as a forecast', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z',
policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
qualifiedCount: 0,
items: [{ entityId: 'media', name: 'Media', kind: 'share', enabled: true, method: 'insufficient_data', windowSeconds: 2592000, dataPoints: 1, confidence: 'none', currentUsedBytes: 100, capacityBytes: 0, rateBytesPerDay: 0, reason: 'insufficient_points' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<CapacityPage />);
expect(await screen.findByText(/0 gekwalificeerde prognoses/)).toBeVisible();
expect(screen.getByRole('heading', { name: 'Media' })).toBeVisible();
expect(screen.getByText('Er zijn minder historische metingen dan het ingestelde minimum.')).toBeVisible();
expect(screen.queryByText('0 B / 0 B')).not.toBeInTheDocument();
});
it('renders a concrete empty state without a synthetic entity', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z', reason: 'source_unavailable',
policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
qualifiedCount: 0, items: [],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<CapacityPage />);
expect(await screen.findByRole('heading', { name: 'Nog geen bruikbare capaciteitsprognose' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Bekijk shares en groeihistorie' })).toHaveAttribute('href', '/shares');
});
});
@@ -0,0 +1,48 @@
import { render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ContainerPage } from '../../src/ContainerPage';
afterEach(() => vi.unstubAllGlobals());
describe('container status projection', () => {
it('normalizes presentation and never fabricates missing health or metrics', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
source: { id: 'unraid', state: 'healthy', freshness: 'fresh' },
total: 2,
containers: [
{ id: 'a', name: 'alpha', state: 'RUNNING', health: 'unknown', intentionalStop: false, metricsAvailable: false, lifecycleAvailable: false, uptimeSeconds: 0, restartCount: 0, exitCode: 0, cpuPercent: 0, memoryBytes: 0, memoryLimitBytes: 0, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
{ id: 'b', name: 'beta', state: 'restarting', health: 'unhealthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 4, exitCode: 137, cpuPercent: 12.5, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ContainerPage />);
const alpha = (await screen.findAllByRole('link', { name: 'alpha' })).map((item) => item.closest('tr')).find(Boolean);
const beta = screen.getAllByText('beta').map((item) => item.closest('tr')).find(Boolean);
expect(alpha).not.toBeNull();
expect(beta).not.toBeNull();
expect(within(alpha!).getByText('Actief').closest('.status-badge')).toHaveClass('status-badge--ready');
expect(within(alpha!).getAllByText('Onbekend').length).toBeGreaterThanOrEqual(2);
expect(within(alpha!).getByText(/metingen niet beschikbaar/)).toBeVisible();
expect(within(beta!).getByText('Wordt herstart').closest('.status-badge')).toHaveClass('status-badge--attention');
expect(within(beta!).getByText('Ongezond').closest('.status-badge')).toHaveClass('status-badge--attention');
expect(within(beta!).getByText('12,5%')).toBeVisible();
});
it('does not render stale item states as ready', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
source: { id: 'unraid', state: 'unknown', freshness: 'stale', reason: 'stale_source' },
total: 1,
containers: [{ id: 'a', name: 'stale-alpha', state: 'running', health: 'healthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 0, exitCode: 0, cpuPercent: 10, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ContainerPage />);
const row = (await screen.findAllByRole('link', { name: 'stale-alpha' })).map((item) => item.closest('tr')).find(Boolean);
expect(row).not.toBeNull();
expect(row!.querySelectorAll('.status-badge--unknown')).toHaveLength(2);
expect(within(row!).queryByText('running')).not.toBeInTheDocument();
expect(within(row!).queryByText('healthy')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from '../../src/App';
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState({}, '', '/');
});
describe('dashboard request caching', () => {
it('does not reuse dashboard responses across authentication or onboarding changes', async () => {
window.history.replaceState({}, '', '/dashboards');
const requests: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
requests.push({ url, init });
if (url.startsWith('/api/v1/dashboards')) {
return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ error: 'not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
}));
render(<App />);
expect(await screen.findByRole('heading', { level: 1, name: 'Jouw dashboards' })).toBeVisible();
const dashboardRequest = requests.find((request) => request.url.startsWith('/api/v1/dashboards'));
expect(dashboardRequest?.init?.cache).toBe('no-store');
});
});
+81
View File
@@ -0,0 +1,81 @@
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EventsPage } from '../../src/EventsPage';
const items = Array.from({ length: 100 }, (_, index) => ({
id: `event-${String(index + 1).padStart(3, '0')}`,
type: index % 2 === 0 ? 'service.down' : 'container.restart',
severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
entityId: `entity-${String(index % 5).padStart(2, '0')}`,
sourceId: 'source-unraid',
occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
}));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
window.history.replaceState({}, '', '/events');
});
function mockEvents(payload = items) {
const fetch = vi.fn(async () => new Response(JSON.stringify({ items: payload }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', fetch);
return fetch;
}
describe('compacte eventtijdlijn', () => {
it('houdt honderd events begrensd en pagineert deterministisch met focus', async () => {
const fetch = mockEvents();
const user = userEvent.setup();
render(<EventsPage />);
const list = await screen.findByRole('list', { name: 'Resultaten' });
expect(within(list).getAllByRole('listitem')).toHaveLength(20);
expect(within(list).getByText('event-001')).toBeInTheDocument();
expect(within(list).queryByText('event-021')).not.toBeInTheDocument();
const next = screen.getByRole('button', { name: 'Volgende pagina' });
await user.click(next);
await waitFor(() => expect(screen.getByText(/Pagina 2 van 5/)).toHaveFocus());
expect(within(list).getByText('event-021')).toBeInTheDocument();
expect(window.location.search).toContain('page=2');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('filtert ernst, soort, onderdeel en vrije tekst zonder nieuwe request', async () => {
const fetch = mockEvents();
const user = userEvent.setup();
render(<EventsPage />);
const list = await screen.findByRole('list', { name: 'Resultaten' });
await user.selectOptions(screen.getByLabelText('Ernst'), 'critical');
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
await user.selectOptions(screen.getByLabelText('Soort'), 'service.down');
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
await user.selectOptions(screen.getByLabelText('Onderdeel'), 'entity-00');
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
await user.clear(screen.getByLabelText('Zoeken'));
await user.type(screen.getByLabelText('Zoeken'), 'Gebeurtenis 091');
expect(within(list).getAllByRole('listitem')).toHaveLength(1);
expect(within(list).getByText('event-091')).toBeInTheDocument();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('houdt het kritieke totaal zichtbaar en biedt een herstelbare lege state', async () => {
mockEvents();
const user = userEvent.setup();
render(<EventsPage />);
const critical = await screen.findByRole('button', { name: /Kritieke gebeurtenissen/i });
expect(critical).toHaveTextContent('10');
await user.type(screen.getByLabelText('Zoeken'), 'bestaat-niet');
expect(screen.getByText('Geen gebeurtenissen binnen de huidige filters.')).toBeVisible();
expect(critical).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Alle filters wissen' }));
expect(await screen.findByRole('list', { name: 'Resultaten' })).toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More