Improve 6.2 usability across core workflows

This commit is contained in:
rcourtman
2026-08-09 01:16:15 +01:00
parent 63a0adf9ac
commit 16179dd0a5
57 changed files with 1968 additions and 1161 deletions
+30 -1
View File
@@ -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 `Cant 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
+77 -17
View File
@@ -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 Cant 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."
]
}
+30 -1
View File
@@ -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 -22
View File
@@ -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]);
@@ -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');
@@ -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);
});
@@ -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 });
});
});
@@ -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 {
+9
View File
@@ -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"
>
Cant 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) => {
@@ -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: 'Cant 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: 'Cant 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');
});
});
});
@@ -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;
@@ -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: {
+1
View File
@@ -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)}`),