mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Improve 6.2 usability across core workflows
This commit is contained in:
+30
-1
@@ -3,7 +3,29 @@
|
||||
## ⚡ Quick Fixes
|
||||
|
||||
### I forgot my password
|
||||
|
||||
Pulse does not provide an email password-reset flow. Choose the path that
|
||||
matches how this self-hosted instance authenticates:
|
||||
|
||||
- **Local Pulse username and password**: recovery requires shell access to the
|
||||
Pulse host or container. Follow the deployment-specific steps below.
|
||||
- **OIDC, SAML, or proxy authentication**: contact the identity-provider or
|
||||
Pulse administrator. If the administrator deliberately kept local login as a
|
||||
fallback, they can open the Pulse URL with `?show_local=true`; this only
|
||||
reveals the existing local form and does not reset credentials.
|
||||
- **Temporary lockout**: wait for the lockout to expire. Another signed-in
|
||||
administrator can use the lockout reset in Pulse; password recovery is not
|
||||
required.
|
||||
|
||||
The local recovery steps remove only the Pulse-generated authentication file.
|
||||
After restart, Pulse still requires the host-only bootstrap token before it
|
||||
will accept replacement credentials. If `PULSE_AUTH_USER` or `PULSE_AUTH_PASS`
|
||||
is supplied by Docker Compose, Kubernetes, systemd, or another deployment
|
||||
manager, update that deployment configuration instead; its environment values
|
||||
override the generated file.
|
||||
|
||||
**Docker**:
|
||||
|
||||
```bash
|
||||
docker exec pulse rm /data/.env
|
||||
docker restart pulse
|
||||
@@ -11,12 +33,15 @@ docker restart pulse
|
||||
# Get it with:
|
||||
docker exec pulse /app/pulse bootstrap-token
|
||||
```
|
||||
|
||||
**Systemd**:
|
||||
Delete `/etc/pulse/.env` and restart the service. Pulse will require a bootstrap token for setup:
|
||||
|
||||
```bash
|
||||
sudo rm /etc/pulse/.env
|
||||
sudo systemctl restart pulse
|
||||
sudo pulse bootstrap-token
|
||||
```
|
||||
|
||||
**Proxmox LXC** (installed from the Proxmox shell):
|
||||
Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`:
|
||||
|
||||
@@ -28,6 +53,10 @@ pct exec <ctid> -- /usr/local/bin/pulse bootstrap-token
|
||||
|
||||
If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one.
|
||||
|
||||
Treat the bootstrap token like a password: enter it only in the Pulse setup
|
||||
screen for this instance and do not paste it into support requests or issue
|
||||
reports.
|
||||
|
||||
### Port change didn't take effect
|
||||
1. Check which service is running: `systemctl status pulse` (legacy installs may use `pulse-backend`).
|
||||
2. Verify environment override: `systemctl show pulse --property=Environment`.
|
||||
|
||||
@@ -3811,6 +3811,12 @@ add flow. `InfrastructureSourcePicker.tsx` must present grouped source types
|
||||
only after the operator deliberately clicks `Add infrastructure`, and
|
||||
`ConnectionEditor.tsx` must stay focused on detect-driven handoff and the
|
||||
selected type's form body instead of reviving a second top-level catalog.
|
||||
The default picker must present the generic Linux, macOS, and Windows host
|
||||
journey once through the primary `Install Pulse Agent` path instead of
|
||||
duplicating it as a specific-source card. The canonical host catalog item must
|
||||
remain searchable by platform names and aliases so deliberate catalog lookup
|
||||
still reaches the same `linux-host` installer route with full platform
|
||||
coverage.
|
||||
Product grouping belongs to the governed platform-support presentation helper,
|
||||
not lane-local card lists. Detect utility copy must stay provider-neutral and
|
||||
operationally plain, and returning from a chosen credential slot to detect
|
||||
|
||||
@@ -4969,12 +4969,17 @@ become an overlay owned by `frontend-modern/src/components/AI/Chat/index.tsx`
|
||||
instead of compressing Infrastructure, Workloads, Storage, or other primary
|
||||
runtime pages into an unusable narrow column or forking page-local layout
|
||||
exceptions.
|
||||
On that mobile overlay path, both the drawer and its backdrop must stop above
|
||||
the mobile navigation safe-area boundary so closing or changing routes remains
|
||||
available; the full-height desktop drawer boundary resumes only with the
|
||||
desktop shell.
|
||||
The closed assistant launcher follows the same shared-shell rule. While the
|
||||
mobile navigation shell is active, `frontend-modern/src/AppLayout.tsx` must
|
||||
present the launcher as a bottom floating affordance that clears the mobile
|
||||
nav instead of restoring the desktop right-edge rail at an earlier breakpoint.
|
||||
The edge-mounted launcher is only valid at the desktop shell breakpoint where
|
||||
the primary navigation and page chrome are also desktop-mode.
|
||||
present the launcher in the authenticated header flow, where it cannot cover
|
||||
the operating surface or the mobile nav, instead of restoring the desktop
|
||||
right-edge rail at an earlier breakpoint. The edge-mounted launcher is only
|
||||
valid at the desktop shell breakpoint where the primary navigation and page
|
||||
chrome are also desktop-mode.
|
||||
Non-AI shell notices may coexist in `frontend-modern/src/AppLayout.tsx`, but
|
||||
they must remain presentation-only. Prerelease banners, billing callouts, or
|
||||
other header-adjacent notices must not fork assistant open state, gate on AI
|
||||
|
||||
@@ -1132,6 +1132,19 @@ redirect to the matching platform-shaped route. Legacy
|
||||
peer platform routes through the shared `Subtabs` primitive rather than an
|
||||
inline FilterBar group. Clearing search or override filters must preserve the
|
||||
active platform route.
|
||||
The thresholds shell is overview-first: every resource section starts
|
||||
collapsed, persisted operator expansion choices still win, and Expand all /
|
||||
Collapse all remain available on every platform tab. Because collapsed tables
|
||||
cannot carry the discovery burden, the shell must surface a filter-independent
|
||||
custom-override summary above the sections; choosing a summary item selects
|
||||
the Custom-only filter, opens its owning section, and moves that section into
|
||||
view. Resources not represented there continue to inherit their group defaults.
|
||||
Metric enablement is an explicit On/Off interaction in row, global-default,
|
||||
mobile, and bulk editors. Enabled numeric inputs accept positive trigger values
|
||||
only and user-facing copy must not expose the persisted disable sentinel.
|
||||
Internally, the canonical `<= 0` read rule remains intact for legacy data, while
|
||||
new Off actions write `-1`, so existing configuration and the API contract
|
||||
continue to round-trip without making `0` a second customer-facing disable path.
|
||||
Within the Proxmox tab, render-heavy ownership now further routes through
|
||||
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`,
|
||||
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`,
|
||||
|
||||
@@ -3758,6 +3758,10 @@ The Patrol workbench may project the summary counts into its fixed lifecycle
|
||||
filter labels at either desktop or narrow widths, but changing presentation
|
||||
does not create a parallel count or filter vocabulary: every selection still
|
||||
uses the canonical `AttentionFilter` request and the returned list summary.
|
||||
Likewise, the frontend may reduce typed evidence freshness/completeness and
|
||||
protection state into plain scan-row language or omit unavailable row metadata,
|
||||
provided selected detail continues to expose the typed facts. Presentation
|
||||
copy must not rewrite transport values or synthesize a stronger trust state.
|
||||
|
||||
Lifecycle read failure returns a typed unavailable response and never a
|
||||
synthetic zero or calm state. Protection lookup failure may return lifecycle
|
||||
|
||||
@@ -2398,10 +2398,11 @@ hosted chrome may not leave the Pulse Assistant launcher or an already-open
|
||||
assistant drawer interactive behind a modal that currently owns the viewport.
|
||||
That same hosted chrome rule also covers responsive launcher placement. While
|
||||
the authenticated shell is using mobile navigation, `frontend-modern/src/AppLayout.tsx`
|
||||
must keep the closed Pulse Assistant launcher as a bottom floating affordance
|
||||
that clears the mobile nav; it must not reintroduce the desktop right-edge
|
||||
launcher until the desktop shell breakpoint, otherwise hosted and self-hosted
|
||||
operators lose usable dashboard and billing-page edge space at tablet widths.
|
||||
must keep the closed Pulse Assistant launcher in the authenticated header flow,
|
||||
where it cannot cover page content or compete with the mobile nav. It must not
|
||||
reintroduce the desktop right-edge launcher until the desktop shell breakpoint,
|
||||
otherwise hosted and self-hosted operators lose usable dashboard and
|
||||
billing-page edge space at tablet widths.
|
||||
That same authenticated shell contract now also has to distinguish backend
|
||||
availability from websocket-stream liveness. When hosted runtime health stays
|
||||
available during a stream reconnect or renegotiation window,
|
||||
|
||||
@@ -368,6 +368,11 @@ and Kubernetes platform pages may add native API-backed sections, but the tabs
|
||||
must use `PlatformSectionTabs`, canonical table alignment helpers, and shared
|
||||
resource type presentation/reporting helpers rather than page-local tab shells,
|
||||
alignment classes, or ad hoc report-category coercion. Platform tabs are
|
||||
responsively scrollable within that shared shell. When the rail overflows, the
|
||||
primitive must expose focusable, labeled previous/next controls and keep their
|
||||
availability synchronized with the rail position so hidden workflows remain
|
||||
discoverable without widening the document or requiring precision swipes.
|
||||
Platform tabs are
|
||||
feature-owned consumers of canonical resource payloads: Docker page model
|
||||
helpers may prefer backend-authored `DockerData` stack and Podman metadata for
|
||||
search/display while shared primitives continue to own only the tab shell,
|
||||
@@ -1068,6 +1073,14 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
icon components may keep their standalone labels, but the nav must treat
|
||||
those icons as decorative inside tab buttons so names come from the tab
|
||||
label plus meaningful badge counts, not duplicated icon titles.
|
||||
That mobile route surface must remain a bounded navigation rail rather than
|
||||
compressing every destination into unreadable tabs. It keeps the active
|
||||
priority platform destination together with Alerts, Actions, and Patrol in
|
||||
the fixed rail, groups the remaining platform and utility destinations
|
||||
(including Settings) behind a labeled More menu, and preserves active-state
|
||||
and badge context on that disclosure. The route rail is a semantic `nav`,
|
||||
not a tablist; the More menu must support first/last/active focus, arrow-key
|
||||
movement, Escape focus return, outside dismissal, and route-change closure.
|
||||
Shared grouped-resource presentation primitives must keep grouped resource
|
||||
labels operator-readable: count-led labels may aggregate repeated resources,
|
||||
but uncountable or category-like resource types such as storage must use
|
||||
@@ -2540,7 +2553,10 @@ The default queue may show resource, impact, evidence, protection, next-step,
|
||||
and age facts. Provider evidence and lifecycle history belong in selected
|
||||
detail, while legacy Patrol analytics belong in collapsed supporting context.
|
||||
Unavailable and partial states must use explicit copy rather than success
|
||||
styling.
|
||||
styling. Scan rows omit unavailable protection metadata and collapse evidence
|
||||
freshness/completeness into one operator phrase; selected detail keeps the typed
|
||||
facts with named fields and plain unavailable-state labels rather than raw token
|
||||
pairs.
|
||||
|
||||
The selected attention detail may compose the shared Actions review for an
|
||||
eligible backend-authored offer. The detail owns only the expected
|
||||
@@ -4159,6 +4175,13 @@ Canonical customer disclosures inside shared shells route through
|
||||
`frontend-modern/src/utils/docsLinks.ts`, so settings privacy links resolve to
|
||||
shipped `/docs/...` assets instead of hard-coded GitHub `main` URLs that can
|
||||
drift from the running build.
|
||||
The pre-authenticated Login shell uses that same canonical docs-link boundary
|
||||
for self-hosted access recovery. `TROUBLESHOOTING_DOC_URL` routes the always-
|
||||
visible `Can’t sign in?` action to the shipped Troubleshooting guide, including
|
||||
when local login is hidden behind SSO, and the `/docs/...` public-route contract
|
||||
keeps that destination readable without an authenticated session. The login
|
||||
surface must remain guidance-only: it may not expose secrets, imply an email
|
||||
reset flow, or create a browser-side authentication bypass.
|
||||
The shared summary strip primitives now follow that same owner split.
|
||||
`frontend-modern/src/components/shared/SummaryPanel.tsx` and
|
||||
`frontend-modern/src/components/shared/SummaryMetricCard.tsx` stay the render
|
||||
@@ -4929,6 +4952,16 @@ links such as `/alerts/thresholds/infrastructure`,
|
||||
`/alerts/thresholds/containers`, and `/alerts/thresholds/mail-gateway` must
|
||||
redirect to the matching platform-shaped route; legacy
|
||||
`/alerts/thresholds/agents` links must continue to resolve to Systems.
|
||||
Thresholds section state is also owned there and composed through the shared
|
||||
collapsible-section pattern. Resource sections open collapsed by default on
|
||||
desktop and narrow layouts, while persisted operator choices, Expand all, and
|
||||
Collapse all stay authoritative. The thresholds shell must pair that compact
|
||||
default with an immediately visible custom-override summary whose actions open
|
||||
the selected section under the Custom-only filter, so inherited state remains
|
||||
clear without forcing every table open. Metric editors on desktop, mobile, and
|
||||
bulk surfaces use explicit On/Off controls and positive enabled-value bounds;
|
||||
legacy values at or below zero still read as Off, while the backend disable
|
||||
sentinel remains compatibility data rather than customer-facing input or copy.
|
||||
The Proxmox tab is itself now a shell that composes
|
||||
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`,
|
||||
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`,
|
||||
|
||||
@@ -967,6 +967,12 @@ protection posture, timeline, next step, resource navigation, and the
|
||||
contextual Assistant handoff. Assistant is absent before selection and receives
|
||||
explanation-only typed context without action or approval authority. Browser
|
||||
proof is `tests/integration/tests/91-operational-trust-attention-workbench.spec.ts`.
|
||||
The scan row must reduce evidence freshness/completeness into one plain phrase,
|
||||
omit protection posture when its state is unavailable, and use operator language
|
||||
for meaningful protection states. The selected detail retains the typed evidence
|
||||
and protection facts, but labels unknown timing, protection state, provider job
|
||||
state, and history availability explicitly instead of exposing ambiguous raw
|
||||
value pairs such as `Unknown / Complete`.
|
||||
|
||||
Attached availability failures use this same queue and detail contract. Their
|
||||
attention item keeps the owning canonical resource ID, the exact
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "4e1d2f6d5dc0819cba4cd3549f82bd87c2e99296",
|
||||
"verified_at": "2026-08-08T23:45:58Z",
|
||||
"base_sha": "63a0adf9ac0828c43db3af6fa15f8f201d0c18ac",
|
||||
"verified_at": "2026-08-09T00:14:31Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx"
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx",
|
||||
"frontend-modern/src/components/Alerts/BulkEditDialog.tsx",
|
||||
"frontend-modern/src/components/Alerts/Thresholds/hooks/useCollapsedSections.ts",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTable.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableDockerHostsSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/alertResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Login.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx",
|
||||
"frontend-modern/src/components/shared/MobileNavBar.tsx",
|
||||
"frontend-modern/src/components/shared/mobileNavBarModel.ts",
|
||||
"frontend-modern/src/components/shared/useMobileNavBarState.ts",
|
||||
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
|
||||
"frontend-modern/src/features/patrol/patrolControlPresentation.ts",
|
||||
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
|
||||
"frontend-modern/src/utils/alertResourceTablePresentation.ts",
|
||||
"frontend-modern/src/utils/alertThresholdsPresentation.ts",
|
||||
"frontend-modern/src/utils/docsLinks.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "6e4afa78ae0cfda3344573174893b80a3930d62e1f3bf2ef9c3288331c18b114",
|
||||
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "b6fee8ae8f5fcb8f1a29e561b7ec66845e1183f26888541c12dd1a2a0b57d2a2",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "6abfe2d91ed052f968ed45952792660d13d4dc469926a499b3929e0b4c994510"
|
||||
"frontend-modern/src/AppLayout.tsx": "8acb2e0855ddbf6c73a83c15ba7ec867c07c16362e22376938cf8b0f254e91cd",
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx": "03544f314050f763b673aa11f9a03043e665a58fef0dbc92bce0407a3c02c008",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx": "c2fe7968e9bd414518b91179b9c8d378b1d6db0fca7a2322b48f16c7bf266bd1",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "75348c3dc0812f378cc889ddca5ee8bc93c9dd003808436c10e045081ff66912",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx": "05da249dc2ce437dd096d775708521b74bf504c15a5cd39adbdc4024a10e1594",
|
||||
"frontend-modern/src/components/Alerts/BulkEditDialog.tsx": "86a01e0d70930c8b8aebd2b6df658253895eb8c1149ff294f53b2822af5e3bfa",
|
||||
"frontend-modern/src/components/Alerts/Thresholds/hooks/useCollapsedSections.ts": "579f92f5bc7d117b8ff66b4e722dff1b9f8836577eded1353f925181270d4008",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTable.tsx": "18d1de01e2d58188d8a946fdfda6286390eda87ec5e42954fa383b16a5ff22c5",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx": "1b224861764d806c01413e1015c1509a7530636cd482d20007805413fbadc209",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSection.tsx": "65dd1ef45028d2e4006907f15fb0f8d125d7c0546339ce5ec36ad70075a65ed4",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableDockerHostsSection.tsx": "0d1e2eb5873d7975b0195551105dd84f00e165709b07eadc2ef8079a70ee38ad",
|
||||
"frontend-modern/src/components/Alerts/alertResourceTableModel.ts": "89ba2a0b6ebb9d6f56f6425f22cd6f1c4793d92c27153ffc8c0aadf3150f86ba",
|
||||
"frontend-modern/src/components/Login.tsx": "f3ce94c4d38e0346ad7795388b9fb57800a67007a82087b5f8b18f4845d9ce72",
|
||||
"frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx": "610386b9a8b47a9559b32e54ccf72b3e06c10f48ee4a37441c15fa2b3da2c37a",
|
||||
"frontend-modern/src/components/shared/MobileNavBar.tsx": "96cd6fee4aaa0f7c11501ae525857952e7c5eb7e4dbb545f4a1f38da2b5694c3",
|
||||
"frontend-modern/src/components/shared/mobileNavBarModel.ts": "21e3ab0adc2533b5328391e8c5dff38ef58232f4e33d6eec0a493abac74800d2",
|
||||
"frontend-modern/src/components/shared/useMobileNavBarState.ts": "0d6321f1109f10d4fc100191fcbc24c075e2f3c9ec72246a80a1a0b9f124836b",
|
||||
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts": "0fc9036d2b8bbc213c031d10103edda7d33a80aaca43dac1a3dc98eaaa765da5",
|
||||
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "280db76a0ee63e15a3de2e3e8c5d95a82c49f553ac4bdd078c22db49e67fc0a6",
|
||||
"frontend-modern/src/features/patrol/patrolControlPresentation.ts": "a552c303cb7c0e17701f157e19eaedede35f8e10bcfed3435ad2f37cc28761a6",
|
||||
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "b0825d9f94a3f5725e480f3632a62e0589a19657b74ca04e97922be5ff8c9628",
|
||||
"frontend-modern/src/utils/alertResourceTablePresentation.ts": "0c61123312e72e7642b79fe42ff8828efab1a457bfbf05a11deaa88ed189476b",
|
||||
"frontend-modern/src/utils/alertThresholdsPresentation.ts": "9223fef1e0b794e8db3b59a58227154d0dcd68460dd34eb9fdc6769640bb744a",
|
||||
"frontend-modern/src/utils/docsLinks.ts": "957f8949adb3183a51b769fd796fedc7f24f847e87f4bfa5c5f60fb30f70fc96"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/infrastructure/agent-doctor"
|
||||
"/",
|
||||
"/docs/TROUBLESHOOTING",
|
||||
"/settings/infrastructure",
|
||||
"/settings/infrastructure?add=linux-host",
|
||||
"/patrol",
|
||||
"/alerts/thresholds/systems",
|
||||
"/docker/overview",
|
||||
"/kubernetes/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -27,15 +76,26 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Authenticated mock Agent Doctor rendered 2 critical, 1 needs-attention, and 29 healthy agents without inventing credential failures from non-authoritative mock token inventory.",
|
||||
"Credential-repair mode rendered a repair-aware banner and required a separate one-use scoped credential for each selected installation when multiple repairs were present.",
|
||||
"The live signed-in Agent Doctor retained duplicate Pi installations as distinct rows with their separate agent versions and identities.",
|
||||
"The Agent Doctor remained within the 390-pixel viewport with no horizontal document overflow."
|
||||
"The pre-authenticated login showed an always-visible Can’t sign in? link at desktop and 390x844, and the shipped Troubleshooting route remained readable without a session with local, SSO/proxy, lockout, environment-override, and bootstrap-token guidance.",
|
||||
"Add infrastructure opened with one primary Install Pulse Agent path, kept the Linux/macOS/Windows host choice out of the default specific-source list, exposed it once after expansion or Windows search, and routed the choice to ?add=linux-host.",
|
||||
"Patrol attention scan rows used one plain evidence phrase, omitted unavailable protection metadata, and retained named evidence and protection facts in selected detail without raw token pairs.",
|
||||
"A fresh threshold visit rendered the Machines section with aria-expanded=false, a zero-height and zero-opacity content rail, explicit On/Off help, and no customer-facing -1 instruction.",
|
||||
"Threshold CPU editing exposed min=1; submitting 0 left the inherited 80% unchanged, Off saved as Off, re-enabling with a positive value restored 80%, and an 81% custom override produced the prominent Custom overrides summary.",
|
||||
"The custom-override summary selected the Custom-only filter and expanded the matching Machines section, then reverting returned the resource to inherited defaults.",
|
||||
"At 390x844 the semantic Mobile navigation rail kept one primary destination plus Alerts, Actions, Patrol, and More; an active overflow destination marked More as the current page and preserved alert/action badges.",
|
||||
"The More menu grouped infrastructure and Pulse destinations, focused a menu item when opened, returned focus to the trigger on Escape, reopened from ArrowDown, and closed after navigating to Kubernetes.",
|
||||
"Kubernetes subnavigation exposed labeled right and left scroll controls only when needed, moved the rail from scrollLeft 0 to 242, and kept the document width at exactly 390 pixels.",
|
||||
"The mobile Assistant launcher stayed in the authenticated header flow; the open drawer and backdrop both ended at y=764 above the mobile nav at y=782.5, while the 1440px desktop launcher stayed centered on the right edge.",
|
||||
"The integrated desktop and mobile routes produced no browser console warnings or errors."
|
||||
],
|
||||
"interactions": [
|
||||
"Opened the isolated current-code Agent Doctor and verified the final mock fleet summary after the token-inventory authority fix.",
|
||||
"Exercised the multiple-installation credential-repair state and verified the installation selector, separate-token guidance, and per-installation unlock behavior.",
|
||||
"Inspected the live Pi duplicate-installation presentation and confirmed both reporting installations remained independently visible.",
|
||||
"Resized Agent Doctor to 390x844 and measured a 390-pixel document scroll width against a 390-pixel viewport."
|
||||
"Opened the login and public Troubleshooting routes before authentication, then signed in to the isolated integrated mock runtime.",
|
||||
"Opened Add infrastructure, expanded the source list, searched for Windows, selected Linux/macOS/Windows host, and confirmed the governed host-add route.",
|
||||
"Opened Patrol, inspected compact attention rows, and selected an attention item to confirm detailed typed evidence remained available.",
|
||||
"Opened thresholds from a fresh browser origin, expanded Machines, attempted and submitted an invalid zero, disabled and re-enabled CPU alerts, created an 81% override, followed the summary action, and reverted it.",
|
||||
"Resized to 390x844, opened and dismissed the More menu with keyboard controls, reopened it with ArrowDown, and navigated to Kubernetes from the overflow menu.",
|
||||
"Clicked the Kubernetes subnavigation scroll-right control and measured the rail and document widths before and after movement.",
|
||||
"Opened the Assistant from the mobile header, measured drawer/backdrop clearance against the mobile nav, closed it, and measured the desktop launcher at 1440x900.",
|
||||
"Logged out at 390x844 and confirmed the recovery link remained visible without horizontal overflow."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,29 @@
|
||||
## ⚡ Quick Fixes
|
||||
|
||||
### I forgot my password
|
||||
|
||||
Pulse does not provide an email password-reset flow. Choose the path that
|
||||
matches how this self-hosted instance authenticates:
|
||||
|
||||
- **Local Pulse username and password**: recovery requires shell access to the
|
||||
Pulse host or container. Follow the deployment-specific steps below.
|
||||
- **OIDC, SAML, or proxy authentication**: contact the identity-provider or
|
||||
Pulse administrator. If the administrator deliberately kept local login as a
|
||||
fallback, they can open the Pulse URL with `?show_local=true`; this only
|
||||
reveals the existing local form and does not reset credentials.
|
||||
- **Temporary lockout**: wait for the lockout to expire. Another signed-in
|
||||
administrator can use the lockout reset in Pulse; password recovery is not
|
||||
required.
|
||||
|
||||
The local recovery steps remove only the Pulse-generated authentication file.
|
||||
After restart, Pulse still requires the host-only bootstrap token before it
|
||||
will accept replacement credentials. If `PULSE_AUTH_USER` or `PULSE_AUTH_PASS`
|
||||
is supplied by Docker Compose, Kubernetes, systemd, or another deployment
|
||||
manager, update that deployment configuration instead; its environment values
|
||||
override the generated file.
|
||||
|
||||
**Docker**:
|
||||
|
||||
```bash
|
||||
docker exec pulse rm /data/.env
|
||||
docker restart pulse
|
||||
@@ -11,12 +33,15 @@ docker restart pulse
|
||||
# Get it with:
|
||||
docker exec pulse /app/pulse bootstrap-token
|
||||
```
|
||||
|
||||
**Systemd**:
|
||||
Delete `/etc/pulse/.env` and restart the service. Pulse will require a bootstrap token for setup:
|
||||
|
||||
```bash
|
||||
sudo rm /etc/pulse/.env
|
||||
sudo systemctl restart pulse
|
||||
sudo pulse bootstrap-token
|
||||
```
|
||||
|
||||
**Proxmox LXC** (installed from the Proxmox shell):
|
||||
Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`:
|
||||
|
||||
@@ -28,6 +53,10 @@ pct exec <ctid> -- /usr/local/bin/pulse bootstrap-token
|
||||
|
||||
If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one.
|
||||
|
||||
Treat the bootstrap token like a password: enter it only in the Pulse setup
|
||||
screen for this instance and do not paste it into support requests or issue
|
||||
reports.
|
||||
|
||||
### Port change didn't take effect
|
||||
1. Check which service is running: `systemctl status pulse` (legacy installs may use `pulse-backend`).
|
||||
2. Verify environment override: `systemctl show pulse --property=Environment`.
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { Organization } from '@/api/orgs';
|
||||
import type { VersionInfo } from '@/api/updates';
|
||||
import type { Alert, State } from '@/types/api';
|
||||
import { useKioskMode } from '@/hooks/useKioskMode';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { layoutStore } from '@/utils/layout';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getActiveTabForPath } from '@/routing/navigation';
|
||||
@@ -129,8 +130,10 @@ export function sessionHasSettingsAccess(scopes: string[] | undefined): boolean
|
||||
return scopes.includes('*') || scopes.includes(SETTINGS_READ_SCOPE);
|
||||
}
|
||||
const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
|
||||
const AI_CHAT_LAUNCHER_BUTTON_CLASS =
|
||||
'fixed right-0 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-40 flex h-11 w-8 items-center justify-center rounded-l-full rounded-r-none border border-r-0 border-border bg-surface text-blue-600 shadow-lg transition-colors duration-200 hover:bg-surface-hover hover:text-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300 lg:top-1/2 lg:bottom-auto lg:h-auto lg:w-auto lg:min-h-9 lg:min-w-10 lg:-translate-y-1/2 lg:rounded-l-lg lg:px-2.5 lg:py-2.5 lg:shadow-none';
|
||||
const AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS =
|
||||
'group relative flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-surface-hover text-blue-600 transition-colors hover:bg-border hover:text-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300';
|
||||
const AI_CHAT_DESKTOP_LAUNCHER_BUTTON_CLASS =
|
||||
'fixed right-0 top-1/2 z-40 flex min-h-9 min-w-10 -translate-y-1/2 items-center justify-center rounded-l-lg border border-r-0 border-border bg-surface px-2.5 py-2.5 text-blue-600 transition-colors duration-200 hover:bg-surface-hover hover:text-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300';
|
||||
|
||||
function getDesktopUtilityTabAriaLabel(tab: UtilityTab): string {
|
||||
const count = tab.count ?? 0;
|
||||
@@ -236,6 +239,7 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const kioskMode = useKioskMode();
|
||||
const viewport = useBreakpoint();
|
||||
const brandMotionActive = createMemo(() => props.connectionStatus().tone === 'healthy');
|
||||
const customBrandLogo = createMemo(() =>
|
||||
runtimeBranding().enabled ? runtimeBranding().logoDataUrl : '',
|
||||
@@ -443,6 +447,25 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
restoreAssistantLauncherFocus = true;
|
||||
aiChatStore.open(assistantPageContext().context);
|
||||
};
|
||||
const assistantLauncherVisible = createMemo(
|
||||
() =>
|
||||
aiChatStore.enabled === true &&
|
||||
!aiChatStore.isOpenSignal() &&
|
||||
!kioskMode() &&
|
||||
!dialogStackHasBlockingDialog(),
|
||||
);
|
||||
const renderAssistantLauncher = (className: string) => (
|
||||
<button
|
||||
ref={assistantLauncherEl}
|
||||
type="button"
|
||||
onClick={openAssistantFromLauncher}
|
||||
class={className}
|
||||
title={assistantPageContext().title}
|
||||
aria-label={assistantPageContext().ariaLabel}
|
||||
>
|
||||
<SparklesIcon class="h-5 w-5 flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
createEffect(() => {
|
||||
if (aiChatStore.isOpenSignal() || !restoreAssistantLauncherFocus) return;
|
||||
restoreAssistantLauncherFocus = false;
|
||||
@@ -805,6 +828,9 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
<div
|
||||
class={`header-controls flex items-center gap-2 ${kioskMode() ? '' : 'justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end'}`}
|
||||
>
|
||||
<Show when={assistantLauncherVisible() && viewport.isBelow('lg')}>
|
||||
{renderAssistantLauncher(AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS)}
|
||||
</Show>
|
||||
<Show when={props.hasAuth() && !props.needsAuth()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.showOrgSwitcher()}>
|
||||
@@ -1022,29 +1048,11 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
</footer>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
aiChatStore.enabled === true &&
|
||||
!aiChatStore.isOpenSignal() &&
|
||||
!kioskMode() &&
|
||||
!dialogStackHasBlockingDialog()
|
||||
}
|
||||
>
|
||||
<Show when={assistantLauncherVisible() && viewport.isAtLeast('lg')}>
|
||||
{/* Portaled out of .app-scroll-shell: as a descendant the scroll
|
||||
container's own scrollbar hit-tests above the tab's right edge,
|
||||
leaving the screen-edge click zone dead. */}
|
||||
<Portal>
|
||||
<button
|
||||
ref={assistantLauncherEl}
|
||||
type="button"
|
||||
onClick={openAssistantFromLauncher}
|
||||
class={AI_CHAT_LAUNCHER_BUTTON_CLASS}
|
||||
title={assistantPageContext().title}
|
||||
aria-label={assistantPageContext().ariaLabel}
|
||||
>
|
||||
<SparklesIcon class="h-5 w-5 flex-shrink-0" />
|
||||
</button>
|
||||
</Portal>
|
||||
<Portal>{renderAssistantLauncher(AI_CHAT_DESKTOP_LAUNCHER_BUTTON_CLASS)}</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -562,11 +562,11 @@ describe('App architecture', () => {
|
||||
expect(appLayoutSource).toContain('!dialogStackHasBlockingDialog()');
|
||||
expect(appLayoutSource).toContain('onClick={openAssistantFromLauncher}');
|
||||
expect(appLayoutSource).toContain('getAssistantPageContext');
|
||||
expect(appLayoutSource).toContain('const AI_CHAT_LAUNCHER_BUTTON_CLASS =');
|
||||
expect(appLayoutSource).toContain('bottom-[calc(5rem+env(safe-area-inset-bottom,0px))]');
|
||||
expect(appLayoutSource).toContain('lg:top-1/2');
|
||||
expect(appLayoutSource).toContain('lg:bottom-auto');
|
||||
expect(appLayoutSource).not.toContain('sm:top-1/2');
|
||||
expect(appLayoutSource).toContain('const AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS =');
|
||||
expect(appLayoutSource).toContain('const AI_CHAT_DESKTOP_LAUNCHER_BUTTON_CLASS =');
|
||||
expect(appLayoutSource).toContain("viewport.isBelow('lg')");
|
||||
expect(appLayoutSource).toContain("viewport.isAtLeast('lg')");
|
||||
expect(appLayoutSource).toContain('fixed right-0 top-1/2');
|
||||
expect(appLayoutSource).not.toContain('Pulse Assistant (⌘K)');
|
||||
expect(appSource).not.toContain("eventBus.on('theme_changed'");
|
||||
expect(appSource).not.toContain("eventBus.on('websocket_reconnected'");
|
||||
|
||||
@@ -16,6 +16,15 @@ import { clearRuntimeBranding, updateRuntimeBrandingFromResponse } from '@/store
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
function setViewportWidth(width: number) {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: width,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
|
||||
const patrolAttentionMockState = vi.hoisted(() => ({
|
||||
activeCount: 0,
|
||||
}));
|
||||
@@ -115,6 +124,7 @@ const renderLayout = (
|
||||
|
||||
describe('AppLayout navigation icons', () => {
|
||||
beforeEach(() => {
|
||||
setViewportWidth(1440);
|
||||
window.history.replaceState({}, '', '/settings/infrastructure');
|
||||
resetPrimaryNavigationRouteMemory();
|
||||
patrolAttentionMockState.activeCount = 0;
|
||||
@@ -166,17 +176,22 @@ describe('AppLayout navigation icons', () => {
|
||||
).toBeNull();
|
||||
expect(within(systemGroup as HTMLElement).queryByRole('tab', { name: 'Patrol P' })).toBeNull();
|
||||
|
||||
const mobileTablist = screen.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
['alerts', 'actions', 'ai', 'settings'].forEach((tabId) => {
|
||||
const button = mobileTablist.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`);
|
||||
const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
|
||||
['alerts', 'actions', 'ai'].forEach((tabId) => {
|
||||
const button = mobileNav.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`);
|
||||
expect(button).toBeTruthy();
|
||||
expect(button?.querySelector('svg')).toBeTruthy();
|
||||
});
|
||||
const mobilePatrolTab = within(mobileTablist).getByRole('button', {
|
||||
const mobilePatrolTab = within(mobileNav).getByRole('button', {
|
||||
name: 'Patrol',
|
||||
});
|
||||
expect(mobilePatrolTab.querySelector('svg')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(mobileNav).getByRole('button', { name: 'More navigation' }));
|
||||
const mobileOverflow = screen.getByRole('menu', { name: 'More navigation destinations' });
|
||||
const mobileSettingsTab = within(mobileOverflow).getByRole('menuitem', { name: 'Settings' });
|
||||
expect(mobileSettingsTab.querySelector('svg')).toBeTruthy();
|
||||
|
||||
expect(container).toHaveTextContent('Infrastructure body');
|
||||
});
|
||||
|
||||
@@ -195,13 +210,13 @@ describe('AppLayout navigation icons', () => {
|
||||
expect(desktopPatrolTab).toHaveTextContent('2');
|
||||
expect(within(systemGroup as HTMLElement).queryByText('Needs Attention')).toBeNull();
|
||||
|
||||
const mobileTablist = screen.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
const mobilePatrolTab = within(mobileTablist).getByRole('button', {
|
||||
const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
|
||||
const mobilePatrolTab = within(mobileNav).getByRole('button', {
|
||||
name: 'Patrol: 2 active attention items',
|
||||
});
|
||||
expect(mobilePatrolTab).toHaveTextContent('Patrol');
|
||||
expect(mobilePatrolTab).toHaveTextContent('2');
|
||||
expect(within(mobileTablist).queryByText('Needs Attention')).toBeNull();
|
||||
expect(within(mobileNav).queryByText('Needs Attention')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows platform and runtime lens tabs with supported infrastructure evidence', () => {
|
||||
@@ -355,22 +370,30 @@ describe('AppLayout navigation icons', () => {
|
||||
expect(document.title).toBe('Settings · Acme Operations');
|
||||
});
|
||||
|
||||
it('keeps the assistant launcher in the mobile edge gutter above navigation', () => {
|
||||
it('keeps the mobile Assistant launcher in header flow instead of covering page content', () => {
|
||||
setViewportWidth(390);
|
||||
renderLayout();
|
||||
|
||||
const launcher = screen.getByRole('button', { name: 'Ask Pulse Assistant about Settings' });
|
||||
const launcherClass = launcher.getAttribute('class') ?? '';
|
||||
|
||||
expect(launcherClass).toContain('right-0');
|
||||
expect(launcherClass).toContain('bottom-[calc(5rem+env(safe-area-inset-bottom,0px))]');
|
||||
expect(launcherClass).toContain('h-11');
|
||||
expect(launcherClass).toContain('w-8');
|
||||
expect(launcherClass).toContain('rounded-l-full');
|
||||
expect(launcherClass).toContain('rounded-r-none');
|
||||
expect(launcherClass).toContain('border-r-0');
|
||||
expect(launcherClass).toContain('lg:top-1/2');
|
||||
expect(launcherClass).toContain('lg:bottom-auto');
|
||||
expect(launcherClass).not.toContain('sm:top-1/2');
|
||||
expect(launcherClass).toContain('w-11');
|
||||
expect(launcherClass).toContain('rounded-full');
|
||||
expect(launcherClass).not.toContain('fixed');
|
||||
expect(launcher.closest('.header-controls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves the Assistant edge launcher on desktop', () => {
|
||||
setViewportWidth(1440);
|
||||
renderLayout();
|
||||
|
||||
const launcher = screen.getByRole('button', { name: 'Ask Pulse Assistant about Settings' });
|
||||
expect(launcher).toHaveClass('fixed');
|
||||
expect(launcher).toHaveClass('right-0');
|
||||
expect(launcher).toHaveClass('top-1/2');
|
||||
expect(launcher).toHaveClass('-translate-y-1/2');
|
||||
expect(launcher.closest('.header-controls')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens Assistant with the current route attached', async () => {
|
||||
|
||||
@@ -2000,10 +2000,16 @@ describe('AIChat', () => {
|
||||
it('switches to overlay layout below the dock threshold', () => {
|
||||
setViewportWidth(1024);
|
||||
renderChat();
|
||||
expect(screen.getByText('Pulse Assistant').closest('[data-layout-mode]')).toHaveAttribute(
|
||||
'data-layout-mode',
|
||||
'overlay',
|
||||
);
|
||||
const panel = screen.getByText('Pulse Assistant').closest('[data-layout-mode]');
|
||||
expect(panel).toHaveAttribute('data-layout-mode', 'overlay');
|
||||
expect(panel).toHaveClass('top-0');
|
||||
expect(panel).toHaveClass('bottom-[calc(5rem+env(safe-area-inset-bottom,0px))]');
|
||||
expect(panel).toHaveClass('h-auto');
|
||||
expect(panel).not.toHaveClass('inset-y-0');
|
||||
expect(panel).not.toHaveClass('h-full');
|
||||
const backdrop = screen.getByRole('button', { name: 'Close Pulse Assistant backdrop' });
|
||||
expect(backdrop).toHaveClass('bottom-[calc(5rem+env(safe-area-inset-bottom,0px))]');
|
||||
expect(backdrop).not.toHaveClass('inset-0');
|
||||
expect(screen.queryByTitle('Collapse Pulse Assistant')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Close Pulse Assistant')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2265,7 +2265,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
onCleanup(stopPanelResize);
|
||||
const rootClassName = createMemo(() => {
|
||||
if (isOverlayLayout()) {
|
||||
return `fixed inset-y-0 right-0 z-50 flex h-full w-full flex-col bg-surface transition-transform duration-300 sm:w-[560px] sm:max-w-[calc(100vw-1rem)] ${
|
||||
return `fixed top-0 right-0 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-50 flex h-auto w-full flex-col bg-surface transition-transform duration-300 sm:w-[560px] sm:max-w-[calc(100vw-1rem)] ${
|
||||
isOpen()
|
||||
? 'translate-x-0 overflow-visible border-l border-border shadow-2xl'
|
||||
: 'translate-x-full overflow-hidden border-l-0'
|
||||
@@ -4248,7 +4248,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<Show when={isOpen() && isOverlayLayout()}>
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-40 bg-slate-950/45 backdrop-blur-[1px]"
|
||||
class="fixed inset-x-0 top-0 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-40 bg-slate-950/45 backdrop-blur-[1px]"
|
||||
onClick={props.onClose}
|
||||
aria-label="Close Pulse Assistant backdrop"
|
||||
/>
|
||||
|
||||
@@ -252,6 +252,9 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
|
||||
if (Number.isNaN(nextValue)) {
|
||||
return;
|
||||
}
|
||||
if (nextValue < bounds.min || nextValue > bounds.max) {
|
||||
return;
|
||||
}
|
||||
props.table.setGlobalDefaults?.((prev) => ({
|
||||
...prev,
|
||||
[metric]: nextValue,
|
||||
|
||||
@@ -183,6 +183,9 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
||||
if (Number.isNaN(nextValue)) {
|
||||
return;
|
||||
}
|
||||
if (nextValue < bounds.min || nextValue > bounds.max) {
|
||||
return;
|
||||
}
|
||||
props.table.setGlobalDefaults?.((prev) => ({
|
||||
...prev,
|
||||
[metric]: nextValue,
|
||||
@@ -422,6 +425,12 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
||||
class="min-h-11 w-16 text-right text-xs p-1 rounded border border-border bg-surface"
|
||||
onInput={(e) => {
|
||||
const nextValue = parseFloat(e.currentTarget.value);
|
||||
if (
|
||||
!Number.isNaN(nextValue) &&
|
||||
(nextValue < bounds.min || nextValue > bounds.max)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
props.table.setEditingThresholds({
|
||||
...props.table.editingThresholds(),
|
||||
[metric]: Number.isNaN(nextValue) ? undefined : nextValue,
|
||||
|
||||
@@ -486,7 +486,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
|
||||
return;
|
||||
}
|
||||
const val = parseFloat(raw);
|
||||
if (!Number.isNaN(val)) {
|
||||
if (!Number.isNaN(val) && val >= bounds.min && val <= bounds.max) {
|
||||
updateEditingThreshold(metric, val);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -81,16 +81,16 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
|
||||
case 'memoryWarnPct':
|
||||
case 'memoryCriticalPct':
|
||||
case 'usage':
|
||||
return { min: 0, max: 100, step: 1 };
|
||||
return { min: 1, max: 100, step: 1 };
|
||||
case 'temperature':
|
||||
case 'diskTemperature':
|
||||
return { min: 20, max: 120, step: 1 };
|
||||
case 'restartCount':
|
||||
return { min: 0, max: 100, step: 1 };
|
||||
return { min: 1, max: 100, step: 1 };
|
||||
case 'restartWindow':
|
||||
return { min: 0, max: 3600, step: 60 };
|
||||
return { min: 1, max: 3600, step: 60 };
|
||||
default:
|
||||
return { min: 0, max: 1000, step: 1 };
|
||||
return { min: 1, max: 1000, step: 1 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,6 +153,7 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
|
||||
max={bounds.max}
|
||||
value={val() !== undefined && !isOff() ? val()! : bounds.min}
|
||||
onChange={(v) => {
|
||||
if (v < bounds.min || v > bounds.max) return;
|
||||
setThresholds((prev) => ({ ...prev, [metric]: v }));
|
||||
}}
|
||||
/>
|
||||
@@ -172,6 +173,9 @@ export function BulkEditDialog(props: BulkEditDialogProps) {
|
||||
}
|
||||
onInput={(e) => {
|
||||
const v = parseFloat(e.currentTarget.value);
|
||||
if (!isNaN(v) && (v < bounds.min || v > bounds.max)) {
|
||||
return;
|
||||
}
|
||||
setThresholds((prev) => ({
|
||||
...prev,
|
||||
[metric]: isNaN(v) ? undefined : v,
|
||||
|
||||
@@ -826,6 +826,20 @@ describe('ResourceTable', () => {
|
||||
expect(screen.getByTestId('status-badge')).toHaveTextContent('Off');
|
||||
});
|
||||
|
||||
it('does not stage 0 through the row numeric input', () => {
|
||||
const setEditingThresholds = vi.fn();
|
||||
const props = editorProps({
|
||||
resources: [makeResource({ id: 'vm-1', thresholds: { cpu: 80 }, defaults: { cpu: 80 } })],
|
||||
editingThresholds: () => ({ cpu: 80 }),
|
||||
setEditingThresholds,
|
||||
});
|
||||
render(() => <ResourceTable {...props} />);
|
||||
|
||||
fireEvent.input(screen.getByRole('spinbutton'), { target: { value: '0' } });
|
||||
|
||||
expect(setEditingThresholds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stages an explicit threshold when the inherited default is itself off', () => {
|
||||
// Clearing the override would only re-inherit the disabled default, so
|
||||
// the save path would drop the change and the metric would stay off.
|
||||
@@ -1623,9 +1637,9 @@ describe('ResourceTable', () => {
|
||||
});
|
||||
render(() => <ResourceTable {...props} />);
|
||||
|
||||
// Find the editing input with the "Set to -1" title (unique to resource editing inputs)
|
||||
// Find the resource editing input by its explicit Off-control guidance.
|
||||
const editInputs = document.querySelectorAll(
|
||||
'input[type="number"][title="Set to -1 or use the Off toggle to disable alerts for this metric"]',
|
||||
'input[type="number"][title="Use the Off control to disable alerts for this metric"]',
|
||||
);
|
||||
expect(editInputs.length).toBeGreaterThanOrEqual(1);
|
||||
fireEvent.input(editInputs[0], { target: { value: '95' } });
|
||||
@@ -1648,7 +1662,7 @@ describe('ResourceTable', () => {
|
||||
render(() => <ResourceTable {...props} />);
|
||||
|
||||
const editInputs = document.querySelectorAll(
|
||||
'input[type="number"][title="Set to -1 or use the Off toggle to disable alerts for this metric"]',
|
||||
'input[type="number"][title="Use the Off control to disable alerts for this metric"]',
|
||||
);
|
||||
expect(editInputs.length).toBeGreaterThanOrEqual(1);
|
||||
fireEvent.blur(editInputs[0]);
|
||||
|
||||
+22
-23
@@ -44,12 +44,12 @@ describe('useCollapsedSections', () => {
|
||||
expect(isCollapsed('pbs')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns expanded (false) for unknown sections', () => {
|
||||
it('keeps known and future resource sections collapsed by default', () => {
|
||||
const { isCollapsed } = useCollapsedSections();
|
||||
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
expect(isCollapsed('vms')).toBe(false);
|
||||
expect(isCollapsed('anything-else')).toBe(false);
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
expect(isCollapsed('dockerContainers')).toBe(true);
|
||||
expect(isCollapsed('anything-else')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('useCollapsedSections', () => {
|
||||
const { isCollapsed } = useCollapsedSections();
|
||||
|
||||
expect(isCollapsed('storage')).toBe(true);
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to defaults when localStorage has non-object value', () => {
|
||||
@@ -82,7 +82,7 @@ describe('useCollapsedSections', () => {
|
||||
const { isCollapsed } = useCollapsedSections();
|
||||
|
||||
expect(isCollapsed('storage')).toBe(true);
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to defaults when localStorage has null', () => {
|
||||
@@ -91,7 +91,7 @@ describe('useCollapsedSections', () => {
|
||||
const { isCollapsed } = useCollapsedSections();
|
||||
|
||||
expect(isCollapsed('storage')).toBe(true);
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles localStorage.getItem throwing an error', () => {
|
||||
@@ -103,7 +103,7 @@ describe('useCollapsedSections', () => {
|
||||
|
||||
// Should fall back to defaults
|
||||
expect(isCollapsed('storage')).toBe(true);
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles localStorage.setItem throwing on init gracefully', () => {
|
||||
@@ -152,22 +152,22 @@ describe('useCollapsedSections', () => {
|
||||
expect(isCollapsed('storage')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles a default-expanded section to collapsed', () => {
|
||||
it('toggles a default-collapsed resource section to expanded', () => {
|
||||
const { isCollapsed, toggleSection } = useCollapsedSections();
|
||||
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
toggleSection('nodes');
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
toggleSection('nodes');
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles back and forth', () => {
|
||||
const { isCollapsed, toggleSection } = useCollapsedSections();
|
||||
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
toggleSection('nodes');
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
toggleSection('nodes');
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
toggleSection('nodes');
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
});
|
||||
|
||||
it('persists toggle to localStorage', () => {
|
||||
@@ -181,7 +181,7 @@ describe('useCollapsedSections', () => {
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const lastWritten = JSON.parse(calls[calls.length - 1][1]);
|
||||
expect(lastWritten.nodes).toBe(true);
|
||||
expect(lastWritten.nodes).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('useCollapsedSections', () => {
|
||||
it('explicitly sets a section to collapsed', () => {
|
||||
const { isCollapsed, setCollapsed } = useCollapsedSections();
|
||||
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
setCollapsed('nodes', true);
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
});
|
||||
@@ -209,7 +209,7 @@ describe('useCollapsedSections', () => {
|
||||
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
expect(isCollapsed('storage')).toBe(true); // default
|
||||
expect(isCollapsed('vms')).toBe(false); // default
|
||||
expect(isCollapsed('vms')).toBe(true); // overview-first default
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,8 +232,8 @@ describe('useCollapsedSections', () => {
|
||||
it('expands manually collapsed sections', () => {
|
||||
const { isCollapsed, toggleSection, expandAll } = useCollapsedSections();
|
||||
|
||||
toggleSection('nodes'); // collapse it
|
||||
expect(isCollapsed('nodes')).toBe(true);
|
||||
toggleSection('nodes'); // expand it
|
||||
expect(isCollapsed('nodes')).toBe(false);
|
||||
|
||||
expandAll();
|
||||
|
||||
@@ -282,7 +282,7 @@ describe('useCollapsedSections', () => {
|
||||
resetToDefaults();
|
||||
|
||||
expect(isCollapsed('storage')).toBe(true); // back to default
|
||||
expect(isCollapsed('nodes')).toBe(false); // back to default (not in defaults)
|
||||
expect(isCollapsed('nodes')).toBe(true); // back to overview-first default
|
||||
});
|
||||
|
||||
it('restores all default-collapsed sections', () => {
|
||||
@@ -307,8 +307,8 @@ describe('useCollapsedSections', () => {
|
||||
|
||||
// hook2 should still reflect its own initial state
|
||||
// (both loaded from same localStorage at creation time, but signals are independent)
|
||||
expect(hook1.isCollapsed('nodes')).toBe(true);
|
||||
expect(hook2.isCollapsed('nodes')).toBe(false);
|
||||
expect(hook1.isCollapsed('nodes')).toBe(false);
|
||||
expect(hook2.isCollapsed('nodes')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,8 +366,7 @@ describe('useCollapsedSections', () => {
|
||||
(c: string[]) => c[0] === STORAGE_KEY,
|
||||
);
|
||||
const lastWritten = JSON.parse(calls[calls.length - 1][1]);
|
||||
// 'nodes' should not be in reset state (only defaults)
|
||||
expect(lastWritten.nodes).toBeUndefined();
|
||||
expect(lastWritten.nodes).toBe(true);
|
||||
expect(lastWritten.storage).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,16 +14,37 @@ interface CollapsedSectionsState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default collapsed state for sections
|
||||
* Sections not listed here default to expanded (false)
|
||||
* Every resource section starts collapsed so the thresholds page opens as an
|
||||
* overview instead of a wall of tables. Explicit user choices still win when
|
||||
* they are restored from localStorage.
|
||||
*/
|
||||
const DEFAULT_COLLAPSED: CollapsedSectionsState = {
|
||||
// In Proxmox tab, collapse less-frequently-used sections by default
|
||||
storage: true,
|
||||
nodes: true,
|
||||
pbs: true,
|
||||
guests: true,
|
||||
guestDisks: true,
|
||||
'guest-filtering': true,
|
||||
backups: true,
|
||||
snapshots: true,
|
||||
// PBS servers often empty, collapse by default
|
||||
pbs: true,
|
||||
storage: true,
|
||||
pmg: true,
|
||||
agents: true,
|
||||
agentDisks: true,
|
||||
dockerHosts: true,
|
||||
dockerContainers: true,
|
||||
kubernetesClusters: true,
|
||||
kubernetesNodes: true,
|
||||
kubernetesNamespaces: true,
|
||||
kubernetesDeployments: true,
|
||||
kubernetesPods: true,
|
||||
trueNASSystems: true,
|
||||
trueNASPools: true,
|
||||
trueNASDatasets: true,
|
||||
trueNASDisks: true,
|
||||
vmwareHosts: true,
|
||||
vmwareVMs: true,
|
||||
vmwareDatastores: true,
|
||||
vmwareNetworks: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -114,11 +135,11 @@ export function useCollapsedSections(): UseCollapsedSectionsResult {
|
||||
|
||||
const isCollapsed = (sectionId: string): boolean => {
|
||||
const state = collapsedState();
|
||||
// If not explicitly set, check defaults
|
||||
// Unknown future resource sections follow the same overview-first default.
|
||||
if (sectionId in state) {
|
||||
return state[sectionId];
|
||||
}
|
||||
return DEFAULT_COLLAPSED[sectionId] ?? false;
|
||||
return DEFAULT_COLLAPSED[sectionId] ?? true;
|
||||
};
|
||||
|
||||
const toggleSection = (sectionId: string): void => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
|
||||
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
|
||||
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
|
||||
@@ -56,6 +56,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
],
|
||||
},
|
||||
];
|
||||
const customOverrideItems = () => state.summaryItems().filter((item) => item.overrides > 0);
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
@@ -120,27 +121,54 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-900 dark:text-blue-100">
|
||||
<span class="font-medium">{state.getAlertThresholdsHelpBanner().title}</span> Set any
|
||||
threshold to{' '}
|
||||
<code class="px-1 py-0.5 bg-blue-100 dark:bg-blue-900 rounded text-xs font-mono">
|
||||
{state.getAlertThresholdsHelpBanner().disableValue}
|
||||
</code>{' '}
|
||||
to disable alerts for that metric. Click on disabled thresholds showing{' '}
|
||||
<span class="italic">{state.getAlertThresholdsHelpBanner().reenableLabel}</span> to
|
||||
re-enable them. Resources with custom settings show a{' '}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-xs">
|
||||
{state.getAlertThresholdsHelpBanner().customBadgeLabel}
|
||||
</span>{' '}
|
||||
badge.{' '}
|
||||
<span class="text-blue-600 dark:text-blue-400">
|
||||
<p class="font-medium">{state.getAlertThresholdsHelpBanner().title}</p>
|
||||
<p class="mt-1">{state.getAlertThresholdsHelpBanner().toggleGuidance}</p>
|
||||
<p>{state.getAlertThresholdsHelpBanner().inheritanceGuidance}</p>
|
||||
<p>{state.getAlertThresholdsHelpBanner().bulkGuidance}</p>
|
||||
<p class="text-blue-600 dark:text-blue-400">
|
||||
{state.getAlertThresholdsHelpBanner().collapseHint}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.activeTab() === 'proxmox'}>
|
||||
<Show when={customOverrideItems().length > 0}>
|
||||
<section
|
||||
class="rounded-lg border border-sky-200 bg-sky-50/70 p-3 dark:border-sky-800 dark:bg-sky-950/30"
|
||||
aria-label={state.getAlertThresholdsOverridesPresentation().title}
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-base-content">
|
||||
{state.getAlertThresholdsOverridesPresentation().title}
|
||||
</h3>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{state.getAlertThresholdsOverridesPresentation().description}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={customOverrideItems()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center gap-2 rounded-full border border-sky-200 bg-surface px-3 py-1.5 text-xs font-medium text-base-content transition-colors hover:border-sky-400 hover:bg-sky-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 sm:min-h-0 dark:border-sky-700 dark:hover:bg-sky-900"
|
||||
onClick={() => state.revealCustomOverrides(item.key)}
|
||||
aria-label={`Show ${item.label}: ${state.getAlertThresholdsOverrideCountLabel(item.overrides)}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span class="rounded-full bg-sky-100 px-2 py-0.5 text-sky-700 dark:bg-sky-900 dark:text-sky-200">
|
||||
{state.getAlertThresholdsOverrideCountLabel(item.overrides)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={state.summaryItems().length > 0}>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { For, Show } from 'solid-js';
|
||||
import Server from 'lucide-solid/icons/server';
|
||||
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { ResourceTable } from './ResourceTable';
|
||||
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
|
||||
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
|
||||
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
|
||||
|
||||
@@ -16,91 +18,102 @@ export function ThresholdsTableAgentsResourcesSection(props: ThresholdsTableSect
|
||||
|
||||
return (
|
||||
<Show when={state.hasSection('agents')}>
|
||||
<div ref={state.registerSection('agents')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title={state.sectionTitles.agents}
|
||||
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
|
||||
resources={state.agentsWithOverrides()}
|
||||
columns={['CPU %', 'Memory %', 'Disk %', 'Disk Temp °C']}
|
||||
activeAlerts={tableProps.activeAlerts}
|
||||
emptyMessage={state.AGENT_THRESHOLDS_FILTER_EMPTY_STATE}
|
||||
onEdit={state.startEditing}
|
||||
onSaveEdit={state.saveEdit}
|
||||
onCancelEdit={state.cancelEdit}
|
||||
onRemoveOverride={state.removeOverride}
|
||||
onToggleDisabled={state.toggleDisabled}
|
||||
onToggleNodeConnectivity={state.toggleNodeConnectivity}
|
||||
showOfflineAlertsColumn={true}
|
||||
editingId={state.editingId}
|
||||
editingThresholds={state.editingThresholds}
|
||||
setEditingThresholds={state.setEditingThresholds}
|
||||
editingNote={state.editingNote}
|
||||
setEditingNote={state.setEditingNote}
|
||||
onBulkEdit={(ids) =>
|
||||
state.handleBulkEdit(ids, ['CPU %', 'Memory %', 'Disk %', 'Temp °C'])
|
||||
}
|
||||
formatMetricValue={formatMetricValue}
|
||||
hasActiveAlert={state.hasActiveAlert}
|
||||
globalDefaults={tableProps.agentDefaults}
|
||||
setGlobalDefaults={tableProps.setAgentDefaults}
|
||||
setHasUnsavedChanges={tableProps.setHasUnsavedChanges}
|
||||
globalDisableFlag={tableProps.disableAllAgents}
|
||||
onToggleGlobalDisable={() =>
|
||||
tableProps.setDisableAllAgents(!tableProps.disableAllAgents())
|
||||
}
|
||||
globalDisableOfflineFlag={tableProps.disableAllAgentsOffline}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
tableProps.setDisableAllAgentsOffline(!tableProps.disableAllAgentsOffline())
|
||||
}
|
||||
showDelayColumn={true}
|
||||
globalDelaySeconds={tableProps.timeThresholds().agent}
|
||||
metricDelaySeconds={tableProps.metricTimeThresholds().agent ?? {}}
|
||||
onMetricDelayChange={(metric, value) => state.updateMetricDelay('agent', metric, value)}
|
||||
factoryDefaults={tableProps.factoryAgentDefaults}
|
||||
onResetDefaults={tableProps.resetAgentDefaults}
|
||||
/>
|
||||
<CollapsibleSection
|
||||
id="agents"
|
||||
title={state.sectionTitles.agents}
|
||||
resourceCount={state.agentsWithOverrides().length}
|
||||
collapsed={state.isCollapsed('agents')}
|
||||
onToggle={() => state.toggleSection('agents')}
|
||||
icon={<Server class="h-5 w-5" />}
|
||||
isGloballyDisabled={tableProps.disableAllAgents()}
|
||||
emptyMessage={state.AGENT_THRESHOLDS_FILTER_EMPTY_STATE}
|
||||
>
|
||||
<div ref={state.registerSection('agents')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title=""
|
||||
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
|
||||
resources={state.agentsWithOverrides()}
|
||||
columns={['CPU %', 'Memory %', 'Disk %', 'Disk Temp °C']}
|
||||
activeAlerts={tableProps.activeAlerts}
|
||||
emptyMessage={state.AGENT_THRESHOLDS_FILTER_EMPTY_STATE}
|
||||
onEdit={state.startEditing}
|
||||
onSaveEdit={state.saveEdit}
|
||||
onCancelEdit={state.cancelEdit}
|
||||
onRemoveOverride={state.removeOverride}
|
||||
onToggleDisabled={state.toggleDisabled}
|
||||
onToggleNodeConnectivity={state.toggleNodeConnectivity}
|
||||
showOfflineAlertsColumn={true}
|
||||
editingId={state.editingId}
|
||||
editingThresholds={state.editingThresholds}
|
||||
setEditingThresholds={state.setEditingThresholds}
|
||||
editingNote={state.editingNote}
|
||||
setEditingNote={state.setEditingNote}
|
||||
onBulkEdit={(ids) =>
|
||||
state.handleBulkEdit(ids, ['CPU %', 'Memory %', 'Disk %', 'Temp °C'])
|
||||
}
|
||||
formatMetricValue={formatMetricValue}
|
||||
hasActiveAlert={state.hasActiveAlert}
|
||||
globalDefaults={tableProps.agentDefaults}
|
||||
setGlobalDefaults={tableProps.setAgentDefaults}
|
||||
setHasUnsavedChanges={tableProps.setHasUnsavedChanges}
|
||||
globalDisableFlag={tableProps.disableAllAgents}
|
||||
onToggleGlobalDisable={() =>
|
||||
tableProps.setDisableAllAgents(!tableProps.disableAllAgents())
|
||||
}
|
||||
globalDisableOfflineFlag={tableProps.disableAllAgentsOffline}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
tableProps.setDisableAllAgentsOffline(!tableProps.disableAllAgentsOffline())
|
||||
}
|
||||
showDelayColumn={true}
|
||||
globalDelaySeconds={tableProps.timeThresholds().agent}
|
||||
metricDelaySeconds={tableProps.metricTimeThresholds().agent ?? {}}
|
||||
onMetricDelayChange={(metric, value) => state.updateMetricDelay('agent', metric, value)}
|
||||
factoryDefaults={tableProps.factoryAgentDefaults}
|
||||
onResetDefaults={tableProps.resetAgentDefaults}
|
||||
/>
|
||||
|
||||
<Card padding="md" tone="card" class="mt-4">
|
||||
<h3 class="text-sm font-semibold text-base-content">Disk temperature by type</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Alert trigger in °C for each disk type. Warning colors start 5°C below the trigger.
|
||||
Setting a Disk Temp override on a host above replaces these for all of that host's
|
||||
disks.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<For each={DISK_TEMP_TYPE_FIELDS}>
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
for={`disk-temp-by-type-${field.key}`}
|
||||
class="text-xs font-medium uppercase tracking-wide text-muted"
|
||||
>
|
||||
{field.label} °C
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
id={`disk-temp-by-type-${field.key}`}
|
||||
value={tableProps.diskTempByType[field.key] ?? ''}
|
||||
onInput={(event) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
if (!Number.isFinite(value) || value <= 0) return;
|
||||
const normalized = Math.max(1, Math.min(100, Math.round(value)));
|
||||
tableProps.setDiskTempByType((prev) => ({
|
||||
...prev,
|
||||
[field.key]: normalized,
|
||||
}));
|
||||
tableProps.setHasUnsavedChanges(true);
|
||||
}}
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface p-2 text-sm text-base-content focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:focus:border-sky-400 dark:focus:ring-sky-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card padding="md" tone="card" class="mt-4">
|
||||
<h3 class="text-sm font-semibold text-base-content">Disk temperature by type</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Alert trigger in °C for each disk type. Warning colors start 5°C below the trigger.
|
||||
Setting a Disk Temp override on a host above replaces these for all of that host's
|
||||
disks.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<For each={DISK_TEMP_TYPE_FIELDS}>
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
for={`disk-temp-by-type-${field.key}`}
|
||||
class="text-xs font-medium uppercase tracking-wide text-muted"
|
||||
>
|
||||
{field.label} °C
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
id={`disk-temp-by-type-${field.key}`}
|
||||
value={tableProps.diskTempByType[field.key] ?? ''}
|
||||
onInput={(event) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
if (!Number.isFinite(value) || value <= 0) return;
|
||||
const normalized = Math.max(1, Math.min(100, Math.round(value)));
|
||||
tableProps.setDiskTempByType((prev) => ({
|
||||
...prev,
|
||||
[field.key]: normalized,
|
||||
}));
|
||||
tableProps.setHasUnsavedChanges(true);
|
||||
}}
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface p-2 text-sm text-base-content focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:focus:border-sky-400 dark:focus:ring-sky-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Show } from 'solid-js';
|
||||
import Boxes from 'lucide-solid/icons/boxes';
|
||||
|
||||
import { ResourceTable } from './ResourceTable';
|
||||
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
|
||||
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
|
||||
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
|
||||
|
||||
@@ -9,50 +11,50 @@ export function ThresholdsTableDockerContainersSection(props: ThresholdsTableSec
|
||||
|
||||
return (
|
||||
<Show when={state.hasSection('dockerContainers')}>
|
||||
<div ref={state.registerSection('dockerContainers')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title={state.sectionTitles.dockerContainers}
|
||||
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
|
||||
groupedResources={state.dockerContainersGroupedByHost()}
|
||||
groupHeaderMeta={state.dockerHostGroupMeta()}
|
||||
columns={[
|
||||
'CPU %',
|
||||
'Memory %',
|
||||
'Disk %',
|
||||
'Restart Count',
|
||||
'Restart Window (s)',
|
||||
'Memory Warn %',
|
||||
'Memory Critical %',
|
||||
]}
|
||||
activeAlerts={tableProps.activeAlerts}
|
||||
emptyMessage={state.CONTAINERS_FILTER_EMPTY_STATE}
|
||||
onEdit={state.startEditing}
|
||||
onSaveEdit={state.saveEdit}
|
||||
onCancelEdit={state.cancelEdit}
|
||||
onRemoveOverride={state.removeOverride}
|
||||
onToggleDisabled={state.toggleDisabled}
|
||||
showOfflineAlertsColumn={false}
|
||||
editingId={state.editingId}
|
||||
editingThresholds={state.editingThresholds}
|
||||
setEditingThresholds={state.setEditingThresholds}
|
||||
editingNote={state.editingNote}
|
||||
setEditingNote={state.setEditingNote}
|
||||
onBulkEdit={(ids) =>
|
||||
state.handleBulkEdit(ids, ['CPU %', 'Memory %', 'Disk %', 'Temp °C'])
|
||||
}
|
||||
formatMetricValue={formatMetricValue}
|
||||
hasActiveAlert={state.hasActiveAlert}
|
||||
globalDefaults={{
|
||||
cpu: tableProps.dockerDefaults.cpu,
|
||||
memory: tableProps.dockerDefaults.memory,
|
||||
disk: tableProps.dockerDefaults.disk,
|
||||
restartCount: tableProps.dockerDefaults.restartCount,
|
||||
restartWindow: tableProps.dockerDefaults.restartWindow,
|
||||
memoryWarnPct: tableProps.dockerDefaults.memoryWarnPct,
|
||||
memoryCriticalPct: tableProps.dockerDefaults.memoryCriticalPct,
|
||||
}}
|
||||
setGlobalDefaults={(value) => {
|
||||
const current = {
|
||||
<CollapsibleSection
|
||||
id="dockerContainers"
|
||||
title={state.sectionTitles.dockerContainers}
|
||||
resourceCount={state.dockerContainersFlat().length}
|
||||
collapsed={state.isCollapsed('dockerContainers')}
|
||||
onToggle={() => state.toggleSection('dockerContainers')}
|
||||
icon={<Boxes class="h-5 w-5" />}
|
||||
isGloballyDisabled={tableProps.disableAllDockerContainers()}
|
||||
emptyMessage={state.CONTAINERS_FILTER_EMPTY_STATE}
|
||||
>
|
||||
<div ref={state.registerSection('dockerContainers')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title=""
|
||||
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
|
||||
groupedResources={state.dockerContainersGroupedByHost()}
|
||||
groupHeaderMeta={state.dockerHostGroupMeta()}
|
||||
columns={[
|
||||
'CPU %',
|
||||
'Memory %',
|
||||
'Disk %',
|
||||
'Restart Count',
|
||||
'Restart Window (s)',
|
||||
'Memory Warn %',
|
||||
'Memory Critical %',
|
||||
]}
|
||||
activeAlerts={tableProps.activeAlerts}
|
||||
emptyMessage={state.CONTAINERS_FILTER_EMPTY_STATE}
|
||||
onEdit={state.startEditing}
|
||||
onSaveEdit={state.saveEdit}
|
||||
onCancelEdit={state.cancelEdit}
|
||||
onRemoveOverride={state.removeOverride}
|
||||
onToggleDisabled={state.toggleDisabled}
|
||||
showOfflineAlertsColumn={false}
|
||||
editingId={state.editingId}
|
||||
editingThresholds={state.editingThresholds}
|
||||
setEditingThresholds={state.setEditingThresholds}
|
||||
editingNote={state.editingNote}
|
||||
setEditingNote={state.setEditingNote}
|
||||
onBulkEdit={(ids) =>
|
||||
state.handleBulkEdit(ids, ['CPU %', 'Memory %', 'Disk %', 'Temp °C'])
|
||||
}
|
||||
formatMetricValue={formatMetricValue}
|
||||
hasActiveAlert={state.hasActiveAlert}
|
||||
globalDefaults={{
|
||||
cpu: tableProps.dockerDefaults.cpu,
|
||||
memory: tableProps.dockerDefaults.memory,
|
||||
disk: tableProps.dockerDefaults.disk,
|
||||
@@ -60,50 +62,61 @@ export function ThresholdsTableDockerContainersSection(props: ThresholdsTableSec
|
||||
restartWindow: tableProps.dockerDefaults.restartWindow,
|
||||
memoryWarnPct: tableProps.dockerDefaults.memoryWarnPct,
|
||||
memoryCriticalPct: tableProps.dockerDefaults.memoryCriticalPct,
|
||||
};
|
||||
const next = typeof value === 'function' ? value(current) : { ...current, ...value };
|
||||
}}
|
||||
setGlobalDefaults={(value) => {
|
||||
const current = {
|
||||
cpu: tableProps.dockerDefaults.cpu,
|
||||
memory: tableProps.dockerDefaults.memory,
|
||||
disk: tableProps.dockerDefaults.disk,
|
||||
restartCount: tableProps.dockerDefaults.restartCount,
|
||||
restartWindow: tableProps.dockerDefaults.restartWindow,
|
||||
memoryWarnPct: tableProps.dockerDefaults.memoryWarnPct,
|
||||
memoryCriticalPct: tableProps.dockerDefaults.memoryCriticalPct,
|
||||
};
|
||||
const next = typeof value === 'function' ? value(current) : { ...current, ...value };
|
||||
|
||||
tableProps.setDockerDefaults((prev) => ({
|
||||
...prev,
|
||||
cpu: next.cpu ?? prev.cpu,
|
||||
memory: next.memory ?? prev.memory,
|
||||
disk: next.disk ?? prev.disk,
|
||||
restartCount: next.restartCount ?? prev.restartCount,
|
||||
restartWindow: next.restartWindow ?? prev.restartWindow,
|
||||
memoryWarnPct: next.memoryWarnPct ?? prev.memoryWarnPct,
|
||||
memoryCriticalPct: next.memoryCriticalPct ?? prev.memoryCriticalPct,
|
||||
}));
|
||||
}}
|
||||
setHasUnsavedChanges={tableProps.setHasUnsavedChanges}
|
||||
globalDisableFlag={tableProps.disableAllDockerContainers}
|
||||
onToggleGlobalDisable={() =>
|
||||
tableProps.setDisableAllDockerContainers(!tableProps.disableAllDockerContainers())
|
||||
}
|
||||
globalDisableOfflineFlag={() => tableProps.dockerDisableConnectivity()}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
tableProps.setDockerDisableConnectivity(!tableProps.dockerDisableConnectivity())
|
||||
}
|
||||
showDelayColumn={true}
|
||||
globalDelaySeconds={tableProps.timeThresholds().guest}
|
||||
metricDelaySeconds={tableProps.metricTimeThresholds().guest ?? {}}
|
||||
onMetricDelayChange={(metric, value) => state.updateMetricDelay('guest', metric, value)}
|
||||
globalOfflineSeverity={tableProps.dockerPoweredOffSeverity()}
|
||||
onSetGlobalOfflineState={(nextState) => {
|
||||
if (nextState === 'off') {
|
||||
tableProps.setDockerDisableConnectivity(true);
|
||||
} else {
|
||||
tableProps.setDockerDisableConnectivity(false);
|
||||
tableProps.setDockerPoweredOffSeverity(
|
||||
nextState === 'critical' ? 'critical' : 'warning',
|
||||
);
|
||||
tableProps.setDockerDefaults((prev) => ({
|
||||
...prev,
|
||||
cpu: next.cpu ?? prev.cpu,
|
||||
memory: next.memory ?? prev.memory,
|
||||
disk: next.disk ?? prev.disk,
|
||||
restartCount: next.restartCount ?? prev.restartCount,
|
||||
restartWindow: next.restartWindow ?? prev.restartWindow,
|
||||
memoryWarnPct: next.memoryWarnPct ?? prev.memoryWarnPct,
|
||||
memoryCriticalPct: next.memoryCriticalPct ?? prev.memoryCriticalPct,
|
||||
}));
|
||||
}}
|
||||
setHasUnsavedChanges={tableProps.setHasUnsavedChanges}
|
||||
globalDisableFlag={tableProps.disableAllDockerContainers}
|
||||
onToggleGlobalDisable={() =>
|
||||
tableProps.setDisableAllDockerContainers(!tableProps.disableAllDockerContainers())
|
||||
}
|
||||
tableProps.setHasUnsavedChanges(true);
|
||||
}}
|
||||
onSetOfflineState={state.setOfflineState}
|
||||
factoryDefaults={tableProps.factoryDockerDefaults}
|
||||
onResetDefaults={tableProps.resetDockerDefaults}
|
||||
/>
|
||||
</div>
|
||||
globalDisableOfflineFlag={() => tableProps.dockerDisableConnectivity()}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
tableProps.setDockerDisableConnectivity(!tableProps.dockerDisableConnectivity())
|
||||
}
|
||||
showDelayColumn={true}
|
||||
globalDelaySeconds={tableProps.timeThresholds().guest}
|
||||
metricDelaySeconds={tableProps.metricTimeThresholds().guest ?? {}}
|
||||
onMetricDelayChange={(metric, value) => state.updateMetricDelay('guest', metric, value)}
|
||||
globalOfflineSeverity={tableProps.dockerPoweredOffSeverity()}
|
||||
onSetGlobalOfflineState={(nextState) => {
|
||||
if (nextState === 'off') {
|
||||
tableProps.setDockerDisableConnectivity(true);
|
||||
} else {
|
||||
tableProps.setDockerDisableConnectivity(false);
|
||||
tableProps.setDockerPoweredOffSeverity(
|
||||
nextState === 'critical' ? 'critical' : 'warning',
|
||||
);
|
||||
}
|
||||
tableProps.setHasUnsavedChanges(true);
|
||||
}}
|
||||
onSetOfflineState={state.setOfflineState}
|
||||
factoryDefaults={tableProps.factoryDockerDefaults}
|
||||
onResetDefaults={tableProps.resetDockerDefaults}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Show } from 'solid-js';
|
||||
import Server from 'lucide-solid/icons/server';
|
||||
|
||||
import { ResourceTable } from './ResourceTable';
|
||||
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
|
||||
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
|
||||
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
|
||||
|
||||
@@ -9,51 +11,62 @@ export function ThresholdsTableDockerHostsSection(props: ThresholdsTableSectionP
|
||||
|
||||
return (
|
||||
<Show when={state.hasSection('dockerHosts')}>
|
||||
<div ref={state.registerSection('dockerHosts')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title={state.sectionTitles.dockerHosts}
|
||||
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
|
||||
resources={state.dockerHostsWithOverrides()}
|
||||
columns={[]}
|
||||
activeAlerts={tableProps.activeAlerts}
|
||||
emptyMessage={state.CONTAINER_RUNTIMES_FILTER_EMPTY_STATE}
|
||||
onEdit={state.startEditing}
|
||||
onSaveEdit={state.saveEdit}
|
||||
onCancelEdit={state.cancelEdit}
|
||||
onRemoveOverride={state.removeOverride}
|
||||
onToggleDisabled={state.toggleDisabled}
|
||||
onToggleNodeConnectivity={state.toggleNodeConnectivity}
|
||||
showOfflineAlertsColumn={true}
|
||||
editingId={state.editingId}
|
||||
editingThresholds={state.editingThresholds}
|
||||
setEditingThresholds={state.setEditingThresholds}
|
||||
editingNote={state.editingNote}
|
||||
setEditingNote={state.setEditingNote}
|
||||
onBulkEdit={(ids) =>
|
||||
state.handleBulkEdit(ids, [
|
||||
'CPU %',
|
||||
'Memory %',
|
||||
'Disk %',
|
||||
'Disk R MB/s',
|
||||
'Disk W MB/s',
|
||||
'Net In MB/s',
|
||||
'Net Out MB/s',
|
||||
'Restart Count',
|
||||
'Restart Window (s)',
|
||||
])
|
||||
}
|
||||
formatMetricValue={formatMetricValue}
|
||||
hasActiveAlert={state.hasActiveAlert}
|
||||
globalDisableFlag={tableProps.disableAllDockerHosts}
|
||||
onToggleGlobalDisable={() =>
|
||||
tableProps.setDisableAllDockerHosts(!tableProps.disableAllDockerHosts())
|
||||
}
|
||||
globalDisableOfflineFlag={tableProps.disableAllDockerHostsOffline}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
tableProps.setDisableAllDockerHostsOffline(!tableProps.disableAllDockerHostsOffline())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<CollapsibleSection
|
||||
id="dockerHosts"
|
||||
title={state.sectionTitles.dockerHosts}
|
||||
resourceCount={state.dockerHostsWithOverrides().length}
|
||||
collapsed={state.isCollapsed('dockerHosts')}
|
||||
onToggle={() => state.toggleSection('dockerHosts')}
|
||||
icon={<Server class="h-5 w-5" />}
|
||||
isGloballyDisabled={tableProps.disableAllDockerHosts()}
|
||||
emptyMessage={state.CONTAINER_RUNTIMES_FILTER_EMPTY_STATE}
|
||||
>
|
||||
<div ref={state.registerSection('dockerHosts')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title=""
|
||||
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
|
||||
resources={state.dockerHostsWithOverrides()}
|
||||
columns={[]}
|
||||
activeAlerts={tableProps.activeAlerts}
|
||||
emptyMessage={state.CONTAINER_RUNTIMES_FILTER_EMPTY_STATE}
|
||||
onEdit={state.startEditing}
|
||||
onSaveEdit={state.saveEdit}
|
||||
onCancelEdit={state.cancelEdit}
|
||||
onRemoveOverride={state.removeOverride}
|
||||
onToggleDisabled={state.toggleDisabled}
|
||||
onToggleNodeConnectivity={state.toggleNodeConnectivity}
|
||||
showOfflineAlertsColumn={true}
|
||||
editingId={state.editingId}
|
||||
editingThresholds={state.editingThresholds}
|
||||
setEditingThresholds={state.setEditingThresholds}
|
||||
editingNote={state.editingNote}
|
||||
setEditingNote={state.setEditingNote}
|
||||
onBulkEdit={(ids) =>
|
||||
state.handleBulkEdit(ids, [
|
||||
'CPU %',
|
||||
'Memory %',
|
||||
'Disk %',
|
||||
'Disk R MB/s',
|
||||
'Disk W MB/s',
|
||||
'Net In MB/s',
|
||||
'Net Out MB/s',
|
||||
'Restart Count',
|
||||
'Restart Window (s)',
|
||||
])
|
||||
}
|
||||
formatMetricValue={formatMetricValue}
|
||||
hasActiveAlert={state.hasActiveAlert}
|
||||
globalDisableFlag={tableProps.disableAllDockerHosts}
|
||||
onToggleGlobalDisable={() =>
|
||||
tableProps.setDisableAllDockerHosts(!tableProps.disableAllDockerHosts())
|
||||
}
|
||||
globalDisableOfflineFlag={tableProps.disableAllDockerHostsOffline}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
tableProps.setDisableAllDockerHostsOffline(!tableProps.disableAllDockerHostsOffline())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,21 +280,17 @@ describe('BulkEditDialog', () => {
|
||||
expect(saved.restartCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a staged 0 as Off, matching the alert engine', () => {
|
||||
// The engine disables any metric whose trigger is <= 0, and older builds
|
||||
// told operators to type 0, so 0 must not read as On here.
|
||||
it('does not stage 0 through the numeric input', () => {
|
||||
const props = defaultProps();
|
||||
props.columns = ['Restart Count'];
|
||||
render(() => <BulkEditDialog {...props} />);
|
||||
|
||||
fireEvent.input(screen.getByRole('spinbutton'), { target: { value: '0' } });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument();
|
||||
|
||||
// Toggling that stale 0 back on stages the canonical enabled default.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
|
||||
expect(screen.getByRole('button', { name: 'On' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Unchanged')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(/Apply to 3 items/));
|
||||
expect(props.onSave).toHaveBeenCalledWith({ restartCount: 3 });
|
||||
expect(props.onSave).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('typing a value after turning a metric off replaces the staged -1', () => {
|
||||
@@ -388,13 +384,13 @@ describe('BulkEditDialog', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes correct bounds for percentage metrics (0-100)', () => {
|
||||
it('passes positive bounds for percentage metrics (1-100)', () => {
|
||||
const props = defaultProps();
|
||||
props.columns = ['CPU %'];
|
||||
render(() => <BulkEditDialog {...props} />);
|
||||
|
||||
const slider = screen.getByTestId('slider-cpu') as HTMLInputElement;
|
||||
expect(slider.min).toBe('0');
|
||||
expect(slider.min).toBe('1');
|
||||
expect(slider.max).toBe('100');
|
||||
});
|
||||
|
||||
@@ -408,24 +404,24 @@ describe('BulkEditDialog', () => {
|
||||
expect(slider.max).toBe('120');
|
||||
});
|
||||
|
||||
it('passes correct bounds for restartWindow (0-3600 step 60)', () => {
|
||||
it('passes positive bounds for restartWindow (1-3600 step 60)', () => {
|
||||
const props = defaultProps();
|
||||
props.columns = ['Restart Window (s)'];
|
||||
render(() => <BulkEditDialog {...props} />);
|
||||
|
||||
const input = screen.getByRole('spinbutton') as HTMLInputElement;
|
||||
expect(input.min).toBe('0');
|
||||
expect(input.min).toBe('1');
|
||||
expect(input.max).toBe('3600');
|
||||
expect(input.step).toBe('60');
|
||||
});
|
||||
|
||||
it('passes default bounds for unknown metrics (0-1000 step 1)', () => {
|
||||
it('passes positive default bounds for unknown metrics (1-1000 step 1)', () => {
|
||||
const props = defaultProps();
|
||||
props.columns = ['Custom Widget'];
|
||||
render(() => <BulkEditDialog {...props} />);
|
||||
|
||||
const input = screen.getByRole('spinbutton') as HTMLInputElement;
|
||||
expect(input.min).toBe('0');
|
||||
expect(input.min).toBe('1');
|
||||
expect(input.max).toBe('1000');
|
||||
expect(input.step).toBe('1');
|
||||
});
|
||||
|
||||
@@ -354,10 +354,52 @@ describe('ThresholdsTable basics', () => {
|
||||
|
||||
it('allows dismissing the help banner', () => {
|
||||
render(() => <ThresholdsTable {...(baseProps() as any)} />);
|
||||
expect(screen.getByText('How thresholds work')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Use the On\/Off control beside a metric/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/-1/)).not.toBeInTheDocument();
|
||||
const dismissButton = screen.getByLabelText(/Dismiss tips/i);
|
||||
expect(dismissButton).toHaveClass('min-h-11', 'min-w-11', 'sm:min-h-0', 'sm:min-w-0');
|
||||
fireEvent.click(dismissButton);
|
||||
expect(screen.queryByText(/Quick tips:/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/How thresholds work/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces custom overrides separately from inherited resources', async () => {
|
||||
setPathname('/alerts/thresholds/systems');
|
||||
const customHost = {
|
||||
id: 'custom-host',
|
||||
hostname: 'custom-host',
|
||||
displayName: 'Custom Host',
|
||||
status: 'online',
|
||||
lastSeen: 123,
|
||||
} as Agent;
|
||||
const inheritedHost = {
|
||||
id: 'inherited-host',
|
||||
hostname: 'inherited-host',
|
||||
displayName: 'Inherited Host',
|
||||
status: 'online',
|
||||
lastSeen: 123,
|
||||
} as Agent;
|
||||
|
||||
render(() => (
|
||||
<ThresholdsTable
|
||||
{...(baseProps() as any)}
|
||||
agents={[customHost, inheritedHost]}
|
||||
overrides={() => [
|
||||
{
|
||||
id: 'custom-host',
|
||||
name: 'Custom Host',
|
||||
type: 'agent' as const,
|
||||
thresholds: { cpu: 91 },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
const summary = await screen.findByRole('region', { name: 'Custom overrides' });
|
||||
expect(summary).toHaveTextContent('differ from the inherited defaults');
|
||||
expect(
|
||||
within(summary).getByRole('button', { name: 'Show Machines: 1 custom' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -411,7 +453,7 @@ describe('ThresholdsTable navigation and redirection', () => {
|
||||
render(() => <ThresholdsTable {...(baseProps() as any)} agents={[host]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('resource-table-Machines')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('section-Machines')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -459,10 +501,9 @@ describe('ThresholdsTable Resource Rendering', () => {
|
||||
render(() => <ThresholdsTable {...(baseProps() as any)} agents={[host]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('resource-table-Machines')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('section-Machines')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('resource-count-Machines')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('resource-name-h1')).toHaveTextContent('Host 1');
|
||||
});
|
||||
|
||||
@@ -489,7 +530,7 @@ describe('ThresholdsTable Resource Rendering', () => {
|
||||
render(() => <ThresholdsTable {...(baseProps() as any)} agents={[host]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('resource-table-Machines')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('section-Machines')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('resource-name-h2')).toHaveTextContent('Secret Host');
|
||||
@@ -736,7 +777,7 @@ describe('ThresholdsTable Resource Rendering', () => {
|
||||
));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('resource-table-Container Runtimes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('section-Container Runtimes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('resource-name-truenas-resource')).toHaveTextContent('TrueNAS');
|
||||
|
||||
+17
-17
@@ -224,23 +224,23 @@ describe('normalizeAlertResourceMetricKey', () => {
|
||||
|
||||
describe('getAlertResourceMetricBounds', () => {
|
||||
it.each([
|
||||
['temperature', { min: -1, max: 150 }],
|
||||
['diskTemperature', { min: -1, max: 150 }],
|
||||
['diskRead', { min: -1, max: 10000 }],
|
||||
['diskWrite', { min: -1, max: 10000 }],
|
||||
['networkIn', { min: -1, max: 10000 }],
|
||||
['networkOut', { min: -1, max: 10000 }],
|
||||
['cpu', { min: -1, max: 100 }],
|
||||
['memory', { min: -1, max: 100 }],
|
||||
['disk', { min: -1, max: 100 }],
|
||||
['usage', { min: -1, max: 100 }],
|
||||
['memoryWarnPct', { min: -1, max: 100 }],
|
||||
['memoryCriticalPct', { min: -1, max: 100 }],
|
||||
['warningSizeGiB', { min: -1, max: 100000 }],
|
||||
['criticalSizeGiB', { min: -1, max: 100000 }],
|
||||
['restartCount', { min: -1, max: 50 }],
|
||||
['restartWindow', { min: -1, max: 86400 }],
|
||||
['unknownMetric', { min: -1, max: 10000 }],
|
||||
['temperature', { min: 1, max: 150 }],
|
||||
['diskTemperature', { min: 1, max: 150 }],
|
||||
['diskRead', { min: 0.01, max: 10000 }],
|
||||
['diskWrite', { min: 0.01, max: 10000 }],
|
||||
['networkIn', { min: 0.01, max: 10000 }],
|
||||
['networkOut', { min: 0.01, max: 10000 }],
|
||||
['cpu', { min: 1, max: 100 }],
|
||||
['memory', { min: 1, max: 100 }],
|
||||
['disk', { min: 1, max: 100 }],
|
||||
['usage', { min: 1, max: 100 }],
|
||||
['memoryWarnPct', { min: 1, max: 100 }],
|
||||
['memoryCriticalPct', { min: 1, max: 100 }],
|
||||
['warningSizeGiB', { min: 0.01, max: 100000 }],
|
||||
['criticalSizeGiB', { min: 0.01, max: 100000 }],
|
||||
['restartCount', { min: 1, max: 50 }],
|
||||
['restartWindow', { min: 1, max: 86400 }],
|
||||
['unknownMetric', { min: 1, max: 10000 }],
|
||||
])('returns correct bounds for %s', (metric, expected) => {
|
||||
expect(getAlertResourceMetricBounds(metric)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
+2
-2
@@ -200,11 +200,11 @@ describe('normalizeAlertResourceMetricKey — compound replace chains', () => {
|
||||
|
||||
describe('getAlertResourceMetricBounds — edge inputs', () => {
|
||||
it('returns the default bounds for an empty-string metric', () => {
|
||||
expect(getAlertResourceMetricBounds('')).toStrictEqual({ min: -1, max: 10000 });
|
||||
expect(getAlertResourceMetricBounds('')).toStrictEqual({ min: 1, max: 10000 });
|
||||
});
|
||||
|
||||
it('returns the default bounds for a whitespace metric', () => {
|
||||
expect(getAlertResourceMetricBounds(' ')).toStrictEqual({ min: -1, max: 10000 });
|
||||
expect(getAlertResourceMetricBounds(' ')).toStrictEqual({ min: 1, max: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+17
-17
@@ -224,23 +224,23 @@ describe('normalizeAlertResourceMetricKey', () => {
|
||||
|
||||
describe('getAlertResourceMetricBounds', () => {
|
||||
it.each([
|
||||
['temperature', { min: -1, max: 150 }],
|
||||
['diskTemperature', { min: -1, max: 150 }],
|
||||
['diskRead', { min: -1, max: 10000 }],
|
||||
['diskWrite', { min: -1, max: 10000 }],
|
||||
['networkIn', { min: -1, max: 10000 }],
|
||||
['networkOut', { min: -1, max: 10000 }],
|
||||
['cpu', { min: -1, max: 100 }],
|
||||
['memory', { min: -1, max: 100 }],
|
||||
['disk', { min: -1, max: 100 }],
|
||||
['usage', { min: -1, max: 100 }],
|
||||
['memoryWarnPct', { min: -1, max: 100 }],
|
||||
['memoryCriticalPct', { min: -1, max: 100 }],
|
||||
['warningSizeGiB', { min: -1, max: 100000 }],
|
||||
['criticalSizeGiB', { min: -1, max: 100000 }],
|
||||
['restartCount', { min: -1, max: 50 }],
|
||||
['restartWindow', { min: -1, max: 86400 }],
|
||||
['unknownMetric', { min: -1, max: 10000 }],
|
||||
['temperature', { min: 1, max: 150 }],
|
||||
['diskTemperature', { min: 1, max: 150 }],
|
||||
['diskRead', { min: 0.01, max: 10000 }],
|
||||
['diskWrite', { min: 0.01, max: 10000 }],
|
||||
['networkIn', { min: 0.01, max: 10000 }],
|
||||
['networkOut', { min: 0.01, max: 10000 }],
|
||||
['cpu', { min: 1, max: 100 }],
|
||||
['memory', { min: 1, max: 100 }],
|
||||
['disk', { min: 1, max: 100 }],
|
||||
['usage', { min: 1, max: 100 }],
|
||||
['memoryWarnPct', { min: 1, max: 100 }],
|
||||
['memoryCriticalPct', { min: 1, max: 100 }],
|
||||
['warningSizeGiB', { min: 0.01, max: 100000 }],
|
||||
['criticalSizeGiB', { min: 0.01, max: 100000 }],
|
||||
['restartCount', { min: 1, max: 50 }],
|
||||
['restartWindow', { min: 1, max: 86400 }],
|
||||
['unknownMetric', { min: 1, max: 10000 }],
|
||||
])('returns correct bounds for %s', (metric, expected) => {
|
||||
expect(getAlertResourceMetricBounds(metric)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
@@ -147,24 +147,24 @@ export function normalizeAlertResourceMetricKey(column: string): string {
|
||||
|
||||
export function getAlertResourceMetricBounds(metric: string): { min: number; max: number } {
|
||||
if (metric === 'temperature' || metric === 'diskTemperature') {
|
||||
return { min: -1, max: 150 };
|
||||
return { min: 1, max: 150 };
|
||||
}
|
||||
if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) {
|
||||
return { min: -1, max: 10000 };
|
||||
return { min: 0.01, max: 10000 };
|
||||
}
|
||||
if (['cpu', 'memory', 'disk', 'usage', 'memoryWarnPct', 'memoryCriticalPct'].includes(metric)) {
|
||||
return { min: -1, max: 100 };
|
||||
return { min: 1, max: 100 };
|
||||
}
|
||||
if (['warningSizeGiB', 'criticalSizeGiB'].includes(metric)) {
|
||||
return { min: -1, max: 100000 };
|
||||
return { min: 0.01, max: 100000 };
|
||||
}
|
||||
if (metric === 'restartCount') {
|
||||
return { min: -1, max: 50 };
|
||||
return { min: 1, max: 50 };
|
||||
}
|
||||
if (metric === 'restartWindow') {
|
||||
return { min: -1, max: 86400 };
|
||||
return { min: 1, max: 86400 };
|
||||
}
|
||||
return { min: -1, max: 10000 };
|
||||
return { min: 1, max: 10000 };
|
||||
}
|
||||
|
||||
export function getAlertResourceMetricStep(metric: string): string | number {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { logger } from '@/utils/logger';
|
||||
import { PulseBrandMark } from '@/components/Brand/PulseBrandMark';
|
||||
import { apiClient, apiFetchJSON } from '@/utils/apiClient';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { TROUBLESHOOTING_DOC_URL } from '@/utils/docsLinks';
|
||||
import Globe from 'lucide-solid/icons/globe';
|
||||
import Key from 'lucide-solid/icons/key';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
@@ -643,6 +644,14 @@ const LoginForm: Component<{
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="border-t border-border pt-4 text-center">
|
||||
<a
|
||||
href={TROUBLESHOOTING_DOC_URL}
|
||||
class="inline-flex min-h-10 items-center rounded px-1 text-xs font-medium text-blue-700 underline-offset-2 transition-colors hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 dark:text-blue-300 sm:min-h-9"
|
||||
>
|
||||
Can’t sign in?
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,9 @@ const CARD_ICON: Record<InfrastructureSourcePickerItemId, Component<{ class?: st
|
||||
kubernetes: ServerCog,
|
||||
};
|
||||
|
||||
const PRIMARY_PATH_PICKER_ITEM_IDS: ReadonlySet<InfrastructureSourcePickerItemId> = new Set();
|
||||
const PRIMARY_PATH_PICKER_ITEM_IDS: ReadonlySet<InfrastructureSourcePickerItemId> = new Set([
|
||||
'linux-host',
|
||||
]);
|
||||
|
||||
// Popular sources shown by default. The rest are hidden behind 'Show more
|
||||
// sources' to keep the picker scannable at a glance and let users scroll to
|
||||
@@ -47,7 +49,6 @@ const POPULAR_PICKER_ITEM_IDS: ReadonlySet<InfrastructureSourcePickerItemId> = n
|
||||
'pve',
|
||||
'truenas',
|
||||
'unraid',
|
||||
'linux-host',
|
||||
]);
|
||||
|
||||
const itemMatchesQuery = (item: InfrastructureSourcePickerItemPresentation, query: string) => {
|
||||
|
||||
+12
-2
@@ -23,7 +23,7 @@ describe('InfrastructureSourcePicker', () => {
|
||||
expect(screen.getByText('Preview')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Available now')).toBeNull();
|
||||
expect(screen.getByText('Unraid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Linux, macOS, Windows host')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Linux, macOS, Windows host')).toBeNull();
|
||||
expect(screen.getByText('Array health, disks, Docker, host telemetry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Agent telemetry')).toBeNull();
|
||||
});
|
||||
@@ -44,7 +44,8 @@ describe('InfrastructureSourcePicker', () => {
|
||||
});
|
||||
|
||||
it('filters the catalog by user-recognizable names and aliases', () => {
|
||||
render(() => <InfrastructureSourcePicker onSelectStep={vi.fn()} />);
|
||||
const onSelectStep = vi.fn();
|
||||
render(() => <InfrastructureSourcePicker onSelectStep={onSelectStep} />);
|
||||
|
||||
fireEvent.input(screen.getByPlaceholderText('Search sources, devices, services...'), {
|
||||
target: { value: 'nas' },
|
||||
@@ -60,5 +61,14 @@ describe('InfrastructureSourcePicker', () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Network endpoint')).toBeNull();
|
||||
|
||||
fireEvent.input(screen.getByPlaceholderText('Search sources, devices, services...'), {
|
||||
target: { value: 'windows' },
|
||||
});
|
||||
|
||||
const hostButton = screen.getByText('Linux, macOS, Windows host').closest('button');
|
||||
expect(hostButton).not.toBeNull();
|
||||
fireEvent.click(hostButton!);
|
||||
expect(onSelectStep).toHaveBeenLastCalledWith('linux-host');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,10 @@ describe('Login', () => {
|
||||
expect(await screen.findByPlaceholderText('Username')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in to pulse/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Can’t sign in?' })).toHaveAttribute(
|
||||
'href',
|
||||
'/docs/TROUBLESHOOTING',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides local login form when hideLocalLogin is true and an SSO provider is available', async () => {
|
||||
@@ -115,6 +119,10 @@ describe('Login', () => {
|
||||
// Username and password fields should NOT be visible
|
||||
expect(screen.queryByPlaceholderText('Username')).toBeNull();
|
||||
expect(screen.queryByPlaceholderText('Password')).toBeNull();
|
||||
expect(screen.getByRole('link', { name: 'Can’t sign in?' })).toHaveAttribute(
|
||||
'href',
|
||||
'/docs/TROUBLESHOOTING',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows local login form when show_local=true is in URL even if hideLocalLogin is true', async () => {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { For, Show } from 'solid-js';
|
||||
import EllipsisIcon from 'lucide-solid/icons/ellipsis';
|
||||
import { type MobileNavBarProps, useMobileNavBarState } from './useMobileNavBarState';
|
||||
import {
|
||||
getMobileNavAlertBadgeCounts,
|
||||
getMobileNavDestinationKey,
|
||||
getMobileNavTabAriaLabel,
|
||||
getMobileNavTabButtonClass,
|
||||
isMobileNavDestinationActive,
|
||||
mobileNavDestinationHasBadge,
|
||||
type MobileNavBarDestination,
|
||||
} from './mobileNavBarModel';
|
||||
|
||||
export type {
|
||||
@@ -12,141 +17,216 @@ export type {
|
||||
MobileNavBarUtilityTab,
|
||||
} from './mobileNavBarModel';
|
||||
|
||||
export function MobileNavBar(props: MobileNavBarProps) {
|
||||
const mobileNav = useMobileNavBarState(props);
|
||||
const tabIconClass = 'h-4 w-4 shrink-0';
|
||||
const MOBILE_NAV_OVERFLOW_ID = 'mobile-navigation-overflow';
|
||||
|
||||
function MobileNavDestinationContent(props: {
|
||||
destination: MobileNavBarDestination;
|
||||
iconClass: string;
|
||||
}) {
|
||||
const tab = () => props.destination.tab;
|
||||
const utilityTab = () =>
|
||||
props.destination.kind === 'utility' ? props.destination.tab : undefined;
|
||||
const alertBadges = () => {
|
||||
const utility = utilityTab();
|
||||
return utility ? getMobileNavAlertBadgeCounts(utility) : null;
|
||||
};
|
||||
const genericCount = () => {
|
||||
const utility = utilityTab();
|
||||
if (!utility || utility.id === 'alerts' || !utility.count || utility.count <= 0) return null;
|
||||
return utility.count;
|
||||
};
|
||||
const Icon = tab().icon;
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface pb-safe xl:hidden"
|
||||
role="tablist"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div class="flex min-w-0 items-stretch">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<div
|
||||
ref={mobileNav.setNavRef}
|
||||
data-mobile-nav-rail="primary"
|
||||
class="flex h-full min-w-0 items-center gap-1 overflow-x-auto scrollbar-hide px-2 py-1.5 sm:gap-2 sm:px-4"
|
||||
<span class="relative flex shrink-0 items-center justify-center">
|
||||
<span aria-hidden="true" class="inline-flex items-center justify-center">
|
||||
<Icon class={props.iconClass} />
|
||||
</span>
|
||||
<Show when={alertBadges()}>
|
||||
{(badges) => (
|
||||
<span aria-hidden="true" class="absolute -right-2 -top-1 flex items-center gap-1">
|
||||
<Show when={badges().critical > 0}>
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
|
||||
{badges().critical}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={badges().warning > 0}>
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900">
|
||||
{badges().warning}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={genericCount()}>
|
||||
{(count) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -right-2 -top-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900"
|
||||
>
|
||||
<For each={mobileNav.orderedPrimaryTabs()}>
|
||||
{(tab) => {
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-tab-id={tab.id}
|
||||
onClick={() => mobileNav.handlePrimaryClick(tab)}
|
||||
title={tab.tooltip}
|
||||
class={getMobileNavTabButtonClass({
|
||||
active: props.activeTab() === tab.id,
|
||||
enabled: tab.enabled,
|
||||
})}
|
||||
>
|
||||
<span aria-hidden="true" class="relative flex items-center justify-center">
|
||||
<Icon class={tabIconClass} />
|
||||
</span>
|
||||
<span class="whitespace-nowrap">{tab.label}</span>
|
||||
<Show when={!tab.enabled}>
|
||||
<span class="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:bg-amber-900 dark:text-amber-200">
|
||||
Setup
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tab.badge}>
|
||||
<span class="rounded-full bg-surface-hover px-1.5 py-0.5 text-[9px] font-semibold text-muted">
|
||||
{tab.badge}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={mobileNav.showLeftFade()}>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-surface to-transparent"></div>
|
||||
</Show>
|
||||
<Show when={mobileNav.showFade()}>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-surface to-transparent"></div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-mobile-nav-rail="utility"
|
||||
class="relative z-10 flex shrink-0 items-center gap-0.5 border-l border-border bg-surface px-1 py-1.5"
|
||||
>
|
||||
<For each={mobileNav.orderedUtilityTabs()}>
|
||||
{(tab) => {
|
||||
const alertBadges = () => getMobileNavAlertBadgeCounts(tab);
|
||||
const genericCount = () =>
|
||||
tab.id === 'alerts' || !tab.count || tab.count <= 0 ? null : tab.count;
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-tab-id={tab.id}
|
||||
onClick={() => mobileNav.handleUtilityClick(tab)}
|
||||
title={tab.tooltip}
|
||||
aria-label={getMobileNavTabAriaLabel(tab)}
|
||||
class={getMobileNavTabButtonClass({
|
||||
active: props.activeTab() === tab.id,
|
||||
})}
|
||||
>
|
||||
<span class="relative flex items-center justify-center">
|
||||
<span aria-hidden="true" class="inline-flex items-center justify-center">
|
||||
<Icon class={tabIconClass} />
|
||||
</span>
|
||||
<Show when={alertBadges()}>
|
||||
{(badges) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -right-2 -top-1 flex items-center gap-1"
|
||||
>
|
||||
<Show when={badges().critical > 0}>
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
|
||||
{badges().critical}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={badges().warning > 0}>
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900">
|
||||
{badges().warning}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={genericCount()}>
|
||||
{(count) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -right-2 -top-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900"
|
||||
>
|
||||
{count()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
<span class="whitespace-nowrap">{tab.label}</span>
|
||||
<Show when={tab.badge === 'update'}>
|
||||
<span class="mt-0.5 h-1.5 w-1.5 rounded-full bg-red-500"></span>
|
||||
</Show>
|
||||
<Show when={tab.badge === 'pro'}>
|
||||
<span class="rounded-full bg-blue-100 px-1.5 py-0.5 text-[9px] font-semibold text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
Pro
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{count()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
<span class="min-w-0 truncate">{tab().label}</span>
|
||||
<Show when={props.destination.kind === 'primary' && !props.destination.tab.enabled}>
|
||||
<span class="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:bg-amber-900 dark:text-amber-200">
|
||||
Setup
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.destination.kind === 'primary' && props.destination.tab.badge}>
|
||||
<span class="rounded-full bg-surface-hover px-1.5 py-0.5 text-[9px] font-semibold text-muted">
|
||||
{props.destination.kind === 'primary' ? props.destination.tab.badge : null}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={utilityTab()?.badge === 'update'}>
|
||||
<span aria-hidden="true" class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
|
||||
</Show>
|
||||
<Show when={utilityTab()?.badge === 'pro'}>
|
||||
<span class="rounded-full bg-blue-100 px-1.5 py-0.5 text-[9px] font-semibold text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
Pro
|
||||
</span>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileNavBar(props: MobileNavBarProps) {
|
||||
const mobileNav = useMobileNavBarState(props);
|
||||
const tabIconClass = 'h-4 w-4 shrink-0';
|
||||
const overflowHasBadge = () =>
|
||||
mobileNav.overflowDestinations().some(mobileNavDestinationHasBadge);
|
||||
|
||||
const renderOverflowGroup = (options: {
|
||||
label: string;
|
||||
destinations: () => MobileNavBarDestination[];
|
||||
}) => (
|
||||
<Show when={options.destinations().length > 0}>
|
||||
<div role="group" aria-label={options.label}>
|
||||
<div class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
{options.label}
|
||||
</div>
|
||||
<For each={options.destinations()}>
|
||||
{(destination) => {
|
||||
const active = () => isMobileNavDestinationActive(destination, props.activeTab());
|
||||
const enabled = () =>
|
||||
destination.kind === 'primary' ? destination.tab.enabled : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
data-tab-id={destination.tab.id}
|
||||
data-mobile-nav-destination={getMobileNavDestinationKey(destination)}
|
||||
aria-current={active() ? 'page' : undefined}
|
||||
aria-label={
|
||||
destination.kind === 'utility'
|
||||
? getMobileNavTabAriaLabel(destination.tab)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => mobileNav.handleDestinationClick(destination)}
|
||||
title={destination.tab.tooltip}
|
||||
class={`flex min-h-11 w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
active()
|
||||
? 'bg-blue-50 font-semibold text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||
: 'text-base-content hover:bg-surface-hover'
|
||||
} ${enabled() === false ? 'opacity-70' : ''}`.trim()}
|
||||
>
|
||||
<MobileNavDestinationContent destination={destination} iconClass={tabIconClass} />
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface pb-safe xl:hidden"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<Show when={mobileNav.isOverflowOpen()}>
|
||||
<div
|
||||
id={MOBILE_NAV_OVERFLOW_ID}
|
||||
ref={mobileNav.setOverflowMenuRef}
|
||||
role="menu"
|
||||
aria-label="More navigation destinations"
|
||||
onKeyDown={mobileNav.handleOverflowMenuKeyDown}
|
||||
class="absolute inset-x-2 bottom-full mb-2 max-h-[70vh] overflow-y-auto rounded-lg border border-border bg-surface p-1 shadow-xl"
|
||||
>
|
||||
{renderOverflowGroup({
|
||||
label: 'Infrastructure',
|
||||
destinations: mobileNav.overflowPrimaryDestinations,
|
||||
})}
|
||||
{renderOverflowGroup({
|
||||
label: 'Pulse',
|
||||
destinations: mobileNav.overflowUtilityDestinations,
|
||||
})}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div data-mobile-nav-rail="fixed" class="flex min-w-0 items-stretch gap-0.5 px-1 py-1.5">
|
||||
<For each={mobileNav.fixedDestinations()}>
|
||||
{(destination) => {
|
||||
const active = () => isMobileNavDestinationActive(destination, props.activeTab());
|
||||
const enabled = () =>
|
||||
destination.kind === 'primary' ? destination.tab.enabled : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-tab-id={destination.tab.id}
|
||||
data-mobile-nav-destination={getMobileNavDestinationKey(destination)}
|
||||
aria-current={active() ? 'page' : undefined}
|
||||
aria-label={
|
||||
destination.kind === 'utility'
|
||||
? getMobileNavTabAriaLabel(destination.tab)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => mobileNav.handleDestinationClick(destination)}
|
||||
title={destination.tab.tooltip}
|
||||
class={getMobileNavTabButtonClass({ active: active(), enabled: enabled() })}
|
||||
>
|
||||
<MobileNavDestinationContent destination={destination} iconClass={tabIconClass} />
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={mobileNav.overflowDestinations().length > 0}>
|
||||
<button
|
||||
ref={mobileNav.setOverflowTriggerRef}
|
||||
type="button"
|
||||
data-tab-id="more"
|
||||
aria-label="More navigation"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={mobileNav.isOverflowOpen()}
|
||||
aria-controls={MOBILE_NAV_OVERFLOW_ID}
|
||||
aria-current={mobileNav.overflowIsActive() ? 'page' : undefined}
|
||||
onClick={mobileNav.handleOverflowTriggerClick}
|
||||
onKeyDown={mobileNav.handleOverflowTriggerKeyDown}
|
||||
title="More navigation destinations"
|
||||
class={getMobileNavTabButtonClass({ active: mobileNav.overflowIsActive() })}
|
||||
>
|
||||
<span class="relative flex items-center justify-center">
|
||||
<EllipsisIcon aria-hidden="true" class={tabIconClass} />
|
||||
<Show when={overflowHasBadge()}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -right-1.5 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-surface"
|
||||
></span>
|
||||
</Show>
|
||||
</span>
|
||||
<span>More</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileNavBar;
|
||||
|
||||
@@ -7794,15 +7794,15 @@ describe('shared primitive guardrails', () => {
|
||||
expect(mobileNavBarSource).not.toContain('new Set(priority)');
|
||||
|
||||
expect(mobileNavBarStateSource).toContain('createSignal');
|
||||
expect(mobileNavBarStateSource).toContain('window.addEventListener');
|
||||
expect(mobileNavBarStateSource).toContain('requestAnimationFrame');
|
||||
expect(mobileNavBarStateSource).toContain('scrollIntoView');
|
||||
expect(mobileNavBarStateSource).toContain('document.addEventListener');
|
||||
expect(mobileNavBarStateSource).toContain('handleOverflowMenuKeyDown');
|
||||
expect(mobileNavBarStateSource).not.toContain('scrollIntoView');
|
||||
expect(mobileNavBarStateSource).toContain('export function useMobileNavBarState');
|
||||
|
||||
expect(mobileNavBarModelSource).toContain('buildMobileNavBarLayout');
|
||||
expect(mobileNavBarModelSource).toContain('buildOrderedMobileNavPrimaryTabs');
|
||||
expect(mobileNavBarModelSource).toContain('buildOrderedMobileNavUtilityTabs');
|
||||
expect(mobileNavBarModelSource).toContain('getMobileNavAlertBadgeCounts');
|
||||
expect(mobileNavBarModelSource).toContain('getMobileNavFadeState');
|
||||
});
|
||||
|
||||
it('keeps pulse data grid on shell, runtime, and model owners', () => {
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createSignal, type Component } from 'solid-js';
|
||||
import { type Component } from 'solid-js';
|
||||
import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw';
|
||||
import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw';
|
||||
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
|
||||
import { MobileNavBar } from '@/components/shared/MobileNavBar';
|
||||
import {
|
||||
MobileNavBar,
|
||||
type MobileNavBarPrimaryTab,
|
||||
type MobileNavBarUtilityTab,
|
||||
} from '@/components/shared/MobileNavBar';
|
||||
|
||||
// Records the element each call landed on, so tests can assert the rail centred
|
||||
// the ACTIVE tab rather than merely that scrollIntoView was called at all.
|
||||
const scrollIntoViewCalls: Array<{ element: HTMLElement; options: unknown }> = [];
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: boolean | ScrollIntoViewOptions,
|
||||
) {
|
||||
scrollIntoViewCalls.push({ element: this, options });
|
||||
});
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
|
||||
const StandaloneIcon: Component<{ class?: string }> = (props) => (
|
||||
<span class={props.class}>SA</span>
|
||||
);
|
||||
const ProxmoxIcon: Component<{ class?: string }> = (props) => <span class={props.class}>PX</span>;
|
||||
const AlertsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>AL</span>;
|
||||
const ActionsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>AC</span>;
|
||||
const SettingsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>SE</span>;
|
||||
const TextIcon: Component<{ class?: string }> = (props) => <span class={props.class}>Icon</span>;
|
||||
const PatrolIcon: Component<{ class?: string }> = (props) => (
|
||||
<svg aria-label="Pulse Patrol" class={props.class} viewBox="0 0 24 24">
|
||||
<title>Pulse Patrol</title>
|
||||
@@ -34,26 +18,63 @@ const PatrolIcon: Component<{ class?: string }> = (props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
function makePrimary(
|
||||
id: string,
|
||||
label: string,
|
||||
overrides: Partial<MobileNavBarPrimaryTab> = {},
|
||||
): MobileNavBarPrimaryTab {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
route: `/${id}/overview`,
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: label,
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: TextIcon,
|
||||
alwaysShow: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUtility(
|
||||
id: MobileNavBarUtilityTab['id'],
|
||||
label: string,
|
||||
overrides: Partial<MobileNavBarUtilityTab> = {},
|
||||
): MobileNavBarUtilityTab {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
route: id === 'ai' ? '/patrol' : `/${id}`,
|
||||
tooltip: label,
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: id === 'ai' ? PatrolIcon : TextIcon,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MobileNavBar', () => {
|
||||
it('keeps the mobile nav on shell, runtime, and model owners', () => {
|
||||
expect(mobileNavBarSource).toContain('useMobileNavBarState');
|
||||
expect(mobileNavBarSource).toContain('getMobileNavTabButtonClass');
|
||||
expect(mobileNavBarSource).toContain('role="menu"');
|
||||
expect(mobileNavBarSource).toContain('pb-safe xl:hidden');
|
||||
expect(mobileNavBarSource).not.toContain('pb-safe lg:hidden');
|
||||
expect(mobileNavBarSource).not.toContain('createSignal');
|
||||
expect(mobileNavBarSource).not.toContain('requestAnimationFrame');
|
||||
expect(mobileNavBarSource).not.toContain('new Set(priority)');
|
||||
expect(mobileNavBarSource).not.toContain('overflow-x-auto');
|
||||
|
||||
expect(mobileNavBarStateSource).toContain('createSignal');
|
||||
expect(mobileNavBarStateSource).toContain('window.addEventListener');
|
||||
expect(mobileNavBarStateSource).toContain('requestAnimationFrame');
|
||||
expect(mobileNavBarStateSource).toContain('scrollIntoView');
|
||||
expect(mobileNavBarStateSource).toContain("document.addEventListener('pointerdown'");
|
||||
expect(mobileNavBarStateSource).toContain("event.key === 'Escape'");
|
||||
expect(mobileNavBarStateSource).toContain('export function useMobileNavBarState');
|
||||
expect(mobileNavBarStateSource).not.toContain('scrollIntoView');
|
||||
|
||||
expect(mobileNavBarModelSource).toContain('buildMobileNavBarLayout');
|
||||
expect(mobileNavBarModelSource).toContain('buildOrderedMobileNavPrimaryTabs');
|
||||
expect(mobileNavBarModelSource).toContain('buildOrderedMobileNavUtilityTabs');
|
||||
expect(mobileNavBarModelSource).toContain('getMobileNavAlertBadgeCounts');
|
||||
expect(mobileNavBarModelSource).toContain('getMobileNavFadeState');
|
||||
expect(mobileNavBarModelSource).toContain("'proxmox'");
|
||||
expect(mobileNavBarModelSource).toContain("'docker'");
|
||||
expect(mobileNavBarModelSource).toContain("'kubernetes'");
|
||||
@@ -62,380 +83,182 @@ describe('MobileNavBar', () => {
|
||||
expect(mobileNavBarModelSource).not.toContain("'workloads'");
|
||||
expect(mobileNavBarModelSource).not.toContain("'storage'");
|
||||
expect(mobileNavBarModelSource).not.toContain("'recovery'");
|
||||
expect(mobileNavBarModelSource).not.toContain("'infrastructure'");
|
||||
});
|
||||
|
||||
it('keeps decorative icon labels out of mobile tab accessible names', () => {
|
||||
render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => 'ai'}
|
||||
primaryTabs={() => []}
|
||||
utilityTabs={() => [
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'Patrol',
|
||||
route: '/patrol',
|
||||
tooltip: 'Continuous verification',
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: PatrolIcon,
|
||||
},
|
||||
]}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
const navList = screen.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
const patrolButton = within(navList).getByRole('button', { name: 'Patrol' });
|
||||
|
||||
expect(patrolButton).toHaveAttribute('data-tab-id', 'ai');
|
||||
expect(within(navList).queryByRole('button', { name: 'Pulse Patrol Patrol' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders Patrol open-work counts without renaming the mobile tab', () => {
|
||||
render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => 'ai'}
|
||||
primaryTabs={() => []}
|
||||
utilityTabs={() => [
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'Patrol',
|
||||
route: '/patrol',
|
||||
tooltip: 'Review Patrol checks, findings, and approvals',
|
||||
badge: null,
|
||||
count: 2,
|
||||
countLabel: '2 open work items',
|
||||
breakdown: undefined,
|
||||
icon: PatrolIcon,
|
||||
},
|
||||
]}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
const navList = screen.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
const patrolButton = within(navList).getByRole('button', {
|
||||
name: 'Patrol: 2 open work items',
|
||||
});
|
||||
|
||||
expect(patrolButton).toHaveAttribute('data-tab-id', 'ai');
|
||||
expect(within(patrolButton).getByText('Patrol')).toBeInTheDocument();
|
||||
expect(within(patrolButton).getByText('2')).toBeInTheDocument();
|
||||
expect(within(navList).queryByText('Needs Attention')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps Actions named, reachable, and route-correct among monitor-first destinations', () => {
|
||||
const onUtilityClick = vi.fn();
|
||||
const utilityTabs = [
|
||||
{
|
||||
id: 'settings' as const,
|
||||
label: 'Settings',
|
||||
route: '/settings',
|
||||
tooltip: 'Settings',
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: 'ai' as const,
|
||||
label: 'Patrol',
|
||||
route: '/patrol',
|
||||
tooltip: 'Patrol',
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: PatrolIcon,
|
||||
},
|
||||
{
|
||||
id: 'actions' as const,
|
||||
label: 'Actions',
|
||||
route: '/actions',
|
||||
tooltip: 'Review actions',
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: ActionsIcon,
|
||||
},
|
||||
{
|
||||
id: 'alerts' as const,
|
||||
label: 'Alerts',
|
||||
route: '/alerts',
|
||||
tooltip: 'Alerts',
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: AlertsIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => 'actions'}
|
||||
primaryTabs={() => []}
|
||||
utilityTabs={() => utilityTabs}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={onUtilityClick}
|
||||
/>
|
||||
));
|
||||
|
||||
const navList = screen.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
const actionsButton = within(navList).getByRole('button', { name: 'Actions' });
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('button[data-tab-id]')).map((button) =>
|
||||
button.getAttribute('data-tab-id'),
|
||||
),
|
||||
).toEqual(['alerts', 'actions', 'ai', 'settings']);
|
||||
expect(actionsButton).toHaveAttribute('data-tab-id', 'actions');
|
||||
expect(actionsButton).toHaveAttribute('type', 'button');
|
||||
actionsButton.focus();
|
||||
expect(actionsButton).toHaveFocus();
|
||||
fireEvent.click(actionsButton);
|
||||
expect(onUtilityClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
route: '/actions',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows inactive platform tabs to render without an active mobile tab', () => {
|
||||
const { container } = render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => null}
|
||||
primaryTabs={() => [
|
||||
{
|
||||
id: 'standalone',
|
||||
label: 'Machines',
|
||||
route: '/standalone/machines',
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: 'Machines',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: StandaloneIcon,
|
||||
alwaysShow: true,
|
||||
},
|
||||
]}
|
||||
utilityTabs={() => [
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
route: '/settings',
|
||||
tooltip: 'Settings',
|
||||
badge: null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
]}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
const buttons = container.querySelectorAll('button[data-tab-id]');
|
||||
expect(buttons).toHaveLength(2);
|
||||
buttons.forEach((button) => {
|
||||
expect(button).not.toHaveClass('bg-blue-50');
|
||||
expect(button).not.toHaveClass('text-blue-700');
|
||||
});
|
||||
});
|
||||
|
||||
it('brings the active platform tab into view on mount', () => {
|
||||
scrollIntoViewCalls.length = 0;
|
||||
|
||||
it('renders one platform and the daily operations set with every other route in More', async () => {
|
||||
const { container } = render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => 'standalone'}
|
||||
primaryTabs={() => [
|
||||
{
|
||||
id: 'proxmox',
|
||||
label: 'Proxmox',
|
||||
route: '/proxmox/overview',
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: 'Proxmox',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: ProxmoxIcon,
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
id: 'standalone',
|
||||
label: 'Machines',
|
||||
route: '/standalone/machines',
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: 'Machines',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: StandaloneIcon,
|
||||
alwaysShow: true,
|
||||
},
|
||||
makePrimary('standalone', 'Machines'),
|
||||
makePrimary('proxmox', 'Proxmox'),
|
||||
]}
|
||||
utilityTabs={() => [
|
||||
makeUtility('settings', 'Settings'),
|
||||
makeUtility('ai', 'Patrol'),
|
||||
makeUtility('actions', 'Actions'),
|
||||
makeUtility('alerts', 'Alerts'),
|
||||
]}
|
||||
utilityTabs={() => []}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
const activeButton = container.querySelector('button[data-tab-id="standalone"]');
|
||||
expect(activeButton).toBeTruthy();
|
||||
expect(scrollIntoViewCalls.map((call) => call.element)).toContain(activeButton);
|
||||
expect(scrollIntoViewCalls.at(-1)?.options).toEqual(
|
||||
expect.objectContaining({ inline: 'center' }),
|
||||
const fixedRail = container.querySelector('[data-mobile-nav-rail="fixed"]');
|
||||
expect(fixedRail).toBeTruthy();
|
||||
expect(
|
||||
Array.from(fixedRail?.querySelectorAll('button[data-tab-id]') ?? []).map((button) =>
|
||||
button.getAttribute('data-tab-id'),
|
||||
),
|
||||
).toEqual(['proxmox', 'alerts', 'actions', 'ai', 'more']);
|
||||
|
||||
const more = screen.getByRole('button', { name: 'More navigation' });
|
||||
expect(more).toHaveAttribute('aria-haspopup', 'menu');
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(more).toHaveAttribute('aria-current', 'page');
|
||||
fireEvent.click(more);
|
||||
|
||||
const menu = await screen.findByRole('menu', { name: 'More navigation destinations' });
|
||||
expect(more).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(within(menu).getByRole('group', { name: 'Infrastructure' })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole('group', { name: 'Pulse' })).toBeInTheDocument();
|
||||
expect(
|
||||
within(menu)
|
||||
.getAllByRole('menuitem')
|
||||
.map((item) => item.getAttribute('data-tab-id')),
|
||||
).toEqual(['standalone', 'settings']);
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Machines' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
});
|
||||
|
||||
it('re-centres the rail when the active platform tab changes', async () => {
|
||||
scrollIntoViewCalls.length = 0;
|
||||
const [activeTab, setActiveTab] = createSignal<string | null>('proxmox');
|
||||
|
||||
const { container } = render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={activeTab}
|
||||
primaryTabs={() => [
|
||||
{
|
||||
id: 'proxmox',
|
||||
label: 'Proxmox',
|
||||
route: '/proxmox/overview',
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: 'Proxmox',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: ProxmoxIcon,
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
id: 'standalone',
|
||||
label: 'Machines',
|
||||
route: '/standalone/machines',
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: 'Machines',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: StandaloneIcon,
|
||||
alwaysShow: true,
|
||||
},
|
||||
]}
|
||||
utilityTabs={() => []}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
const machinesButton = container.querySelector('button[data-tab-id="standalone"]');
|
||||
expect(scrollIntoViewCalls.map((call) => call.element)).not.toContain(machinesButton);
|
||||
|
||||
setActiveTab('standalone');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollIntoViewCalls.map((call) => call.element)).toContain(machinesButton);
|
||||
});
|
||||
});
|
||||
|
||||
it('orders tabs, renders alert badges, and shows fades from scroll state', async () => {
|
||||
it('preserves route callbacks for fixed and overflow destinations', async () => {
|
||||
const onPrimaryClick = vi.fn();
|
||||
const onUtilityClick = vi.fn();
|
||||
|
||||
const { container } = render(() => (
|
||||
render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => 'proxmox'}
|
||||
primaryTabs={() => [
|
||||
{
|
||||
id: 'standalone',
|
||||
label: 'Machines',
|
||||
route: '/standalone/machines',
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: 'Machines',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: StandaloneIcon,
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
id: 'proxmox',
|
||||
label: 'Proxmox',
|
||||
route: '/proxmox/overview',
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: 'Proxmox',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: ProxmoxIcon,
|
||||
alwaysShow: true,
|
||||
},
|
||||
]}
|
||||
utilityTabs={() => [
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
route: '/settings',
|
||||
tooltip: 'Settings',
|
||||
badge: 'pro',
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
label: 'Alerts',
|
||||
route: '/alerts',
|
||||
tooltip: 'Alerts',
|
||||
badge: null,
|
||||
count: 5,
|
||||
breakdown: { critical: 2, warning: 3 },
|
||||
icon: AlertsIcon,
|
||||
},
|
||||
makePrimary('proxmox', 'Proxmox'),
|
||||
makePrimary('standalone', 'Machines'),
|
||||
]}
|
||||
utilityTabs={() => [makeUtility('alerts', 'Alerts'), makeUtility('settings', 'Settings')]}
|
||||
onPrimaryClick={onPrimaryClick}
|
||||
onUtilityClick={onUtilityClick}
|
||||
/>
|
||||
));
|
||||
|
||||
const primaryRail = container.querySelector('[data-mobile-nav-rail="primary"]');
|
||||
const utilityRail = container.querySelector('[data-mobile-nav-rail="utility"]');
|
||||
expect(primaryRail).toBeTruthy();
|
||||
expect(utilityRail).toBeTruthy();
|
||||
Object.defineProperty(primaryRail as Element, 'scrollWidth', {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(primaryRail as Element, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 200,
|
||||
});
|
||||
Object.defineProperty(primaryRail as Element, 'scrollLeft', {
|
||||
configurable: true,
|
||||
value: 20,
|
||||
writable: true,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Alerts' }));
|
||||
expect(onUtilityClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'alerts', route: '/alerts' }),
|
||||
);
|
||||
|
||||
fireEvent.scroll(primaryRail as Element);
|
||||
const more = screen.getByRole('button', { name: 'More navigation' });
|
||||
fireEvent.click(more);
|
||||
const menu = await screen.findByRole('menu');
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Machines' }));
|
||||
expect(onPrimaryClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'standalone', route: '/standalone/overview' }),
|
||||
);
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
|
||||
const buttons = container.querySelectorAll('button[data-tab-id]');
|
||||
expect(buttons[0]).toHaveAttribute('data-tab-id', 'proxmox');
|
||||
expect(buttons[1]).toHaveAttribute('data-tab-id', 'standalone');
|
||||
expect(buttons[2]).toHaveAttribute('data-tab-id', 'alerts');
|
||||
expect(buttons[3]).toHaveAttribute('data-tab-id', 'settings');
|
||||
fireEvent.click(more);
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Settings' }));
|
||||
expect(onUtilityClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'settings', route: '/settings' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
expect(within(utilityRail as HTMLElement).getByTitle('Alerts')).toBeInTheDocument();
|
||||
expect(within(utilityRail as HTMLElement).getByTitle('Settings')).toBeInTheDocument();
|
||||
it('preserves fixed and overflow badges and keeps icon labels decorative', async () => {
|
||||
render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => 'ai'}
|
||||
primaryTabs={() => [
|
||||
makePrimary('proxmox', 'Proxmox'),
|
||||
makePrimary('standalone', 'Machines', { enabled: false }),
|
||||
]}
|
||||
utilityTabs={() => [
|
||||
makeUtility('alerts', 'Alerts', {
|
||||
count: 5,
|
||||
breakdown: { critical: 2, warning: 3 },
|
||||
}),
|
||||
makeUtility('ai', 'Patrol', { count: 2, countLabel: '2 open work items' }),
|
||||
makeUtility('settings', 'Settings', { badge: 'update' }),
|
||||
]}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
fireEvent.click(screen.getByTitle('Machines'));
|
||||
expect(onPrimaryClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'standalone' }));
|
||||
const nav = screen.getByRole('navigation', { name: 'Mobile navigation' });
|
||||
expect(
|
||||
within(nav).getByRole('button', { name: 'Alerts: 2 critical, 3 warning' }),
|
||||
).toHaveTextContent('23');
|
||||
expect(
|
||||
within(nav).getByRole('button', { name: 'Patrol: 2 open work items' }),
|
||||
).toHaveTextContent('Patrol2');
|
||||
expect(within(nav).queryByRole('button', { name: 'Pulse Patrol Patrol' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Alerts'));
|
||||
expect(onUtilityClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'alerts' }));
|
||||
fireEvent.click(within(nav).getByRole('button', { name: 'More navigation' }));
|
||||
const menu = await screen.findByRole('menu');
|
||||
expect(within(menu).getByRole('menuitem', { name: /Machines/ })).toHaveTextContent(
|
||||
'MachinesSetup',
|
||||
);
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.bg-gradient-to-r')).toBeTruthy();
|
||||
expect(container.querySelector('.bg-gradient-to-l')).toBeTruthy();
|
||||
it('supports menu arrow keys, Escape focus return, and outside dismissal', async () => {
|
||||
render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => 'proxmox'}
|
||||
primaryTabs={() => [
|
||||
makePrimary('proxmox', 'Proxmox'),
|
||||
makePrimary('standalone', 'Machines'),
|
||||
]}
|
||||
utilityTabs={() => [makeUtility('alerts', 'Alerts'), makeUtility('settings', 'Settings')]}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
const more = screen.getByRole('button', { name: 'More navigation' });
|
||||
more.focus();
|
||||
fireEvent.keyDown(more, { key: 'ArrowDown' });
|
||||
const menu = await screen.findByRole('menu');
|
||||
const items = within(menu).getAllByRole('menuitem');
|
||||
await waitFor(() => expect(items[0]).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(items[0], { key: 'ArrowDown' });
|
||||
expect(items[1]).toHaveFocus();
|
||||
fireEvent.keyDown(items[1], { key: 'Home' });
|
||||
expect(items[0]).toHaveFocus();
|
||||
fireEvent.keyDown(items[0], { key: 'End' });
|
||||
expect(items[1]).toHaveFocus();
|
||||
fireEvent.keyDown(items[1], { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
|
||||
expect(more).toHaveFocus();
|
||||
|
||||
fireEvent.click(more);
|
||||
await screen.findByRole('menu');
|
||||
fireEvent.pointerDown(document.body);
|
||||
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
|
||||
});
|
||||
|
||||
it('omits More when every supplied destination fits the fixed set', () => {
|
||||
render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => null}
|
||||
primaryTabs={() => [makePrimary('proxmox', 'Proxmox')]}
|
||||
utilityTabs={() => [makeUtility('alerts', 'Alerts'), makeUtility('ai', 'Patrol')]}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'More navigation' })).toBeNull();
|
||||
screen.getAllByRole('button').forEach((button) => {
|
||||
expect(button).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+66
-73
@@ -1,13 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildMobileNavBarLayout,
|
||||
buildOrderedMobileNavTabs,
|
||||
getMobileNavAlertBadgeCounts,
|
||||
getMobileNavFadeState,
|
||||
getMobileNavTabAriaLabel,
|
||||
getMobileNavTabButtonClass,
|
||||
} from '@/components/shared/mobileNavBarModel';
|
||||
import type {
|
||||
MobileNavBarIcon,
|
||||
MobileNavBarPrimaryTab,
|
||||
MobileNavBarUtilityTab,
|
||||
} from '@/components/shared/mobileNavBarModel';
|
||||
|
||||
@@ -31,6 +32,24 @@ function makeUtilityTab(overrides: Partial<MobileNavBarUtilityTab> = {}): Mobile
|
||||
};
|
||||
}
|
||||
|
||||
function makePrimaryTab(
|
||||
id: string,
|
||||
overrides: Partial<MobileNavBarPrimaryTab> = {},
|
||||
): MobileNavBarPrimaryTab {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
route: `/${id}`,
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: id,
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: noopIcon,
|
||||
alwaysShow: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mobileNavBarModel.branchcov2', () => {
|
||||
describe('getMobileNavAlertBadgeCounts', () => {
|
||||
it('returns null for a non-alerts tab via the id guard (true arm)', () => {
|
||||
@@ -199,9 +218,54 @@ describe('mobileNavBarModel.branchcov2', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMobileNavBarLayout', () => {
|
||||
it('keeps one priority-ordered platform and daily operations fixed', () => {
|
||||
const layout = buildMobileNavBarLayout(
|
||||
[makePrimaryTab('standalone'), makePrimaryTab('docker'), makePrimaryTab('proxmox')],
|
||||
[
|
||||
makeUtilityTab({ id: 'settings' }),
|
||||
makeUtilityTab({ id: 'ai' }),
|
||||
makeUtilityTab({ id: 'actions' }),
|
||||
makeUtilityTab({ id: 'alerts' }),
|
||||
],
|
||||
);
|
||||
|
||||
expect(layout.fixedDestinations.map((destination) => destination.tab.id)).toEqual([
|
||||
'proxmox',
|
||||
'alerts',
|
||||
'actions',
|
||||
'ai',
|
||||
]);
|
||||
expect(layout.overflowDestinations.map((destination) => destination.tab.id)).toEqual([
|
||||
'docker',
|
||||
'standalone',
|
||||
'settings',
|
||||
]);
|
||||
expect(layout.overflowDestinations.map((destination) => destination.kind)).toEqual([
|
||||
'primary',
|
||||
'primary',
|
||||
'utility',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not invent an overflow destination when all supplied tabs are fixed', () => {
|
||||
const layout = buildMobileNavBarLayout(
|
||||
[makePrimaryTab('proxmox')],
|
||||
[makeUtilityTab({ id: 'alerts' }), makeUtilityTab({ id: 'ai' })],
|
||||
);
|
||||
|
||||
expect(layout.fixedDestinations.map((destination) => destination.tab.id)).toEqual([
|
||||
'proxmox',
|
||||
'alerts',
|
||||
'ai',
|
||||
]);
|
||||
expect(layout.overflowDestinations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMobileNavTabButtonClass', () => {
|
||||
const BASE =
|
||||
'relative flex min-h-10 shrink-0 select-none flex-col items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors';
|
||||
'relative flex min-h-12 min-w-0 flex-1 select-none flex-col items-center justify-center gap-1 rounded-md px-1 py-1.5 text-[11px] font-medium transition-colors';
|
||||
const ACTIVE = 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-300';
|
||||
const MUTED = 'text-muted';
|
||||
|
||||
@@ -229,75 +293,4 @@ describe('mobileNavBarModel.branchcov2', () => {
|
||||
expect(getMobileNavTabButtonClass({ active: true, enabled: true })).toBe(`${BASE} ${ACTIVE}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMobileNavFadeState', () => {
|
||||
// Helper: jsdom reports 0 for all scroll geometry by default, so define the
|
||||
// readonly scroll metrics as configurable own properties on the instance.
|
||||
function makeScrollEl(props: {
|
||||
scrollWidth: number;
|
||||
clientWidth: number;
|
||||
scrollLeft: number;
|
||||
}): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
Object.defineProperty(el, 'scrollWidth', { configurable: true, value: props.scrollWidth });
|
||||
Object.defineProperty(el, 'clientWidth', { configurable: true, value: props.clientWidth });
|
||||
Object.defineProperty(el, 'scrollLeft', { configurable: true, value: props.scrollLeft });
|
||||
return el;
|
||||
}
|
||||
|
||||
it('returns both fades false when the element is undefined (early-return guard)', () => {
|
||||
expect(getMobileNavFadeState(undefined)).toStrictEqual({
|
||||
showLeftFade: false,
|
||||
showRightFade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns both fades false when there is no scrollable overflow (maxScrollLeft <= 1)', () => {
|
||||
const el = makeScrollEl({ scrollWidth: 100, clientWidth: 100, scrollLeft: 0 });
|
||||
expect(getMobileNavFadeState(el)).toStrictEqual({
|
||||
showLeftFade: false,
|
||||
showRightFade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the right fade at the left edge (scrollLeft > 1 false)', () => {
|
||||
const el = makeScrollEl({ scrollWidth: 200, clientWidth: 100, scrollLeft: 0 });
|
||||
expect(getMobileNavFadeState(el)).toStrictEqual({
|
||||
showLeftFade: false,
|
||||
showRightFade: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats scrollLeft === 1 as not past the left threshold (> 1 strict boundary)', () => {
|
||||
const el = makeScrollEl({ scrollWidth: 200, clientWidth: 100, scrollLeft: 1 });
|
||||
expect(getMobileNavFadeState(el)).toStrictEqual({
|
||||
showLeftFade: false,
|
||||
showRightFade: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows both fades in the middle of the scroll range', () => {
|
||||
const el = makeScrollEl({ scrollWidth: 200, clientWidth: 100, scrollLeft: 50 });
|
||||
expect(getMobileNavFadeState(el)).toStrictEqual({
|
||||
showLeftFade: true,
|
||||
showRightFade: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the left fade at the far-right edge (scrollLeft < maxScrollLeft - 1 false)', () => {
|
||||
const el = makeScrollEl({ scrollWidth: 200, clientWidth: 100, scrollLeft: 100 });
|
||||
expect(getMobileNavFadeState(el)).toStrictEqual({
|
||||
showLeftFade: true,
|
||||
showRightFade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats scrollLeft === maxScrollLeft - 1 as already at the right edge (< strict boundary)', () => {
|
||||
const el = makeScrollEl({ scrollWidth: 200, clientWidth: 100, scrollLeft: 99 });
|
||||
expect(getMobileNavFadeState(el)).toStrictEqual({
|
||||
showLeftFade: true,
|
||||
showRightFade: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,15 @@ export type MobileNavBarProps = {
|
||||
onUtilityClick: (tab: MobileNavBarUtilityTab) => void;
|
||||
};
|
||||
|
||||
export type MobileNavBarDestination =
|
||||
| { kind: 'primary'; tab: MobileNavBarPrimaryTab }
|
||||
| { kind: 'utility'; tab: MobileNavBarUtilityTab };
|
||||
|
||||
export type MobileNavBarLayout = {
|
||||
fixedDestinations: MobileNavBarDestination[];
|
||||
overflowDestinations: MobileNavBarDestination[];
|
||||
};
|
||||
|
||||
const MOBILE_NAV_PRIMARY_PRIORITY = [
|
||||
'proxmox',
|
||||
'docker',
|
||||
@@ -45,6 +54,11 @@ const MOBILE_NAV_PRIMARY_PRIORITY = [
|
||||
] as const;
|
||||
|
||||
const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'actions', 'ai', 'settings'] as const;
|
||||
const MOBILE_NAV_FIXED_UTILITY_IDS = new Set<MobileNavBarUtilityTab['id']>([
|
||||
'alerts',
|
||||
'actions',
|
||||
'ai',
|
||||
]);
|
||||
|
||||
export function buildOrderedMobileNavTabs<T extends { id: string }>(
|
||||
tabs: T[],
|
||||
@@ -78,6 +92,56 @@ export function buildOrderedMobileNavUtilityTabs(
|
||||
return buildOrderedMobileNavTabs(tabs, MOBILE_NAV_UTILITY_PRIORITY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the bottom bar to five predictable targets at most: the primary
|
||||
* infrastructure destination, the three daily operations destinations, and
|
||||
* More. Every other admitted platform and utility remains available in More.
|
||||
*/
|
||||
export function buildMobileNavBarLayout(
|
||||
primaryTabs: MobileNavBarPrimaryTab[],
|
||||
utilityTabs: MobileNavBarUtilityTab[],
|
||||
): MobileNavBarLayout {
|
||||
const orderedPrimaryTabs = buildOrderedMobileNavPrimaryTabs(primaryTabs);
|
||||
const orderedUtilityTabs = buildOrderedMobileNavUtilityTabs(utilityTabs);
|
||||
const fixedPrimaryTabs = orderedPrimaryTabs.slice(0, 1);
|
||||
const fixedUtilityTabs = orderedUtilityTabs.filter((tab) =>
|
||||
MOBILE_NAV_FIXED_UTILITY_IDS.has(tab.id),
|
||||
);
|
||||
|
||||
return {
|
||||
fixedDestinations: [
|
||||
...fixedPrimaryTabs.map((tab): MobileNavBarDestination => ({ kind: 'primary', tab })),
|
||||
...fixedUtilityTabs.map((tab): MobileNavBarDestination => ({ kind: 'utility', tab })),
|
||||
],
|
||||
overflowDestinations: [
|
||||
...orderedPrimaryTabs
|
||||
.slice(1)
|
||||
.map((tab): MobileNavBarDestination => ({ kind: 'primary', tab })),
|
||||
...orderedUtilityTabs
|
||||
.filter((tab) => !MOBILE_NAV_FIXED_UTILITY_IDS.has(tab.id))
|
||||
.map((tab): MobileNavBarDestination => ({ kind: 'utility', tab })),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function getMobileNavDestinationKey(destination: MobileNavBarDestination): string {
|
||||
return `${destination.kind}:${destination.tab.id}`;
|
||||
}
|
||||
|
||||
export function isMobileNavDestinationActive(
|
||||
destination: MobileNavBarDestination,
|
||||
activeTab: string | null,
|
||||
): boolean {
|
||||
return destination.tab.id === activeTab;
|
||||
}
|
||||
|
||||
export function mobileNavDestinationHasBadge(destination: MobileNavBarDestination): boolean {
|
||||
if (destination.kind === 'primary') {
|
||||
return Boolean(destination.tab.badge || !destination.tab.enabled);
|
||||
}
|
||||
return Boolean(destination.tab.badge || (destination.tab.count && destination.tab.count > 0));
|
||||
}
|
||||
|
||||
export function getMobileNavAlertBadgeCounts(
|
||||
tab: MobileNavBarUtilityTab,
|
||||
): { critical: number; warning: number } | null {
|
||||
@@ -113,22 +177,7 @@ export function getMobileNavTabButtonClass(options: {
|
||||
active: boolean;
|
||||
enabled?: boolean;
|
||||
}): string {
|
||||
return `relative flex min-h-10 shrink-0 select-none flex-col items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors ${
|
||||
return `relative flex min-h-12 min-w-0 flex-1 select-none flex-col items-center justify-center gap-1 rounded-md px-1 py-1.5 text-[11px] font-medium transition-colors ${
|
||||
options.active ? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'text-muted'
|
||||
} ${options.enabled === false ? 'opacity-70' : ''}`.trim();
|
||||
}
|
||||
|
||||
export function getMobileNavFadeState(element: HTMLDivElement | undefined): {
|
||||
showLeftFade: boolean;
|
||||
showRightFade: boolean;
|
||||
} {
|
||||
if (!element) {
|
||||
return { showLeftFade: false, showRightFade: false };
|
||||
}
|
||||
|
||||
const maxScrollLeft = element.scrollWidth - element.clientWidth;
|
||||
return {
|
||||
showLeftFade: element.scrollLeft > 1,
|
||||
showRightFade: maxScrollLeft > 1 && element.scrollLeft < maxScrollLeft - 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
import {
|
||||
buildOrderedMobileNavPrimaryTabs,
|
||||
buildOrderedMobileNavUtilityTabs,
|
||||
getMobileNavFadeState,
|
||||
buildMobileNavBarLayout,
|
||||
isMobileNavDestinationActive,
|
||||
type MobileNavBarDestination,
|
||||
type MobileNavBarPrimaryTab,
|
||||
type MobileNavBarProps,
|
||||
type MobileNavBarUtilityTab,
|
||||
@@ -15,51 +15,80 @@ export type {
|
||||
} from './mobileNavBarModel';
|
||||
|
||||
export function useMobileNavBarState(props: MobileNavBarProps) {
|
||||
const [showFade, setShowFade] = createSignal(false);
|
||||
const [showLeftFade, setShowLeftFade] = createSignal(false);
|
||||
const [navRef, setNavRef] = createSignal<HTMLDivElement>();
|
||||
const [isOverflowOpen, setIsOverflowOpen] = createSignal(false);
|
||||
const [overflowTriggerRef, setOverflowTriggerRef] = createSignal<HTMLButtonElement>();
|
||||
const [overflowMenuRef, setOverflowMenuRef] = createSignal<HTMLDivElement>();
|
||||
|
||||
const orderedPrimaryTabs = createMemo(() =>
|
||||
buildOrderedMobileNavPrimaryTabs(props.primaryTabs()),
|
||||
const layout = createMemo(() =>
|
||||
buildMobileNavBarLayout(props.primaryTabs(), props.utilityTabs()),
|
||||
);
|
||||
const orderedUtilityTabs = createMemo(() =>
|
||||
buildOrderedMobileNavUtilityTabs(props.utilityTabs()),
|
||||
const fixedDestinations = createMemo(() => layout().fixedDestinations);
|
||||
const overflowDestinations = createMemo(() => layout().overflowDestinations);
|
||||
const overflowPrimaryDestinations = createMemo(() =>
|
||||
overflowDestinations().filter(
|
||||
(destination): destination is Extract<MobileNavBarDestination, { kind: 'primary' }> =>
|
||||
destination.kind === 'primary',
|
||||
),
|
||||
);
|
||||
const overflowUtilityDestinations = createMemo(() =>
|
||||
overflowDestinations().filter(
|
||||
(destination): destination is Extract<MobileNavBarDestination, { kind: 'utility' }> =>
|
||||
destination.kind === 'utility',
|
||||
),
|
||||
);
|
||||
const overflowIsActive = createMemo(() =>
|
||||
overflowDestinations().some((destination) =>
|
||||
isMobileNavDestinationActive(destination, props.activeTab()),
|
||||
),
|
||||
);
|
||||
|
||||
const updateFadeIndicator = () => {
|
||||
const fadeState = getMobileNavFadeState(navRef());
|
||||
setShowFade(fadeState.showRightFade);
|
||||
setShowLeftFade(fadeState.showLeftFade);
|
||||
const overflowMenuItems = () =>
|
||||
Array.from(overflowMenuRef()?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []);
|
||||
|
||||
const focusOverflowItem = (target: 'active' | 'first' | 'last') => {
|
||||
queueMicrotask(() => {
|
||||
const items = overflowMenuItems();
|
||||
if (items.length === 0) return;
|
||||
const item =
|
||||
target === 'last'
|
||||
? items.at(-1)
|
||||
: target === 'active'
|
||||
? items.find((candidate) => candidate.getAttribute('aria-current') === 'page')
|
||||
: undefined;
|
||||
(item ?? items[0])?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const openOverflow = (focusTarget: 'active' | 'first' | 'last' = 'active') => {
|
||||
setIsOverflowOpen(true);
|
||||
focusOverflowItem(focusTarget);
|
||||
};
|
||||
|
||||
const closeOverflow = (restoreFocus = false) => {
|
||||
setIsOverflowOpen(false);
|
||||
if (restoreFocus) {
|
||||
queueMicrotask(() => overflowTriggerRef()?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const nav = navRef();
|
||||
if (!nav) return;
|
||||
if (!isOverflowOpen()) return;
|
||||
|
||||
updateFadeIndicator();
|
||||
const handleScroll = () => updateFadeIndicator();
|
||||
nav.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleScroll);
|
||||
onCleanup(() => {
|
||||
nav.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
});
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (overflowMenuRef()?.contains(target) || overflowTriggerRef()?.contains(target)) return;
|
||||
closeOverflow();
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
onCleanup(() => document.removeEventListener('pointerdown', handlePointerDown));
|
||||
});
|
||||
|
||||
let previousActiveTab = props.activeTab();
|
||||
createEffect(() => {
|
||||
const nav = navRef();
|
||||
if (!nav) return;
|
||||
|
||||
const activeId = props.activeTab();
|
||||
if (!activeId) return;
|
||||
|
||||
const activeEl = nav.querySelector<HTMLElement>(`[data-tab-id="${activeId}"]`);
|
||||
if (!activeEl) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
updateFadeIndicator();
|
||||
});
|
||||
const activeTab = props.activeTab();
|
||||
if (activeTab === previousActiveTab) return;
|
||||
previousActiveTab = activeTab;
|
||||
closeOverflow();
|
||||
});
|
||||
|
||||
const handlePrimaryClick = (tab: MobileNavBarPrimaryTab) => {
|
||||
@@ -70,13 +99,69 @@ export function useMobileNavBarState(props: MobileNavBarProps) {
|
||||
props.onUtilityClick(tab);
|
||||
};
|
||||
|
||||
const handleDestinationClick = (destination: MobileNavBarDestination) => {
|
||||
closeOverflow();
|
||||
if (destination.kind === 'primary') {
|
||||
handlePrimaryClick(destination.tab);
|
||||
return;
|
||||
}
|
||||
handleUtilityClick(destination.tab);
|
||||
};
|
||||
|
||||
const handleOverflowTriggerClick = () => {
|
||||
if (isOverflowOpen()) {
|
||||
closeOverflow();
|
||||
return;
|
||||
}
|
||||
openOverflow();
|
||||
};
|
||||
|
||||
const handleOverflowTriggerKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
event.preventDefault();
|
||||
openOverflow(event.key === 'ArrowUp' ? 'last' : 'first');
|
||||
};
|
||||
|
||||
const handleOverflowMenuKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeOverflow(true);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
closeOverflow();
|
||||
return;
|
||||
}
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
|
||||
const items = overflowMenuItems();
|
||||
if (items.length === 0) return;
|
||||
event.preventDefault();
|
||||
const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let nextIndex = 0;
|
||||
if (event.key === 'End') {
|
||||
nextIndex = items.length - 1;
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
nextIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1;
|
||||
}
|
||||
items[nextIndex]?.focus();
|
||||
};
|
||||
|
||||
return {
|
||||
handlePrimaryClick,
|
||||
handleUtilityClick,
|
||||
orderedPrimaryTabs,
|
||||
orderedUtilityTabs,
|
||||
setNavRef,
|
||||
showFade,
|
||||
showLeftFade,
|
||||
closeOverflow,
|
||||
fixedDestinations,
|
||||
handleDestinationClick,
|
||||
handleOverflowMenuKeyDown,
|
||||
handleOverflowTriggerClick,
|
||||
handleOverflowTriggerKeyDown,
|
||||
isOverflowOpen,
|
||||
overflowDestinations,
|
||||
overflowIsActive,
|
||||
overflowPrimaryDestinations,
|
||||
overflowUtilityDestinations,
|
||||
setOverflowMenuRef,
|
||||
setOverflowTriggerRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
getAlertThresholdsHelpBanner,
|
||||
getAlertThresholdsGuestFilterPresentation,
|
||||
getAlertThresholdsHelpDismissLabel,
|
||||
getAlertThresholdsOverrideCountLabel,
|
||||
getAlertThresholdsOverridesPresentation,
|
||||
getAlertThresholdsBackupOrphanedPresentation,
|
||||
getAlertThresholdsDockerServicePresentation,
|
||||
getAlertThresholdsDockerUpdatePresentation,
|
||||
@@ -499,35 +501,35 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
|
||||
{
|
||||
key: 'nodes',
|
||||
label: 'Virtualization Hosts',
|
||||
overrides: countOverrides(nodesWithOverrides()),
|
||||
overrides: countOverrides(rawNodesWithOverrides()),
|
||||
tab: 'proxmox',
|
||||
total: props.nodes?.length ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'dockerHosts',
|
||||
label: 'Container Runtimes',
|
||||
overrides: countOverrides(dockerHostsWithOverrides()),
|
||||
overrides: countOverrides(rawDockerHostsWithOverrides()),
|
||||
tab: 'docker',
|
||||
total: props.containerRuntimes?.length ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'agents',
|
||||
label: 'Machines',
|
||||
overrides: countOverrides(agentsWithOverrides()),
|
||||
overrides: countOverrides(rawAgentsWithOverrides()),
|
||||
tab: 'systems',
|
||||
total: props.agents?.length ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'agentDisks',
|
||||
label: 'Machine Disks',
|
||||
overrides: countOverrides(agentDisksWithOverrides()),
|
||||
overrides: countOverrides(rawAgentDisksWithOverrides()),
|
||||
tab: 'systems',
|
||||
total: agentDisksWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
label: 'Storage',
|
||||
overrides: countOverrides(storageWithOverrides()),
|
||||
overrides: countOverrides(rawStorageWithOverrides()),
|
||||
tab: 'proxmox',
|
||||
total: props.storage?.length ?? 0,
|
||||
},
|
||||
@@ -548,128 +550,128 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
|
||||
{
|
||||
key: 'pbs',
|
||||
label: 'PBS Servers',
|
||||
overrides: countOverrides(pbsServersWithOverrides()),
|
||||
overrides: countOverrides(rawPbsServersWithOverrides()),
|
||||
tab: 'proxmox',
|
||||
total: props.pbsInstances?.length ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'pmg',
|
||||
label: 'Mail Gateways',
|
||||
overrides: countOverrides(pmgServersWithOverrides()),
|
||||
overrides: countOverrides(rawPmgServersWithOverrides()),
|
||||
tab: 'proxmox',
|
||||
total: props.pmgInstances?.length ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'dockerContainers',
|
||||
label: 'Containers',
|
||||
overrides: countOverrides(dockerContainersFlat()),
|
||||
overrides: countOverrides(rawDockerContainersFlat()),
|
||||
tab: 'docker',
|
||||
total: totalDockerContainers() ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'guests',
|
||||
label: 'VMs & Containers',
|
||||
overrides: countOverrides(guestsFlat()),
|
||||
overrides: countOverrides(rawGuestsFlat()),
|
||||
tab: 'proxmox',
|
||||
total: props.allGuests?.()?.length ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'guestDisks',
|
||||
label: 'Guest Filesystems',
|
||||
overrides: countOverrides(guestDisksWithOverrides()),
|
||||
overrides: countOverrides(rawGuestDisksWithOverrides()),
|
||||
tab: 'proxmox',
|
||||
total: guestDisksWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'kubernetesClusters',
|
||||
label: 'Kubernetes Clusters',
|
||||
overrides: countOverrides(kubernetesClustersWithOverrides()),
|
||||
overrides: countOverrides(rawKubernetesClustersWithOverrides()),
|
||||
tab: 'kubernetes',
|
||||
total: kubernetesClustersWithOverrides().length,
|
||||
total: rawKubernetesClustersWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'kubernetesNodes',
|
||||
label: 'Kubernetes Nodes',
|
||||
overrides: countOverrides(kubernetesNodesWithOverrides()),
|
||||
overrides: countOverrides(rawKubernetesNodesWithOverrides()),
|
||||
tab: 'kubernetes',
|
||||
total: kubernetesNodesWithOverrides().length,
|
||||
total: rawKubernetesNodesWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'kubernetesNamespaces',
|
||||
label: 'Namespaces',
|
||||
overrides: countOverrides(kubernetesNamespacesWithOverrides()),
|
||||
overrides: countOverrides(rawKubernetesNamespacesWithOverrides()),
|
||||
tab: 'kubernetes',
|
||||
total: kubernetesNamespacesWithOverrides().length,
|
||||
total: rawKubernetesNamespacesWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'kubernetesDeployments',
|
||||
label: 'Deployments',
|
||||
overrides: countOverrides(kubernetesDeploymentsWithOverrides()),
|
||||
overrides: countOverrides(rawKubernetesDeploymentsWithOverrides()),
|
||||
tab: 'kubernetes',
|
||||
total: kubernetesDeploymentsWithOverrides().length,
|
||||
total: rawKubernetesDeploymentsWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'kubernetesPods',
|
||||
label: 'Pods',
|
||||
overrides: countOverrides(kubernetesPodsWithOverrides()),
|
||||
overrides: countOverrides(rawKubernetesPodsWithOverrides()),
|
||||
tab: 'kubernetes',
|
||||
total: kubernetesPodsWithOverrides().length,
|
||||
total: rawKubernetesPodsWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'trueNASSystems',
|
||||
label: 'TrueNAS Systems',
|
||||
overrides: countOverrides(trueNASSystemsWithOverrides()),
|
||||
overrides: countOverrides(rawTrueNASSystemsWithOverrides()),
|
||||
tab: 'truenas',
|
||||
total: trueNASSystemsWithOverrides().length,
|
||||
total: rawTrueNASSystemsWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'trueNASPools',
|
||||
label: 'Pools',
|
||||
overrides: countOverrides(trueNASPoolsWithOverrides()),
|
||||
overrides: countOverrides(rawTrueNASPoolsWithOverrides()),
|
||||
tab: 'truenas',
|
||||
total: trueNASPoolsWithOverrides().length,
|
||||
total: rawTrueNASPoolsWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'trueNASDatasets',
|
||||
label: 'Datasets',
|
||||
overrides: countOverrides(trueNASDatasetsWithOverrides()),
|
||||
overrides: countOverrides(rawTrueNASDatasetsWithOverrides()),
|
||||
tab: 'truenas',
|
||||
total: trueNASDatasetsWithOverrides().length,
|
||||
total: rawTrueNASDatasetsWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'trueNASDisks',
|
||||
label: 'Disks',
|
||||
overrides: countOverrides(trueNASDisksWithOverrides()),
|
||||
overrides: countOverrides(rawTrueNASDisksWithOverrides()),
|
||||
tab: 'truenas',
|
||||
total: trueNASDisksWithOverrides().length,
|
||||
total: rawTrueNASDisksWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'vmwareHosts',
|
||||
label: 'vSphere Hosts',
|
||||
overrides: countOverrides(vmwareHostsWithOverrides()),
|
||||
overrides: countOverrides(rawVmwareHostsWithOverrides()),
|
||||
tab: 'vmware',
|
||||
total: vmwareHostsWithOverrides().length,
|
||||
total: rawVmwareHostsWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'vmwareVMs',
|
||||
label: 'vSphere VMs',
|
||||
overrides: countOverrides(vmwareVMsWithOverrides()),
|
||||
overrides: countOverrides(rawVmwareVMsWithOverrides()),
|
||||
tab: 'vmware',
|
||||
total: vmwareVMsWithOverrides().length,
|
||||
total: rawVmwareVMsWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'vmwareDatastores',
|
||||
label: 'Datastores',
|
||||
overrides: countOverrides(vmwareDatastoresWithOverrides()),
|
||||
overrides: countOverrides(rawVmwareDatastoresWithOverrides()),
|
||||
tab: 'vmware',
|
||||
total: vmwareDatastoresWithOverrides().length,
|
||||
total: rawVmwareDatastoresWithOverrides().length,
|
||||
},
|
||||
{
|
||||
key: 'vmwareNetworks',
|
||||
label: 'Networks',
|
||||
overrides: countOverrides(vmwareNetworksWithOverrides()),
|
||||
overrides: countOverrides(rawVmwareNetworksWithOverrides()),
|
||||
tab: 'vmware',
|
||||
total: vmwareNetworksWithOverrides().length,
|
||||
total: rawVmwareNetworksWithOverrides().length,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -682,6 +684,12 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
|
||||
}
|
||||
});
|
||||
|
||||
const revealCustomOverrides = (sectionId: ThresholdsSummaryItem['key']) => {
|
||||
setOverrideFilter('custom');
|
||||
setCollapsed(sectionId, false);
|
||||
queueMicrotask(() => sectionElements()[sectionId]?.scrollIntoView({ block: 'start' }));
|
||||
};
|
||||
|
||||
const hasSection = (key: string) => summaryItems().some((item) => item.key === key);
|
||||
|
||||
const startEditing = (
|
||||
@@ -972,7 +980,10 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
|
||||
AGENT_THRESHOLDS_FILTER_EMPTY_STATE,
|
||||
getAlertThresholdsHelpBanner,
|
||||
getAlertThresholdsHelpDismissLabel,
|
||||
getAlertThresholdsOverrideCountLabel,
|
||||
getAlertThresholdsOverridesPresentation,
|
||||
getAlertThresholdsSearchPlaceholder,
|
||||
revealCustomOverrides,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ import { LabeledFilterToggleGroup } from '@/components/shared/FilterToolbar';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { MetadataBadge, type MetadataBadgeTone } from '@/components/shared/MetadataBadge';
|
||||
import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
|
||||
import {
|
||||
getPatrolAttentionEvidencePresentation,
|
||||
getPatrolAttentionProtectionPresentation,
|
||||
getPatrolProtectionProviderLabels,
|
||||
} from '@/features/patrol/patrolControlPresentation';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||
import {
|
||||
@@ -721,11 +726,7 @@ function AttentionDetail(props: {
|
||||
<span class="font-medium text-base-content">
|
||||
{formatProvider(provider.provider)}
|
||||
</span>
|
||||
<span class="text-muted">
|
||||
{' '}
|
||||
· {formatLabel(provider.jobState)} ·{' '}
|
||||
{formatLabel(provider.historyCompleteness)}
|
||||
</span>
|
||||
<ProtectionProviderMetadata provider={provider} />
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
@@ -1090,43 +1091,46 @@ function StateBadge(props: { item: AttentionItem }) {
|
||||
}
|
||||
|
||||
function EvidenceLabel(props: { item: AttentionItem; badge?: boolean }) {
|
||||
const label = () =>
|
||||
props.item.evidenceFreshness === 'fresh' && props.item.evidenceCompleteness === 'complete'
|
||||
? 'Evidence current'
|
||||
: `${formatLabel(props.item.evidenceFreshness)} / ${formatLabel(props.item.evidenceCompleteness)}`;
|
||||
const presentation = () =>
|
||||
getPatrolAttentionEvidencePresentation(
|
||||
props.item.evidenceFreshness,
|
||||
props.item.evidenceCompleteness,
|
||||
);
|
||||
if (props.badge) {
|
||||
return (
|
||||
<MetadataBadge
|
||||
tone={
|
||||
props.item.evidenceFreshness === 'fresh' && props.item.evidenceCompleteness === 'complete'
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
>
|
||||
{label()}
|
||||
<MetadataBadge tone={presentation().tone} size="xs" shape="rounded">
|
||||
{presentation().label}
|
||||
</MetadataBadge>
|
||||
);
|
||||
}
|
||||
return <span>{label()}</span>;
|
||||
return <span>{presentation().label}</span>;
|
||||
}
|
||||
|
||||
function ProtectionLabel(props: { item: AttentionItem; badge?: boolean }) {
|
||||
const state = () => props.item.protectionPosture?.state ?? 'unknown';
|
||||
const label = () => `Protection ${formatLabel(state())}`;
|
||||
const presentation = () =>
|
||||
getPatrolAttentionProtectionPresentation(props.item.protectionPosture?.state);
|
||||
if (props.badge) {
|
||||
return (
|
||||
<MetadataBadge
|
||||
tone={state() === 'protected' ? 'success' : state() === 'unknown' ? 'muted' : 'warning'}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
>
|
||||
{label()}
|
||||
<MetadataBadge tone={presentation().tone} size="xs" shape="rounded">
|
||||
{presentation().detailLabel}
|
||||
</MetadataBadge>
|
||||
);
|
||||
}
|
||||
return <span>{label()}</span>;
|
||||
return <Show when={presentation().rowLabel}>{(label) => <span>{label()}</span>}</Show>;
|
||||
}
|
||||
|
||||
function ProtectionProviderMetadata(props: {
|
||||
provider: NonNullable<AttentionItem['protectionPosture']>['providerStates'][number];
|
||||
}) {
|
||||
const labels = () => getPatrolProtectionProviderLabels(props.provider);
|
||||
return (
|
||||
<span class="text-muted">
|
||||
{' · '}
|
||||
<span>{labels().job}</span>
|
||||
{' · '}
|
||||
<span>{labels().history}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function stateTone(item: AttentionItem): MetadataBadgeTone {
|
||||
|
||||
@@ -282,6 +282,54 @@ describe('PatrolAttentionWorkbench', () => {
|
||||
expect(window.location.search).toBe('?attention=record-1');
|
||||
});
|
||||
|
||||
it('uses plain scan-row language while keeping unknown trust metadata in detail', async () => {
|
||||
const unknownTrust = item({
|
||||
state: 'unknown',
|
||||
evidenceFreshness: 'unknown',
|
||||
evidenceCompleteness: 'complete',
|
||||
protectionPosture: {
|
||||
...item().protectionPosture!,
|
||||
state: 'unknown',
|
||||
explanation: 'Pulse has not received enough protection evidence to classify this resource.',
|
||||
providerStates: [
|
||||
{
|
||||
provider: 'pbs',
|
||||
source: 'recovery-points',
|
||||
scope: 'primary',
|
||||
jobState: 'unknown',
|
||||
historyCompleteness: 'complete',
|
||||
permissions: 'sufficient',
|
||||
evidenceIds: [],
|
||||
verificationExpected: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
apiMocks.getList.mockResolvedValue(
|
||||
listResponse([unknownTrust], summary({ activeCount: 1, uncertainCount: 1, calm: false })),
|
||||
);
|
||||
apiMocks.getDetail.mockResolvedValue(detail(unknownTrust));
|
||||
renderWorkbench();
|
||||
|
||||
const row = await screen.findByRole('button', { name: 'Open Disk pressure on Database VM' });
|
||||
expect(within(row).getByText('Evidence timing unavailable')).toBeInTheDocument();
|
||||
expect(within(row).queryByText('Unknown / Complete')).not.toBeInTheDocument();
|
||||
expect(within(row).queryByText(/Protection/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(row);
|
||||
|
||||
const detailRegion = await screen.findByRole('complementary', {
|
||||
name: 'Disk pressure on Database VM',
|
||||
});
|
||||
expect(within(detailRegion).getByText('Evidence timing unavailable')).toBeInTheDocument();
|
||||
expect(within(detailRegion).getByText('Protection status unavailable')).toBeInTheDocument();
|
||||
expect(within(detailRegion).getByText('Job status unavailable')).toBeInTheDocument();
|
||||
expect(within(detailRegion).getByText('History: Complete')).toBeInTheDocument();
|
||||
expect(
|
||||
within(detailRegion).getByText(/not received enough protection evidence/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps repeated evidence observations available without overwhelming the default detail', async () => {
|
||||
const active = item();
|
||||
const expandedDetail = detail(active);
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
getPatrolProInvestigationHandoff,
|
||||
getPatrolQueueWorkspaceDescription,
|
||||
getPatrolWatchOnlyInvestigationNudge,
|
||||
getPatrolAttentionEvidencePresentation,
|
||||
getPatrolAttentionProtectionPresentation,
|
||||
getPatrolProtectionProviderLabels,
|
||||
getMonitorContextPatrolProtectionPosture,
|
||||
getPatrolReadyWorkDetail,
|
||||
getPatrolSetupIssueReason,
|
||||
@@ -15,6 +18,67 @@ import {
|
||||
} from '../patrolControlPresentation';
|
||||
|
||||
describe('patrolControlPresentation', () => {
|
||||
it.each([
|
||||
['fresh', 'complete', 'Evidence current', 'success'],
|
||||
['stale', 'complete', 'Evidence out of date', 'warning'],
|
||||
['unknown', 'complete', 'Evidence timing unavailable', 'warning'],
|
||||
['fresh', 'partial', 'Evidence incomplete', 'warning'],
|
||||
['stale', 'partial', 'Evidence incomplete and out of date', 'warning'],
|
||||
['unknown', 'partial', 'Evidence incomplete; timing unavailable', 'warning'],
|
||||
['fresh', 'unavailable', 'Evidence unavailable', 'muted'],
|
||||
['stale', 'unavailable', 'Evidence unavailable', 'muted'],
|
||||
['unknown', 'unavailable', 'Evidence unavailable', 'muted'],
|
||||
] as const)(
|
||||
'presents %s/%s evidence as plain operator language',
|
||||
(freshness, completeness, label, tone) => {
|
||||
expect(getPatrolAttentionEvidencePresentation(freshness, completeness)).toEqual({
|
||||
label,
|
||||
tone,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps meaningful protection states in rows and reserves unavailable state for detail', () => {
|
||||
expect(getPatrolAttentionProtectionPresentation('protected')).toEqual({
|
||||
detailLabel: 'Protected',
|
||||
rowLabel: 'Protected',
|
||||
tone: 'success',
|
||||
});
|
||||
expect(getPatrolAttentionProtectionPresentation('attention')).toEqual({
|
||||
detailLabel: 'Protection needs attention',
|
||||
rowLabel: 'Protection needs attention',
|
||||
tone: 'warning',
|
||||
});
|
||||
expect(getPatrolAttentionProtectionPresentation('unprotected')).toEqual({
|
||||
detailLabel: 'Not protected',
|
||||
rowLabel: 'Not protected',
|
||||
tone: 'danger',
|
||||
});
|
||||
expect(getPatrolAttentionProtectionPresentation('unknown')).toEqual({
|
||||
detailLabel: 'Protection status unavailable',
|
||||
tone: 'muted',
|
||||
});
|
||||
expect(getPatrolAttentionProtectionPresentation()).toEqual({
|
||||
detailLabel: 'Protection status unavailable',
|
||||
tone: 'muted',
|
||||
});
|
||||
});
|
||||
|
||||
it('names provider protection fields instead of presenting an ambiguous value pair', () => {
|
||||
expect(
|
||||
getPatrolProtectionProviderLabels({
|
||||
jobState: 'unknown',
|
||||
historyCompleteness: 'complete',
|
||||
}),
|
||||
).toEqual({ job: 'Job status unavailable', history: 'History: Complete' });
|
||||
expect(
|
||||
getPatrolProtectionProviderLabels({
|
||||
jobState: 'success',
|
||||
historyCompleteness: 'unknown',
|
||||
}),
|
||||
).toEqual({ job: 'Job: Success', history: 'History unavailable' });
|
||||
});
|
||||
|
||||
it('keeps the four Patrol mode options in user-facing language', () => {
|
||||
expect(PATROL_AUTONOMY_POLICY_PRESENTATION).toEqual({
|
||||
monitor: {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { PatrolAutonomyLevel, PatrolRunRecord, PatrolStatus } from '@/api/patrol';
|
||||
import type { MetadataBadgeTone } from '@/components/shared/MetadataBadge';
|
||||
import type { EvidenceCompleteness, EvidenceFreshness } from '@/types/operationalTrust';
|
||||
import type {
|
||||
ProtectionHistoryCompleteness,
|
||||
ProtectionState,
|
||||
RecoveryOutcome,
|
||||
} from '@/types/recovery';
|
||||
import {
|
||||
getPatrolFindingIssueCountLabel,
|
||||
getPatrolWorkTypeCompositionClause,
|
||||
@@ -48,6 +54,76 @@ export const PATROL_WORKSPACE_QUEUE_TITLE = 'Open work';
|
||||
|
||||
export const PATROL_WORKSPACE_RUN_RECORD_TITLE = 'Check details';
|
||||
|
||||
export interface PatrolAttentionEvidencePresentation {
|
||||
label: string;
|
||||
tone: MetadataBadgeTone;
|
||||
}
|
||||
|
||||
export interface PatrolAttentionProtectionPresentation {
|
||||
detailLabel: string;
|
||||
rowLabel?: string;
|
||||
tone: MetadataBadgeTone;
|
||||
}
|
||||
|
||||
export function getPatrolAttentionEvidencePresentation(
|
||||
freshness: EvidenceFreshness,
|
||||
completeness: EvidenceCompleteness,
|
||||
): PatrolAttentionEvidencePresentation {
|
||||
if (completeness === 'unavailable') {
|
||||
return { label: 'Evidence unavailable', tone: 'muted' };
|
||||
}
|
||||
if (completeness === 'complete') {
|
||||
if (freshness === 'fresh') return { label: 'Evidence current', tone: 'success' };
|
||||
if (freshness === 'stale') return { label: 'Evidence out of date', tone: 'warning' };
|
||||
return { label: 'Evidence timing unavailable', tone: 'warning' };
|
||||
}
|
||||
if (freshness === 'fresh') return { label: 'Evidence incomplete', tone: 'warning' };
|
||||
if (freshness === 'stale') {
|
||||
return { label: 'Evidence incomplete and out of date', tone: 'warning' };
|
||||
}
|
||||
return { label: 'Evidence incomplete; timing unavailable', tone: 'warning' };
|
||||
}
|
||||
|
||||
export function getPatrolAttentionProtectionPresentation(
|
||||
state?: ProtectionState,
|
||||
): PatrolAttentionProtectionPresentation {
|
||||
switch (state) {
|
||||
case 'protected':
|
||||
return { detailLabel: 'Protected', rowLabel: 'Protected', tone: 'success' };
|
||||
case 'attention':
|
||||
return {
|
||||
detailLabel: 'Protection needs attention',
|
||||
rowLabel: 'Protection needs attention',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'unprotected':
|
||||
return { detailLabel: 'Not protected', rowLabel: 'Not protected', tone: 'danger' };
|
||||
default:
|
||||
return { detailLabel: 'Protection status unavailable', tone: 'muted' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatAttentionMetadataToken(value: string): string {
|
||||
const normalized = value.trim().replace(/[_-]+/g, ' ');
|
||||
return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : 'Unavailable';
|
||||
}
|
||||
|
||||
export function getPatrolProtectionProviderLabels(input: {
|
||||
jobState: RecoveryOutcome;
|
||||
historyCompleteness: ProtectionHistoryCompleteness;
|
||||
}): { job: string; history: string } {
|
||||
return {
|
||||
job:
|
||||
input.jobState === 'unknown'
|
||||
? 'Job status unavailable'
|
||||
: `Job: ${formatAttentionMetadataToken(input.jobState)}`,
|
||||
history:
|
||||
input.historyCompleteness === 'unknown' || input.historyCompleteness === 'unavailable'
|
||||
? 'History unavailable'
|
||||
: `History: ${formatAttentionMetadataToken(input.historyCompleteness)}`,
|
||||
};
|
||||
}
|
||||
|
||||
interface PatrolControlCopyInput {
|
||||
autonomyLevel?: PatrolAutonomyLevel;
|
||||
autonomyLocked?: boolean;
|
||||
|
||||
+51
-1
@@ -1,6 +1,6 @@
|
||||
import { cleanup, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getHorizontalRailScrollLeft } from '@/components/shared/horizontalRailVisibilityModel';
|
||||
import { PlatformSectionTabs } from '../sharedPlatformPage';
|
||||
|
||||
@@ -119,6 +119,56 @@ describe('PlatformSectionTabs', () => {
|
||||
expect(activeLink).toHaveClass('whitespace-nowrap');
|
||||
});
|
||||
|
||||
it('exposes operable phone-width controls when more sections are clipped', async () => {
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="/"
|
||||
component={() => (
|
||||
<PlatformSectionTabs
|
||||
tabs={
|
||||
[
|
||||
{ id: 'overview', label: 'Overview', path: '/example/overview' },
|
||||
{ id: 'images', label: 'Images', path: '/example/images' },
|
||||
{ id: 'storage', label: 'Storage', path: '/example/storage' },
|
||||
{ id: 'networks', label: 'Networks', path: '/example/networks' },
|
||||
] as const
|
||||
}
|
||||
active="overview"
|
||||
ariaLabel="Example sections"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Router>
|
||||
));
|
||||
|
||||
const navigation = screen.getByRole('navigation', { name: 'Example sections' });
|
||||
const scrollBy = vi.fn();
|
||||
Object.defineProperties(navigation, {
|
||||
clientWidth: { configurable: true, value: 220 },
|
||||
scrollWidth: { configurable: true, value: 520 },
|
||||
scrollLeft: { configurable: true, writable: true, value: 0 },
|
||||
scrollBy: { configurable: true, value: scrollBy },
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
const scrollRight = await screen.findByRole('button', {
|
||||
name: 'Example sections: scroll right',
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Example sections: scroll left' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
scrollRight.click();
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 160, behavior: 'smooth' });
|
||||
|
||||
navigation.scrollLeft = 120;
|
||||
navigation.dispatchEvent(new Event('scroll'));
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Example sections: scroll left' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the active destination visible when the tab rail narrows', async () => {
|
||||
render(() => (
|
||||
<Router>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { A } from '@solidjs/router';
|
||||
import ChevronLeftIcon from 'lucide-solid/icons/chevron-left';
|
||||
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
|
||||
import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw';
|
||||
import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert';
|
||||
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from 'solid-js';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup';
|
||||
import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar';
|
||||
@@ -72,36 +84,106 @@ export function PlatformSectionTabs<TabId extends string>(props: {
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
let tabListRef: HTMLElement | undefined;
|
||||
const [canScrollLeft, setCanScrollLeft] = createSignal(false);
|
||||
const [canScrollRight, setCanScrollRight] = createSignal(false);
|
||||
|
||||
const updateScrollControls = () => {
|
||||
const rail = tabListRef;
|
||||
if (!rail) return;
|
||||
const maxScrollLeft = Math.max(0, rail.scrollWidth - rail.clientWidth);
|
||||
setCanScrollLeft(rail.scrollLeft > 1);
|
||||
setCanScrollRight(rail.scrollLeft < maxScrollLeft - 1);
|
||||
};
|
||||
|
||||
useActiveHorizontalRailItemVisibility({
|
||||
active: () => props.active,
|
||||
rail: () => tabListRef,
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
void props.active;
|
||||
queueMicrotask(updateScrollControls);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const rail = tabListRef;
|
||||
if (!rail) return;
|
||||
|
||||
rail.addEventListener('scroll', updateScrollControls, { passive: true });
|
||||
window.addEventListener('resize', updateScrollControls, { passive: true });
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'function' ? new ResizeObserver(updateScrollControls) : undefined;
|
||||
resizeObserver?.observe(rail);
|
||||
updateScrollControls();
|
||||
|
||||
onCleanup(() => {
|
||||
rail.removeEventListener('scroll', updateScrollControls);
|
||||
window.removeEventListener('resize', updateScrollControls);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
const scrollSections = (direction: -1 | 1) => {
|
||||
const rail = tabListRef;
|
||||
if (!rail) return;
|
||||
rail.scrollBy({
|
||||
left: direction * Math.max(160, Math.round(rail.clientWidth * 0.7)),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.tabs.length > 1}>
|
||||
<nav
|
||||
ref={(element) => {
|
||||
tabListRef = element;
|
||||
}}
|
||||
class="flex min-w-0 items-center gap-1 overflow-x-auto border-b border-border scrollbar-hide"
|
||||
aria-label={props.ariaLabel}
|
||||
>
|
||||
<For each={props.tabs}>
|
||||
{(tab) => (
|
||||
<A
|
||||
href={tab.path}
|
||||
class={`inline-flex min-h-10 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-3 text-sm font-medium transition-colors ${
|
||||
props.active === tab.id
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-300'
|
||||
: 'border-transparent text-muted hover:border-border hover:text-base-content'
|
||||
}`}
|
||||
aria-current={props.active === tab.id ? 'page' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
<div class="relative min-w-0 border-b border-border">
|
||||
<nav
|
||||
ref={(element) => {
|
||||
tabListRef = element;
|
||||
}}
|
||||
class="flex min-w-0 scroll-px-10 items-center gap-1 overflow-x-auto px-10 scrollbar-hide sm:scroll-px-0 sm:px-0"
|
||||
aria-label={props.ariaLabel}
|
||||
>
|
||||
<For each={props.tabs}>
|
||||
{(tab) => (
|
||||
<A
|
||||
href={tab.path}
|
||||
class={`inline-flex min-h-10 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-3 text-sm font-medium transition-colors ${
|
||||
props.active === tab.id
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-300'
|
||||
: 'border-transparent text-muted hover:border-border hover:text-base-content'
|
||||
}`}
|
||||
aria-current={props.active === tab.id ? 'page' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
<Show when={canScrollLeft()}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 left-0 z-10 flex w-10 items-center justify-start bg-gradient-to-r from-surface via-surface to-transparent pl-1 text-muted hover:text-base-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:hidden"
|
||||
onClick={() => scrollSections(-1)}
|
||||
aria-label={`${props.ariaLabel}: scroll left`}
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-surface shadow-sm">
|
||||
<ChevronLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={canScrollRight()}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 z-10 flex w-10 items-center justify-end bg-gradient-to-l from-surface via-surface to-transparent pr-1 text-muted hover:text-base-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:hidden"
|
||||
onClick={() => scrollSections(1)}
|
||||
aria-label={`${props.ariaLabel}: scroll right`}
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-surface shadow-sm">
|
||||
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('alertResourceTablePresentation', () => {
|
||||
});
|
||||
expect(getAlertResourceTableMetricInputTitle(true)).toBe('Click to enable this metric');
|
||||
expect(getAlertResourceTableMetricInputTitle(false)).toBe(
|
||||
'Set to -1 or use the Off toggle to disable alerts for this metric',
|
||||
'Use the Off control to disable alerts for this metric',
|
||||
);
|
||||
expect(getAlertResourceTableEditMetricTitle()).toBe('Click to edit this metric');
|
||||
});
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
getAlertThresholdsGuestFilterPresentation,
|
||||
getAlertThresholdsHelpBanner,
|
||||
getAlertThresholdsHelpDismissLabel,
|
||||
getAlertThresholdsOverrideCountLabel,
|
||||
getAlertThresholdsOverridesPresentation,
|
||||
getAlertThresholdsSearchPlaceholder,
|
||||
getAlertThresholdsSectionTitles,
|
||||
} from '../alertThresholdsPresentation';
|
||||
@@ -94,15 +96,19 @@ describe('alertThresholdsPresentation', () => {
|
||||
expect(ALERT_THRESHOLDS_HELP_DISMISS_LABEL).toBe('Dismiss tips');
|
||||
expect(getAlertThresholdsHelpDismissLabel()).toBe('Dismiss tips');
|
||||
expect(getAlertThresholdsHelpBanner()).toEqual({
|
||||
title: 'Quick tips:',
|
||||
// Must match the value the backend and docs/FAQ.md treat as "off";
|
||||
// the banner told users to type 0, which edit-state off detection
|
||||
// (=== -1) never recognised.
|
||||
disableValue: '-1',
|
||||
reenableLabel: 'Off',
|
||||
customBadgeLabel: 'Custom',
|
||||
collapseHint: 'Click sections to collapse/expand.',
|
||||
title: 'How thresholds work',
|
||||
toggleGuidance: 'Use the On/Off control beside a metric to enable or disable its alerts.',
|
||||
inheritanceGuidance:
|
||||
'Resources inherit their group defaults until you save a custom override.',
|
||||
bulkGuidance: 'Select multiple resources to apply the same threshold changes in bulk.',
|
||||
collapseHint: 'Resource groups start collapsed; open only the group you need.',
|
||||
});
|
||||
expect(getAlertThresholdsOverridesPresentation()).toEqual({
|
||||
title: 'Custom overrides',
|
||||
description:
|
||||
'These resources differ from the inherited defaults. Choose a group to review only its custom settings.',
|
||||
});
|
||||
expect(getAlertThresholdsOverrideCountLabel(2)).toBe('2 custom');
|
||||
});
|
||||
|
||||
it('exports canonical thresholds auxiliary filter and backup vocabulary', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
README_DOC_URL,
|
||||
SECURITY_DOC_URL,
|
||||
TERMS_DOC_URL,
|
||||
TROUBLESHOOTING_DOC_URL,
|
||||
SHIPPED_DOCS_ROOT,
|
||||
getShippedDocUrl,
|
||||
} from '@/utils/docsLinks';
|
||||
@@ -69,6 +70,7 @@ describe('docsLinks', () => {
|
||||
expect(PRIVACY_DOC_URL).toBe('/docs/PRIVACY');
|
||||
expect(README_DOC_URL).toBe('/docs/README');
|
||||
expect(MIGRATION_GUIDE_DOC_URL).toBe('/docs/MIGRATION_UNIFIED_NAV');
|
||||
expect(TROUBLESHOOTING_DOC_URL).toBe('/docs/TROUBLESHOOTING');
|
||||
expect(CONFIGURATION_DOC_URL).toBe('/docs/CONFIGURATION');
|
||||
expect(PROXY_AUTH_DOC_URL).toBe('/docs/PROXY_AUTH');
|
||||
expect(SECURITY_DOC_URL).toBe('/docs/SECURITY');
|
||||
|
||||
@@ -2460,13 +2460,13 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(mobileNavBarSource).not.toContain('requestAnimationFrame');
|
||||
expect(mobileNavBarSource).not.toContain('new Set(priority)');
|
||||
expect(mobileNavBarStateSource).toContain('createSignal');
|
||||
expect(mobileNavBarStateSource).toContain('window.addEventListener');
|
||||
expect(mobileNavBarStateSource).toContain('requestAnimationFrame');
|
||||
expect(mobileNavBarStateSource).toContain('scrollIntoView');
|
||||
expect(mobileNavBarStateSource).toContain('document.addEventListener');
|
||||
expect(mobileNavBarStateSource).toContain('handleOverflowMenuKeyDown');
|
||||
expect(mobileNavBarStateSource).not.toContain('scrollIntoView');
|
||||
expect(mobileNavBarModelSource).toContain('buildMobileNavBarLayout');
|
||||
expect(mobileNavBarModelSource).toContain('buildOrderedMobileNavPrimaryTabs');
|
||||
expect(mobileNavBarModelSource).toContain('buildOrderedMobileNavUtilityTabs');
|
||||
expect(mobileNavBarModelSource).toContain('getMobileNavAlertBadgeCounts');
|
||||
expect(mobileNavBarModelSource).toContain('getMobileNavFadeState');
|
||||
expect(useUnifiedResourcesSource).not.toContain('normalizeResourcePolicyAISafeSummary(');
|
||||
expect(useUnifiedResourcesSource).not.toContain('normalizeResourcePolicy(');
|
||||
expect(useUnifiedResourcesSource).not.toContain('const resolvePolicySensitivity =');
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ALERT_RESOURCE_TABLE_OFFLINE_STATE_CRITICAL_TITLE =
|
||||
'Offline alerts will raise critical-level notifications.';
|
||||
export const ALERT_RESOURCE_TABLE_ENABLE_METRIC_TITLE = 'Click to enable this metric';
|
||||
export const ALERT_RESOURCE_TABLE_DISABLE_METRIC_TITLE =
|
||||
'Set to -1 or use the Off toggle to disable alerts for this metric';
|
||||
'Use the Off control to disable alerts for this metric';
|
||||
export const ALERT_RESOURCE_TABLE_EDIT_METRIC_TITLE = 'Click to edit this metric';
|
||||
export const ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_LABEL_ON = 'On';
|
||||
export const ALERT_RESOURCE_TABLE_METRIC_OFF_TOGGLE_LABEL_OFF = 'Off';
|
||||
|
||||
@@ -27,6 +27,9 @@ export const CONTAINER_RUNTIMES_FILTER_EMPTY_STATE =
|
||||
export const CONTAINERS_FILTER_EMPTY_STATE = 'No containers match the current filters.';
|
||||
export const ALERT_THRESHOLDS_SEARCH_PLACEHOLDER = 'Search resources...';
|
||||
export const ALERT_THRESHOLDS_HELP_DISMISS_LABEL = 'Dismiss tips';
|
||||
export const ALERT_THRESHOLDS_OVERRIDES_TITLE = 'Custom overrides';
|
||||
export const ALERT_THRESHOLDS_OVERRIDES_DESCRIPTION =
|
||||
'These resources differ from the inherited defaults. Choose a group to review only its custom settings.';
|
||||
export const ALERT_THRESHOLDS_GUEST_IGNORED_PREFIXES_TITLE = 'Ignored prefixes';
|
||||
export const ALERT_THRESHOLDS_GUEST_IGNORED_PREFIXES_DESCRIPTION =
|
||||
'Skip metrics for VMs and containers starting with:';
|
||||
@@ -101,14 +104,25 @@ export function getAlertThresholdsHelpDismissLabel() {
|
||||
|
||||
export function getAlertThresholdsHelpBanner() {
|
||||
return {
|
||||
title: 'Quick tips:',
|
||||
disableValue: '-1',
|
||||
reenableLabel: 'Off',
|
||||
customBadgeLabel: 'Custom',
|
||||
collapseHint: 'Click sections to collapse/expand.',
|
||||
title: 'How thresholds work',
|
||||
toggleGuidance: 'Use the On/Off control beside a metric to enable or disable its alerts.',
|
||||
inheritanceGuidance: 'Resources inherit their group defaults until you save a custom override.',
|
||||
bulkGuidance: 'Select multiple resources to apply the same threshold changes in bulk.',
|
||||
collapseHint: 'Resource groups start collapsed; open only the group you need.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getAlertThresholdsOverridesPresentation() {
|
||||
return {
|
||||
title: ALERT_THRESHOLDS_OVERRIDES_TITLE,
|
||||
description: ALERT_THRESHOLDS_OVERRIDES_DESCRIPTION,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getAlertThresholdsOverrideCountLabel(count: number) {
|
||||
return `${count} custom`;
|
||||
}
|
||||
|
||||
export function getAlertThresholdsGuestFilterPresentation() {
|
||||
return {
|
||||
ignoredPrefixes: {
|
||||
|
||||
@@ -13,6 +13,7 @@ export function getShippedDocUrl(filename: string): string {
|
||||
|
||||
export const README_DOC_URL = getShippedDocUrl('README.md');
|
||||
export const MIGRATION_GUIDE_DOC_URL = getShippedDocUrl('MIGRATION_UNIFIED_NAV.md');
|
||||
export const TROUBLESHOOTING_DOC_URL = getShippedDocUrl('TROUBLESHOOTING.md');
|
||||
export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md');
|
||||
export const CONFIGURATION_DOC_URL = getShippedDocUrl('CONFIGURATION.md');
|
||||
export const PROXY_AUTH_DOC_URL = getShippedDocUrl('PROXY_AUTH.md');
|
||||
|
||||
@@ -725,6 +725,10 @@ test("makes active operational work primary and preserves the evidence boundary"
|
||||
"CPU has remained above the configured threshold for two collection cycles.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(queue.getByText("Evidence current").first()).toBeVisible();
|
||||
await expect(
|
||||
queue.getByText("Protection needs attention").first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Explain with Assistant" }),
|
||||
).toHaveCount(0);
|
||||
@@ -741,6 +745,11 @@ test("makes active operational work primary and preserves the evidence boundary"
|
||||
await expect(
|
||||
queue.getByText("Connection state unknown for pve-edge"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
queue.getByText("Evidence incomplete; timing unavailable"),
|
||||
).toBeVisible();
|
||||
await expect(queue.getByText("Protection Unknown")).toHaveCount(0);
|
||||
await expect(queue.getByText("Unknown / Partial")).toHaveCount(0);
|
||||
await selectAttentionState(page, "Recent resolved 1", "resolved");
|
||||
await expect(
|
||||
queue.getByText("Storage pressure resolved on pve-recovered"),
|
||||
@@ -773,6 +782,11 @@ test("makes active operational work primary and preserves the evidence boundary"
|
||||
"A current backup exists, but its verification is outside the verification window.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText("Protection needs attention"),
|
||||
).toBeVisible();
|
||||
await expect(detailPanel.getByText("Job: Success")).toBeVisible();
|
||||
await expect(detailPanel.getByText("History: Complete")).toBeVisible();
|
||||
await expect(detailPanel.getByText("Observing to Open")).toBeVisible();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`attention=${encodeURIComponent(attentionID)}`),
|
||||
|
||||
Reference in New Issue
Block a user