diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index bfffa570db..a6445d150d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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 -- /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`. diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 4dfff6765a..74e1061335 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 306729dc41..73e2eae131 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 8549f52046..bcfbcc82f9 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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`, diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index caacf7d224..80491c8242 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index e5777c7e86..00134e5139 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c706410aed..3cc12e2db1 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -368,6 +368,11 @@ and Kubernetes platform pages may add native API-backed sections, but the tabs must use `PlatformSectionTabs`, canonical table alignment helpers, and shared resource type presentation/reporting helpers rather than page-local tab shells, alignment classes, or ad hoc report-category coercion. Platform tabs are +responsively scrollable within that shared shell. When the rail overflows, the +primitive must expose focusable, labeled previous/next controls and keep their +availability synchronized with the rail position so hidden workflows remain +discoverable without widening the document or requiring precision swipes. +Platform tabs are feature-owned consumers of canonical resource payloads: Docker page model helpers may prefer backend-authored `DockerData` stack and Podman metadata for search/display while shared primitives continue to own only the tab shell, @@ -1068,6 +1073,14 @@ not a replacement status card, CTA band, or page-local nested card. icon components may keep their standalone labels, but the nav must treat those icons as decorative inside tab buttons so names come from the tab label plus meaningful badge counts, not duplicated icon titles. + That mobile route surface must remain a bounded navigation rail rather than + compressing every destination into unreadable tabs. It keeps the active + priority platform destination together with Alerts, Actions, and Patrol in + the fixed rail, groups the remaining platform and utility destinations + (including Settings) behind a labeled More menu, and preserves active-state + and badge context on that disclosure. The route rail is a semantic `nav`, + not a tablist; the More menu must support first/last/active focus, arrow-key + movement, Escape focus return, outside dismissal, and route-change closure. Shared grouped-resource presentation primitives must keep grouped resource labels operator-readable: count-led labels may aggregate repeated resources, but uncountable or category-like resource types such as storage must use @@ -2540,7 +2553,10 @@ The default queue may show resource, impact, evidence, protection, next-step, and age facts. Provider evidence and lifecycle history belong in selected detail, while legacy Patrol analytics belong in collapsed supporting context. Unavailable and partial states must use explicit copy rather than success -styling. +styling. Scan rows omit unavailable protection metadata and collapse evidence +freshness/completeness into one operator phrase; selected detail keeps the typed +facts with named fields and plain unavailable-state labels rather than raw token +pairs. The selected attention detail may compose the shared Actions review for an eligible backend-authored offer. The detail owns only the expected @@ -4159,6 +4175,13 @@ Canonical customer disclosures inside shared shells route through `frontend-modern/src/utils/docsLinks.ts`, so settings privacy links resolve to shipped `/docs/...` assets instead of hard-coded GitHub `main` URLs that can drift from the running build. +The pre-authenticated Login shell uses that same canonical docs-link boundary +for self-hosted access recovery. `TROUBLESHOOTING_DOC_URL` routes the always- +visible `Can’t sign in?` action to the shipped Troubleshooting guide, including +when local login is hidden behind SSO, and the `/docs/...` public-route contract +keeps that destination readable without an authenticated session. The login +surface must remain guidance-only: it may not expose secrets, imply an email +reset flow, or create a browser-side authentication bypass. The shared summary strip primitives now follow that same owner split. `frontend-modern/src/components/shared/SummaryPanel.tsx` and `frontend-modern/src/components/shared/SummaryMetricCard.tsx` stay the render @@ -4929,6 +4952,16 @@ links such as `/alerts/thresholds/infrastructure`, `/alerts/thresholds/containers`, and `/alerts/thresholds/mail-gateway` must redirect to the matching platform-shaped route; legacy `/alerts/thresholds/agents` links must continue to resolve to Systems. +Thresholds section state is also owned there and composed through the shared +collapsible-section pattern. Resource sections open collapsed by default on +desktop and narrow layouts, while persisted operator choices, Expand all, and +Collapse all stay authoritative. The thresholds shell must pair that compact +default with an immediately visible custom-override summary whose actions open +the selected section under the Custom-only filter, so inherited state remains +clear without forcing every table open. Metric editors on desktop, mobile, and +bulk surfaces use explicit On/Off controls and positive enabled-value bounds; +legacy values at or below zero still read as Off, while the backend disable +sentinel remains compatibility data rather than customer-facing input or copy. The Proxmox tab is itself now a shell that composes `frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`, `frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`, diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index de45d323eb..2dd054903c 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 585235dc0a..ca2412885f 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,20 +1,69 @@ { "version": 1, - "base_sha": "4e1d2f6d5dc0819cba4cd3549f82bd87c2e99296", - "verified_at": "2026-08-08T23:45:58Z", + "base_sha": "63a0adf9ac0828c43db3af6fa15f8f201d0c18ac", + "verified_at": "2026-08-09T00:14:31Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", - "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts", - "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx" + "frontend-modern/src/AppLayout.tsx", + "frontend-modern/src/components/AI/Chat/index.tsx", + "frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx", + "frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx", + "frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx", + "frontend-modern/src/components/Alerts/BulkEditDialog.tsx", + "frontend-modern/src/components/Alerts/Thresholds/hooks/useCollapsedSections.ts", + "frontend-modern/src/components/Alerts/ThresholdsTable.tsx", + "frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx", + "frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSection.tsx", + "frontend-modern/src/components/Alerts/ThresholdsTableDockerHostsSection.tsx", + "frontend-modern/src/components/Alerts/alertResourceTableModel.ts", + "frontend-modern/src/components/Login.tsx", + "frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx", + "frontend-modern/src/components/shared/MobileNavBar.tsx", + "frontend-modern/src/components/shared/mobileNavBarModel.ts", + "frontend-modern/src/components/shared/useMobileNavBarState.ts", + "frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", + "frontend-modern/src/features/patrol/patrolControlPresentation.ts", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx", + "frontend-modern/src/utils/alertResourceTablePresentation.ts", + "frontend-modern/src/utils/alertThresholdsPresentation.ts", + "frontend-modern/src/utils/docsLinks.ts" ], "content_sha256": { - "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "6e4afa78ae0cfda3344573174893b80a3930d62e1f3bf2ef9c3288331c18b114", - "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "b6fee8ae8f5fcb8f1a29e561b7ec66845e1183f26888541c12dd1a2a0b57d2a2", - "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "6abfe2d91ed052f968ed45952792660d13d4dc469926a499b3929e0b4c994510" + "frontend-modern/src/AppLayout.tsx": "8acb2e0855ddbf6c73a83c15ba7ec867c07c16362e22376938cf8b0f254e91cd", + "frontend-modern/src/components/AI/Chat/index.tsx": "03544f314050f763b673aa11f9a03043e665a58fef0dbc92bce0407a3c02c008", + "frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx": "c2fe7968e9bd414518b91179b9c8d378b1d6db0fca7a2322b48f16c7bf266bd1", + "frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "75348c3dc0812f378cc889ddca5ee8bc93c9dd003808436c10e045081ff66912", + "frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx": "05da249dc2ce437dd096d775708521b74bf504c15a5cd39adbdc4024a10e1594", + "frontend-modern/src/components/Alerts/BulkEditDialog.tsx": "86a01e0d70930c8b8aebd2b6df658253895eb8c1149ff294f53b2822af5e3bfa", + "frontend-modern/src/components/Alerts/Thresholds/hooks/useCollapsedSections.ts": "579f92f5bc7d117b8ff66b4e722dff1b9f8836577eded1353f925181270d4008", + "frontend-modern/src/components/Alerts/ThresholdsTable.tsx": "18d1de01e2d58188d8a946fdfda6286390eda87ec5e42954fa383b16a5ff22c5", + "frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx": "1b224861764d806c01413e1015c1509a7530636cd482d20007805413fbadc209", + "frontend-modern/src/components/Alerts/ThresholdsTableDockerContainersSection.tsx": "65dd1ef45028d2e4006907f15fb0f8d125d7c0546339ce5ec36ad70075a65ed4", + "frontend-modern/src/components/Alerts/ThresholdsTableDockerHostsSection.tsx": "0d1e2eb5873d7975b0195551105dd84f00e165709b07eadc2ef8079a70ee38ad", + "frontend-modern/src/components/Alerts/alertResourceTableModel.ts": "89ba2a0b6ebb9d6f56f6425f22cd6f1c4793d92c27153ffc8c0aadf3150f86ba", + "frontend-modern/src/components/Login.tsx": "f3ce94c4d38e0346ad7795388b9fb57800a67007a82087b5f8b18f4845d9ce72", + "frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx": "610386b9a8b47a9559b32e54ccf72b3e06c10f48ee4a37441c15fa2b3da2c37a", + "frontend-modern/src/components/shared/MobileNavBar.tsx": "96cd6fee4aaa0f7c11501ae525857952e7c5eb7e4dbb545f4a1f38da2b5694c3", + "frontend-modern/src/components/shared/mobileNavBarModel.ts": "21e3ab0adc2533b5328391e8c5dff38ef58232f4e33d6eec0a493abac74800d2", + "frontend-modern/src/components/shared/useMobileNavBarState.ts": "0d6321f1109f10d4fc100191fcbc24c075e2f3c9ec72246a80a1a0b9f124836b", + "frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts": "0fc9036d2b8bbc213c031d10103edda7d33a80aaca43dac1a3dc98eaaa765da5", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "280db76a0ee63e15a3de2e3e8c5d95a82c49f553ac4bdd078c22db49e67fc0a6", + "frontend-modern/src/features/patrol/patrolControlPresentation.ts": "a552c303cb7c0e17701f157e19eaedede35f8e10bcfed3435ad2f37cc28761a6", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "b0825d9f94a3f5725e480f3632a62e0589a19657b74ca04e97922be5ff8c9628", + "frontend-modern/src/utils/alertResourceTablePresentation.ts": "0c61123312e72e7642b79fe42ff8828efab1a457bfbf05a11deaa88ed189476b", + "frontend-modern/src/utils/alertThresholdsPresentation.ts": "9223fef1e0b794e8db3b59a58227154d0dcd68460dd34eb9fdc6769640bb744a", + "frontend-modern/src/utils/docsLinks.ts": "957f8949adb3183a51b769fd796fedc7f24f847e87f4bfa5c5f60fb30f70fc96" }, "routes": [ - "/settings/infrastructure/agent-doctor" + "/", + "/docs/TROUBLESHOOTING", + "/settings/infrastructure", + "/settings/infrastructure?add=linux-host", + "/patrol", + "/alerts/thresholds/systems", + "/docker/overview", + "/kubernetes/overview" ], "viewports": [ { @@ -27,15 +76,26 @@ } ], "states": [ - "Authenticated mock Agent Doctor rendered 2 critical, 1 needs-attention, and 29 healthy agents without inventing credential failures from non-authoritative mock token inventory.", - "Credential-repair mode rendered a repair-aware banner and required a separate one-use scoped credential for each selected installation when multiple repairs were present.", - "The live signed-in Agent Doctor retained duplicate Pi installations as distinct rows with their separate agent versions and identities.", - "The Agent Doctor remained within the 390-pixel viewport with no horizontal document overflow." + "The pre-authenticated login showed an always-visible Can’t sign in? link at desktop and 390x844, and the shipped Troubleshooting route remained readable without a session with local, SSO/proxy, lockout, environment-override, and bootstrap-token guidance.", + "Add infrastructure opened with one primary Install Pulse Agent path, kept the Linux/macOS/Windows host choice out of the default specific-source list, exposed it once after expansion or Windows search, and routed the choice to ?add=linux-host.", + "Patrol attention scan rows used one plain evidence phrase, omitted unavailable protection metadata, and retained named evidence and protection facts in selected detail without raw token pairs.", + "A fresh threshold visit rendered the Machines section with aria-expanded=false, a zero-height and zero-opacity content rail, explicit On/Off help, and no customer-facing -1 instruction.", + "Threshold CPU editing exposed min=1; submitting 0 left the inherited 80% unchanged, Off saved as Off, re-enabling with a positive value restored 80%, and an 81% custom override produced the prominent Custom overrides summary.", + "The custom-override summary selected the Custom-only filter and expanded the matching Machines section, then reverting returned the resource to inherited defaults.", + "At 390x844 the semantic Mobile navigation rail kept one primary destination plus Alerts, Actions, Patrol, and More; an active overflow destination marked More as the current page and preserved alert/action badges.", + "The More menu grouped infrastructure and Pulse destinations, focused a menu item when opened, returned focus to the trigger on Escape, reopened from ArrowDown, and closed after navigating to Kubernetes.", + "Kubernetes subnavigation exposed labeled right and left scroll controls only when needed, moved the rail from scrollLeft 0 to 242, and kept the document width at exactly 390 pixels.", + "The mobile Assistant launcher stayed in the authenticated header flow; the open drawer and backdrop both ended at y=764 above the mobile nav at y=782.5, while the 1440px desktop launcher stayed centered on the right edge.", + "The integrated desktop and mobile routes produced no browser console warnings or errors." ], "interactions": [ - "Opened the isolated current-code Agent Doctor and verified the final mock fleet summary after the token-inventory authority fix.", - "Exercised the multiple-installation credential-repair state and verified the installation selector, separate-token guidance, and per-installation unlock behavior.", - "Inspected the live Pi duplicate-installation presentation and confirmed both reporting installations remained independently visible.", - "Resized Agent Doctor to 390x844 and measured a 390-pixel document scroll width against a 390-pixel viewport." + "Opened the login and public Troubleshooting routes before authentication, then signed in to the isolated integrated mock runtime.", + "Opened Add infrastructure, expanded the source list, searched for Windows, selected Linux/macOS/Windows host, and confirmed the governed host-add route.", + "Opened Patrol, inspected compact attention rows, and selected an attention item to confirm detailed typed evidence remained available.", + "Opened thresholds from a fresh browser origin, expanded Machines, attempted and submitted an invalid zero, disabled and re-enabled CPU alerts, created an 81% override, followed the summary action, and reverted it.", + "Resized to 390x844, opened and dismissed the More menu with keyboard controls, reopened it with ArrowDown, and navigated to Kubernetes from the overflow menu.", + "Clicked the Kubernetes subnavigation scroll-right control and measured the rail and document widths before and after movement.", + "Opened the Assistant from the mobile header, measured drawer/backdrop clearance against the mobile nav, closed it, and measured the desktop launcher at 1440x900.", + "Logged out at 390x844 and confirmed the recovery link remained visible without horizontal overflow." ] } diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index bfffa570db..a6445d150d 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -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 -- /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`. diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 5956f0bd62..aaf087a45d 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -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) => ( + + ); createEffect(() => { if (aiChatStore.isOpenSignal() || !restoreAssistantLauncherFocus) return; restoreAssistantLauncherFocus = false; @@ -805,6 +828,9 @@ export function AppLayout(props: AppLayoutProps) {
+ + {renderAssistantLauncher(AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS)} +
@@ -1022,29 +1048,11 @@ export function AppLayout(props: AppLayoutProps) { - + {/* 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. */} - - - + {renderAssistantLauncher(AI_CHAT_DESKTOP_LAUNCHER_BUTTON_CLASS)}
); diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 43740b9003..3676ad7a3e 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -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'"); diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index ba59bc81f1..f16452f9de 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -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(`[data-tab-id="${tabId}"]`); + const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' }); + ['alerts', 'actions', 'ai'].forEach((tabId) => { + const button = mobileNav.querySelector(`[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 () => { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index c146c06e75..5ec67d0000 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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(); }); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index f1b49b45e2..a821b3f81f 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -2265,7 +2265,7 @@ export const AIChat: Component = (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 = (props) => {
- + 0}> +
+
+
+

+ {state.getAlertThresholdsOverridesPresentation().title} +

+

+ {state.getAlertThresholdsOverridesPresentation().description} +

+
+
+ + {(item) => ( + + )} + +
+
+
+
+ + 0}>
+ diff --git a/frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx b/frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx index 7f6caa1c35..9312d7cca1 100644 --- a/frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx @@ -37,7 +37,9 @@ const CARD_ICON: Record = new Set(); +const PRIMARY_PATH_PICKER_ITEM_IDS: ReadonlySet = 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 = n 'pve', 'truenas', 'unraid', - 'linux-host', ]); const itemMatchesQuery = (item: InfrastructureSourcePickerItemPresentation, query: string) => { diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourcePicker.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourcePicker.test.tsx index 8145c0c8ec..0f54099199 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourcePicker.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourcePicker.test.tsx @@ -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(() => ); + const onSelectStep = vi.fn(); + render(() => ); 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'); }); }); diff --git a/frontend-modern/src/components/__tests__/Login.test.tsx b/frontend-modern/src/components/__tests__/Login.test.tsx index 37f1fc4b7a..0a7439005e 100644 --- a/frontend-modern/src/components/__tests__/Login.test.tsx +++ b/frontend-modern/src/components/__tests__/Login.test.tsx @@ -82,6 +82,10 @@ describe('Login', () => { expect(await screen.findByPlaceholderText('Username')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Password')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /sign in to pulse/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Can’t sign in?' })).toHaveAttribute( + 'href', + '/docs/TROUBLESHOOTING', + ); }); it('hides local login form when hideLocalLogin is true and an SSO provider is available', async () => { @@ -115,6 +119,10 @@ describe('Login', () => { // Username and password fields should NOT be visible expect(screen.queryByPlaceholderText('Username')).toBeNull(); expect(screen.queryByPlaceholderText('Password')).toBeNull(); + expect(screen.getByRole('link', { name: 'Can’t sign in?' })).toHaveAttribute( + 'href', + '/docs/TROUBLESHOOTING', + ); }); it('shows local login form when show_local=true is in URL even if hideLocalLogin is true', async () => { diff --git a/frontend-modern/src/components/shared/MobileNavBar.tsx b/frontend-modern/src/components/shared/MobileNavBar.tsx index 34cf1ed383..94b94c9c5e 100644 --- a/frontend-modern/src/components/shared/MobileNavBar.tsx +++ b/frontend-modern/src/components/shared/MobileNavBar.tsx @@ -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 ( <> - + {count()} + + )} +
+ + {tab().label} + + + Setup + + + + + {props.destination.kind === 'primary' ? props.destination.tab.badge : null} + + + + + + + + Pro + + ); } +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[]; + }) => ( + 0}> +
+
+ {options.label} +
+ + {(destination) => { + const active = () => isMobileNavDestinationActive(destination, props.activeTab()); + const enabled = () => + destination.kind === 'primary' ? destination.tab.enabled : undefined; + + return ( + + ); + }} + +
+
+ ); + + return ( + + ); +} + export default MobileNavBar; diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 74dfb04be2..73c283f677 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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', () => { diff --git a/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx b/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx index 224b844670..9a2a665b6e 100644 --- a/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx @@ -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) => ( - SA -); -const ProxmoxIcon: Component<{ class?: string }> = (props) => PX; -const AlertsIcon: Component<{ class?: string }> = (props) => AL; -const ActionsIcon: Component<{ class?: string }> = (props) => AC; -const SettingsIcon: Component<{ class?: string }> = (props) => SE; +const TextIcon: Component<{ class?: string }> = (props) => Icon; const PatrolIcon: Component<{ class?: string }> = (props) => ( Pulse Patrol @@ -34,26 +18,63 @@ const PatrolIcon: Component<{ class?: string }> = (props) => ( ); +function makePrimary( + id: string, + label: string, + overrides: Partial = {}, +): 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 { + 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(() => ( - '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(() => ( - '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(() => ( - '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(() => ( - 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(() => ( '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('proxmox'); - - const { container } = render(() => ( - [ - { - 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(() => ( '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(() => ( + '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(() => ( + '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(() => ( + 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'); }); }); }); diff --git a/frontend-modern/src/components/shared/__tests__/mobileNavBarModel.branchcov0712.test.ts b/frontend-modern/src/components/shared/__tests__/mobileNavBarModel.branchcov0712.test.ts index 74f3afb07b..7cac8562c8 100644 --- a/frontend-modern/src/components/shared/__tests__/mobileNavBarModel.branchcov0712.test.ts +++ b/frontend-modern/src/components/shared/__tests__/mobileNavBarModel.branchcov0712.test.ts @@ -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 = {}): Mobile }; } +function makePrimaryTab( + id: string, + overrides: Partial = {}, +): 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, - }); - }); - }); }); diff --git a/frontend-modern/src/components/shared/mobileNavBarModel.ts b/frontend-modern/src/components/shared/mobileNavBarModel.ts index ebc61e75e1..abb14992da 100644 --- a/frontend-modern/src/components/shared/mobileNavBarModel.ts +++ b/frontend-modern/src/components/shared/mobileNavBarModel.ts @@ -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([ + 'alerts', + 'actions', + 'ai', +]); export function buildOrderedMobileNavTabs( 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, - }; -} diff --git a/frontend-modern/src/components/shared/useMobileNavBarState.ts b/frontend-modern/src/components/shared/useMobileNavBarState.ts index c962d58cd5..89270d15ab 100644 --- a/frontend-modern/src/components/shared/useMobileNavBarState.ts +++ b/frontend-modern/src/components/shared/useMobileNavBarState.ts @@ -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(); + const [isOverflowOpen, setIsOverflowOpen] = createSignal(false); + const [overflowTriggerRef, setOverflowTriggerRef] = createSignal(); + const [overflowMenuRef, setOverflowMenuRef] = createSignal(); - 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 => + destination.kind === 'primary', + ), + ); + const overflowUtilityDestinations = createMemo(() => + overflowDestinations().filter( + (destination): destination is Extract => + 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('[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(`[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, }; } diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts index 32bb0a200d..029b99aab5 100644 --- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts @@ -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; } diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index 0008954cc5..a83e3897be 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -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: { {formatProvider(provider.provider)} - - {' '} - · {formatLabel(provider.jobState)} ·{' '} - {formatLabel(provider.historyCompleteness)} - + )} @@ -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 ( - - {label()} + + {presentation().label} ); } - return {label()}; + return {presentation().label}; } 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 ( - - {label()} + + {presentation().detailLabel} ); } - return {label()}; + return {(label) => {label()}}; +} + +function ProtectionProviderMetadata(props: { + provider: NonNullable['providerStates'][number]; +}) { + const labels = () => getPatrolProtectionProviderLabels(props.provider); + return ( + + {' · '} + {labels().job} + {' · '} + {labels().history} + + ); } function stateTone(item: AttentionItem): MetadataBadgeTone { diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx index e049bfad7c..3168f3fc05 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx @@ -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); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts index 9472c388cc..42cbc3ebde 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts @@ -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: { diff --git a/frontend-modern/src/features/patrol/patrolControlPresentation.ts b/frontend-modern/src/features/patrol/patrolControlPresentation.ts index 0c1fa5c806..a6b0b49cb6 100644 --- a/frontend-modern/src/features/patrol/patrolControlPresentation.ts +++ b/frontend-modern/src/features/patrol/patrolControlPresentation.ts @@ -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; diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.navigation.test.tsx b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.navigation.test.tsx index fe1e42faca..a573acd7bc 100644 --- a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.navigation.test.tsx +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.navigation.test.tsx @@ -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(() => ( + + ( + + )} + /> + + )); + + 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(() => ( diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index c2dfdc93fa..22c4d635b8 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -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(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 ( 1}> - +
+ + + + + + + + +
); } diff --git a/frontend-modern/src/utils/__tests__/alertResourceTablePresentation.test.ts b/frontend-modern/src/utils/__tests__/alertResourceTablePresentation.test.ts index d9b811b793..7989f5b019 100644 --- a/frontend-modern/src/utils/__tests__/alertResourceTablePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertResourceTablePresentation.test.ts @@ -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'); }); diff --git a/frontend-modern/src/utils/__tests__/alertThresholdsPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertThresholdsPresentation.test.ts index 0cee8cbb09..fe8ce6330e 100644 --- a/frontend-modern/src/utils/__tests__/alertThresholdsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertThresholdsPresentation.test.ts @@ -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', () => { diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 5770b2d833..6106eb7127 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -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'); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index d1f812f555..1ed24596a4 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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 ='); diff --git a/frontend-modern/src/utils/alertResourceTablePresentation.ts b/frontend-modern/src/utils/alertResourceTablePresentation.ts index 1ec75ec057..44eccbc1fd 100644 --- a/frontend-modern/src/utils/alertResourceTablePresentation.ts +++ b/frontend-modern/src/utils/alertResourceTablePresentation.ts @@ -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'; diff --git a/frontend-modern/src/utils/alertThresholdsPresentation.ts b/frontend-modern/src/utils/alertThresholdsPresentation.ts index 2a1c572e91..3de57a522d 100644 --- a/frontend-modern/src/utils/alertThresholdsPresentation.ts +++ b/frontend-modern/src/utils/alertThresholdsPresentation.ts @@ -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: { diff --git a/frontend-modern/src/utils/docsLinks.ts b/frontend-modern/src/utils/docsLinks.ts index 2e9130be82..e832a14d04 100644 --- a/frontend-modern/src/utils/docsLinks.ts +++ b/frontend-modern/src/utils/docsLinks.ts @@ -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'); diff --git a/tests/integration/tests/91-operational-trust-attention-workbench.spec.ts b/tests/integration/tests/91-operational-trust-attention-workbench.spec.ts index bc9519c474..6547aa7496 100644 --- a/tests/integration/tests/91-operational-trust-attention-workbench.spec.ts +++ b/tests/integration/tests/91-operational-trust-attention-workbench.spec.ts @@ -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)}`),