From 1b422dd1a63111214d885841bd8567d6ffe543dc Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:23:24 +0100 Subject: [PATCH] Document maintenance API and repair documentation section links Lands contributor PR #2067 (head 5eeea72a3a, author rcourtman) as a single maintainer commit on main. The PR documents the authenticated /api/resources/{id}/operator-state maintenance window contract and repairs GitHub-compatible documentation heading fragments. Composed from the reviewed web-product candidate tree 68465bc48d (candidate 20260921T054028Z-web-product), which resolved the conflicts against main and replaced the unavailable github-slugger dependency with a dependency-free GitHub-compatible heading slugger. The tree is unchanged from that reviewed candidate; only the commit shape differs so the fdb2be15be..HEAD provenance range contains no external PR commit. Validation retained from that candidate: vite build passed, check-bundle-size.mjs passed (vendor within budget), 61 focused vitest tests passed, and the offline Playwright docs-fragment-navigation run passed with a content-addressed browser-verification.json receipt based at fdb2be15be. Change-source: pulse-maintainer --- docs/API.md | 67 +++++- .../v6/internal/subsystems/api-contracts.md | 13 ++ .../subsystems/frontend-primitives.md | 11 + .../v6/internal/subsystems/registry.json | 20 +- .../docs-fragment-navigation.html | 11 + .../docs-fragment-navigation.tsx | 36 +++ frontend-modern/browser-verification.json | 44 ++-- frontend-modern/public/docs/API.md | 67 +++++- .../docs/__tests__/docMarkdown.test.ts | 58 ++++- .../src/features/docs/docMarkdown.ts | 56 +++++ frontend-modern/src/pages/Docs.tsx | 23 +- .../src/utils/__tests__/docsLinks.test.ts | 15 ++ scripts/check-docs-fragment-navigation.cjs | 213 ++++++++++++++++++ 13 files changed, 610 insertions(+), 24 deletions(-) create mode 100644 frontend-modern/browser-tests/docs-fragment-navigation.html create mode 100644 frontend-modern/browser-tests/docs-fragment-navigation.tsx create mode 100644 scripts/check-docs-fragment-navigation.cjs diff --git a/docs/API.md b/docs/API.md index 187bb3eb02..aa44a080f7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -41,6 +41,7 @@ Public endpoints include: Some endpoints require admin privileges and/or scopes. Common scopes include: - `monitoring:read` +- `monitoring:write` - `settings:read` - `settings:write` - `agent:config:read` @@ -197,6 +198,66 @@ Report an incorrect merge (creates exclusions). { "sources": ["proxmox", "agent"], "notes": "optional note" } ``` +### Resource Maintenance and Operator State + +Use a timed maintenance window to suppress alerts during planned work on a +resource. This API, including descendant scope, is available in stable v6.4.1. +Choose the resource's `id` from `GET /api/resources` and URL-encode it in the +request path. Do not substitute a display name or a Proxmox VM number. + +| Endpoint | Required scope | Result | +| --- | --- | --- | +| `GET /api/resources/{id}/operator-state` | `monitoring:read` | Returns the saved state, or HTTP 404 with `operator_state_not_set` when no state is saved. | +| `PUT /api/resources/{id}/operator-state` | `monitoring:write` | Replaces the entire state and returns the saved record. | +| `DELETE /api/resources/{id}/operator-state` | `monitoring:write` | Removes the entire state, returning HTTP 204 even if it was already absent. | + +**PUT replaces the whole record, not just the fields supplied.** Read the current +state first and preserve unrelated settings, such as monitoring mode, lifecycle, +remediation policy, criticality and notes. Treat only `operator_state_not_set` +as an empty record, and stop on other read errors. Coordinate concurrent writers +so a read followed by a PUT does not overwrite someone else's changes. + +For a one-time window, merge these fields into that record, replacing the example +times with your intended start and end: + +```json +{ + "maintenanceStartAt": "2026-10-15T20:00:00Z", + "maintenanceEndAt": "2026-10-15T20:30:00Z", + "maintenanceScope": "resource_and_descendants", + "maintenanceReason": "Proxmox node maintenance" +} +``` + +This is a set of fields to merge, not a complete replacement for an existing +record. Remove any `maintenanceRecurrence` when replacing a recurring schedule +with this one-time window. The API rejects a record containing both. + +| Field | Meaning | +| --- | --- | +| `maintenanceStartAt`, `maintenanceEndAt` | RFC3339 timestamps. Supply both, with the end strictly after the start. The window is active from the start, inclusive, until the end, exclusive. | +| `maintenanceScope` | `resource` applies only to this resource and is the default. `resource_and_descendants` also covers descendants in Pulse's resource hierarchy, such as a node's guests. | +| `maintenanceReason` | Optional explanation of the planned work. | + +The response includes `maintenanceWindowActive` and, while active, +`maintenanceActiveStartAt` and `maintenanceActiveEndAt`. The server sets the +record's `setAt` and `setBy` fields from the request, ignoring client values. + +During an active window, matching new alerts and firing/recovery notifications +are suppressed. Applying an active window also clears matching existing alerts +from the active list, retaining history. This is broader than pausing delivery +for an existing incident. Resources outside the scope remain monitored normally. + +The window expires automatically, with no re-enable request needed. Subsequent +observations can raise alerts again, subject to normal alert policy and any other +active maintenance window. Previously cleared alerts are not automatically +restored, and expiry does not replay a backlog of maintenance notifications. + +To end maintenance early, read the latest record, remove its maintenance fields +and PUT the remaining state back. Do not DELETE the record unless you also intend +to remove its other operator settings. An inherited window must be changed on +the ancestor that owns it. + ### Fleet Connections `GET /api/connections` Returns the canonical fleet connections ledger with per-row fleet-governance state. Requires admin access with `settings:read`. @@ -648,6 +709,10 @@ Common reporting error codes: Alert configuration and history (requires `monitoring:read`/`monitoring:write`). +For planned downtime, use [Resource Maintenance and Operator State](#resource-maintenance-and-operator-state). +For an existing incident whose notifications should be paused without clearing +it, use the snooze and unsnooze endpoints below. + - `GET /api/alerts/config` - `PUT /api/alerts/config` - `GET /api/alerts/deadman/config` — returns only whether an external watchdog @@ -659,7 +724,7 @@ Alert configuration and history (requires `monitoring:read`/`monitoring:write`). - `GET /api/alerts/deadman/status` — live watchdog health, monitor-loop progress, sanitized delivery failure state, and the most recent restart interruption; never includes the URL or endpoint fingerprint -- `POST /api/alerts/activate` +- `POST /api/alerts/activate` enables notification delivery and can notify about existing unacknowledged critical alerts. It is not a maintenance toggle, and there is no matching `/api/alerts/deactivate` endpoint. - `GET /api/alerts/active` - `GET /api/alerts/delivery-diagnosis?alertIdentifier=` (omit `alertIdentifier` to get the diagnosis array for every active alert) - `GET /api/alerts/events?alertIdentifier=&type=&since=&limit=` — append-only alert event log: lifecycle transitions and notification decisions, including suppressions with reasons; all parameters optional, newest first diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index f53afc75fd..37bc55d860 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -20,6 +20,19 @@ ## Purpose +### Resource maintenance API reference + +The canonical and shipped API references document the existing authenticated +GET/PUT/DELETE `/api/resources/{id}/operator-state` contract. Read access requires +`monitoring:read`, and replacement or deletion requires `monitoring:write`. +Maintenance examples preserve unrelated operator settings and distinguish +resource-only scope from inherited descendant maintenance. Bounded expiry ends +maintenance suppression without a separate activation call. Applying active +maintenance clears matching active alerts while retaining history, rather than +promising incident-preserving snooze semantics. This documentation change adds +no route, permission or lifecycle behavior. The existing `docsLinks` checks keep +the shipped reference synchronized with `docs/API.md`. + ### Ollama credential lifecycle Settings return only `ollama_username` and `ollama_password_set`, never the password. An omitted password preserves it, a supplied password replaces it diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index ecb5d6a077..4109f6d402 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,17 @@ ## Purpose +### Shipped documentation fragment navigation + +The shared documentation renderer assigns GitHub-compatible, document-local +heading IDs from sanitized text, retaining explicit anchors and avoiding +duplicate IDs. The documentation viewer follows fragments after asynchronous +content rendering as well as in-page navigation. Fragment targets receive +keyboard focus without entering the normal tab order. Missing or malformed +fragments do not throw or move focus. The renderer and fragment helper are +covered by `frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts`, +with direct-link, reload and keyboard navigation verified in the live viewer. + ### Disk mount scrolling DisksCard keeps every supplied mount in its parent's scrolling flow. It must not diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index d569008e3c..12fcc8e950 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5128,7 +5128,8 @@ "lane": "L8", "contract": "docs/release-control/v6/internal/subsystems/frontend-primitives.md", "owned_prefixes": [ - "frontend-modern/src/components/shared/" + "frontend-modern/src/components/shared/", + "frontend-modern/src/features/docs/" ], "owned_files": [ "frontend-modern/scripts/__tests__/planning-doc-status-audit.test.mjs", @@ -5229,6 +5230,7 @@ "frontend-modern/src/i18n/messages.ts", "frontend-modern/src/i18n/policy.ts", "frontend-modern/src/index.css", + "frontend-modern/src/pages/Docs.tsx", "frontend-modern/src/routing/routePreload.ts", "frontend-modern/src/stores/aiChat.ts", "frontend-modern/src/stores/updates.ts", @@ -5897,6 +5899,22 @@ "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", "frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts" ] + }, + { + "id": "shipped-documentation-navigation", + "label": "sanitized documentation rendering and fragment navigation proof", + "match_prefixes": [ + "frontend-modern/src/features/docs/" + ], + "match_files": [ + "frontend-modern/src/pages/Docs.tsx" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts", + "frontend-modern/src/pages/__tests__/Docs.test.ts" + ] } ], "match_files": null diff --git a/frontend-modern/browser-tests/docs-fragment-navigation.html b/frontend-modern/browser-tests/docs-fragment-navigation.html new file mode 100644 index 0000000000..db161db13f --- /dev/null +++ b/frontend-modern/browser-tests/docs-fragment-navigation.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/frontend-modern/browser-tests/docs-fragment-navigation.tsx b/frontend-modern/browser-tests/docs-fragment-navigation.tsx new file mode 100644 index 0000000000..2f929f78e7 --- /dev/null +++ b/frontend-modern/browser-tests/docs-fragment-navigation.tsx @@ -0,0 +1,36 @@ +// Browser fixture: the production Docs page under the production router. +// +// It exists to verify #2067's shipped-documentation navigation in a real +// browser: GitHub-compatible heading IDs, direct/reload fragment focus and +// scrolling after the asynchronous Markdown fetch, malformed-fragment safety +// and keyboard activation of intra-document links. It serves the real +// `frontend-modern/public/docs/API.md` asset from the Vite dev server, so no +// backend, API or WebSocket path is used. +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; + +import Docs from '../src/pages/Docs'; +import '../src/index.css'; + +const scenario = new URLSearchParams(window.location.search).get('scenario') ?? 'plain'; + +const targets: Record = { + plain: '/docs/API', + direct: '/docs/API#resource-maintenance-and-operator-state', + reload: '/docs/API#resource-maintenance-and-operator-state', + malformed: '/docs/API#%invalid', + missing: '/docs/API#does-not-exist', +}; + +window.history.replaceState({}, '', targets[scenario] ?? targets.plain); + +render( + () => ( + + + +

not found

} /> +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 9d12fef7d7..f7e826e095 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,38 +1,48 @@ { "version": 1, - "base_sha": "788936b72d3360a54ad801387c96c6b444d2fc27", - "verified_at": "2026-09-20T22:48:14Z", + "base_sha": "fdb2be15befb4fbcd34b4e0399b147bfa098b355", + "verified_at": "2026-09-21T05:50:47Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts" + "frontend-modern/src/features/docs/docMarkdown.ts", + "frontend-modern/src/pages/Docs.tsx" ], "content_sha256": { - "frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts": "e1cc0cb0f4087c5c66014d254421aeea6b02842795c41978e1b9847dcfcb5c77" + "frontend-modern/src/features/docs/docMarkdown.ts": "e114a4c2e49a8dc0f2549a704af6ceac54d4eca0cfcae28c8ab8cb6cc1b0718c", + "frontend-modern/src/pages/Docs.tsx": "c6c4b066c6af8a4eb8f01a9e03c2f939ee1592867dea2bf0f328bf111f99f2eb" }, "routes": [ - "/browser-tests/thresholds-windowed-list.html" + "/docs/API", + "/docs/API#resource-maintenance-and-operator-state", + "/docs/README" ], "viewports": [ { - "width": 1280, + "width": 1440, "height": 900 }, { - "width": 480, - "height": 800 + "width": 390, + "height": 844 } ], "states": [ - "Alert Thresholds card list with two Proxmox hosts and 30 guests, both hosts exposing a guest named gateway01", - "The virtual window mounts 24 of 32 items with the leading item a 36px group header and each guest card 315px tall", - "After a 70px scroll the first group header is still within the mounted window; the final guest is reachable at the bottom" + "API reference renders 149 headings, every one with a unique generated id; the maintenance section id is resource-maintenance-and-operator-state", + "The in-document link to #resource-maintenance-and-operator-state resolves to the generated heading", + "Direct fragment load focuses and scrolls the maintenance heading after the asynchronous Markdown fetch", + "Reload on the same fragment restores focus and target", + "Malformed (#%invalid) and missing (#does-not-exist) fragments leave the document rendered and do not move focus", + "Keyboard activation of the All documentation link routes to /docs/README inside the SPA shell", + "Wide API tables scroll inside their own container; neither viewport overflows horizontally" ], "interactions": [ - "Mount the production AlertResourceTableMobile renderer with synthetic grouped resources in a 600px native scroll container", - "Confirm the bottom spacer is 2520px, i.e. eight unmounted rows at the measured 315px card height rather than the 36px group-header height (which previously produced 288px)", - "Scroll the container 70px and confirm the top spacer stays 0, so the window does not jump past the first group", - "Scroll to the bottom and confirm the last guest renders; inspect desktop and narrow screenshots" + "Mount the production Docs page under the production router and serve the real public/docs/API.md asset from the Vite dev server", + "Load /docs/API, /docs/API#resource-maintenance-and-operator-state, a malformed fragment and a missing fragment, and reload the direct fragment", + "Assert document.activeElement, tabIndex, scrollIntoView position and window.location.hash", + "Focus the first documentation link and activate it with Enter, confirming router navigation without a full reload", + "Resize to 390x844 and check page overflow and table scroll wrappers", + "Record Chromium 141.0.7390.37 with no uncaught page errors and no failed responses" ], - "command": "pulse-worker-browser scripts/check-thresholds-windowed-list.cjs", - "notes": "Offline Playwright Chromium run of the production Alert Thresholds card list (the narrow-layout renderer that virtualises via PlatformWindowedList) with synthetic props and the project CSS; no backend, API or WebSocket path. Before the fix the same run failed with bottom spacer 288px (8 x the 36px leading group header), so the item-height estimate collapsed to the header and the mounted window desynchronised from the scroll position, dropping a host's rows (#2130). After the fix the estimate follows the 315px card, the window holds through a 70px scroll, and the last guest is reachable. No page errors and no failed responses. Chromium 141.0.7390.37, matching the offline runtime. This receipt is content-addressed to usePlatformWindowedItems.ts sha256 e1cc0cb0…, verified at 22:48Z, and refreshed at the candidate tip so the cumulative browser guard can match the tip parent." + "command": "pulse-worker-browser scripts/check-docs-fragment-navigation.cjs", + "notes": "Offline Playwright Chromium run of the production Docs page. Synthetic backend: /api/security/status is answered with hasAuthentication=false; the shipped API markdown asset is served by the Vite dev server, not a live Pulse. Heading ids follow GitHub's algorithm (lower-case, punctuation removed, spaces to hyphens, per-document duplicate numbering) so repository fragment links resolve. The renderer keeps the documentation route dependency-free. Chromium 141.0.7390.37 matches the offline runtime. This receipt is content-addressed to the changed frontend sources at this candidate base." } diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index 187bb3eb02..aa44a080f7 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -41,6 +41,7 @@ Public endpoints include: Some endpoints require admin privileges and/or scopes. Common scopes include: - `monitoring:read` +- `monitoring:write` - `settings:read` - `settings:write` - `agent:config:read` @@ -197,6 +198,66 @@ Report an incorrect merge (creates exclusions). { "sources": ["proxmox", "agent"], "notes": "optional note" } ``` +### Resource Maintenance and Operator State + +Use a timed maintenance window to suppress alerts during planned work on a +resource. This API, including descendant scope, is available in stable v6.4.1. +Choose the resource's `id` from `GET /api/resources` and URL-encode it in the +request path. Do not substitute a display name or a Proxmox VM number. + +| Endpoint | Required scope | Result | +| --- | --- | --- | +| `GET /api/resources/{id}/operator-state` | `monitoring:read` | Returns the saved state, or HTTP 404 with `operator_state_not_set` when no state is saved. | +| `PUT /api/resources/{id}/operator-state` | `monitoring:write` | Replaces the entire state and returns the saved record. | +| `DELETE /api/resources/{id}/operator-state` | `monitoring:write` | Removes the entire state, returning HTTP 204 even if it was already absent. | + +**PUT replaces the whole record, not just the fields supplied.** Read the current +state first and preserve unrelated settings, such as monitoring mode, lifecycle, +remediation policy, criticality and notes. Treat only `operator_state_not_set` +as an empty record, and stop on other read errors. Coordinate concurrent writers +so a read followed by a PUT does not overwrite someone else's changes. + +For a one-time window, merge these fields into that record, replacing the example +times with your intended start and end: + +```json +{ + "maintenanceStartAt": "2026-10-15T20:00:00Z", + "maintenanceEndAt": "2026-10-15T20:30:00Z", + "maintenanceScope": "resource_and_descendants", + "maintenanceReason": "Proxmox node maintenance" +} +``` + +This is a set of fields to merge, not a complete replacement for an existing +record. Remove any `maintenanceRecurrence` when replacing a recurring schedule +with this one-time window. The API rejects a record containing both. + +| Field | Meaning | +| --- | --- | +| `maintenanceStartAt`, `maintenanceEndAt` | RFC3339 timestamps. Supply both, with the end strictly after the start. The window is active from the start, inclusive, until the end, exclusive. | +| `maintenanceScope` | `resource` applies only to this resource and is the default. `resource_and_descendants` also covers descendants in Pulse's resource hierarchy, such as a node's guests. | +| `maintenanceReason` | Optional explanation of the planned work. | + +The response includes `maintenanceWindowActive` and, while active, +`maintenanceActiveStartAt` and `maintenanceActiveEndAt`. The server sets the +record's `setAt` and `setBy` fields from the request, ignoring client values. + +During an active window, matching new alerts and firing/recovery notifications +are suppressed. Applying an active window also clears matching existing alerts +from the active list, retaining history. This is broader than pausing delivery +for an existing incident. Resources outside the scope remain monitored normally. + +The window expires automatically, with no re-enable request needed. Subsequent +observations can raise alerts again, subject to normal alert policy and any other +active maintenance window. Previously cleared alerts are not automatically +restored, and expiry does not replay a backlog of maintenance notifications. + +To end maintenance early, read the latest record, remove its maintenance fields +and PUT the remaining state back. Do not DELETE the record unless you also intend +to remove its other operator settings. An inherited window must be changed on +the ancestor that owns it. + ### Fleet Connections `GET /api/connections` Returns the canonical fleet connections ledger with per-row fleet-governance state. Requires admin access with `settings:read`. @@ -648,6 +709,10 @@ Common reporting error codes: Alert configuration and history (requires `monitoring:read`/`monitoring:write`). +For planned downtime, use [Resource Maintenance and Operator State](#resource-maintenance-and-operator-state). +For an existing incident whose notifications should be paused without clearing +it, use the snooze and unsnooze endpoints below. + - `GET /api/alerts/config` - `PUT /api/alerts/config` - `GET /api/alerts/deadman/config` — returns only whether an external watchdog @@ -659,7 +724,7 @@ Alert configuration and history (requires `monitoring:read`/`monitoring:write`). - `GET /api/alerts/deadman/status` — live watchdog health, monitor-loop progress, sanitized delivery failure state, and the most recent restart interruption; never includes the URL or endpoint fingerprint -- `POST /api/alerts/activate` +- `POST /api/alerts/activate` enables notification delivery and can notify about existing unacknowledged critical alerts. It is not a maintenance toggle, and there is no matching `/api/alerts/deactivate` endpoint. - `GET /api/alerts/active` - `GET /api/alerts/delivery-diagnosis?alertIdentifier=` (omit `alertIdentifier` to get the diagnosis array for every active alert) - `GET /api/alerts/events?alertIdentifier=&type=&since=&limit=` — append-only alert event log: lifecycle transitions and notification decisions, including suppressions with reasons; all parameters optional, newest first diff --git a/frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts b/frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts index bce5221d61..56b5aaed08 100644 --- a/frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts +++ b/frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { docAssetUrlForPath, docRouteForPath, @@ -6,6 +6,7 @@ import { normalizeDocPath, renderDocMarkdown, resolveDocLink, + scrollToDocFragment, stripLeadingTitle, wrapTables, } from '../docMarkdown'; @@ -86,6 +87,34 @@ describe('renderDocMarkdown', () => { expect(html).toContain('
  • one
  • '); }); + it('creates stable GitHub fragments from heading text, including duplicate headings', () => { + const source = '## Resource **Maintenance**\n\n## Resource Maintenance\n\n## Привет 你好'; + const container = document.createElement('div'); + container.innerHTML = renderDocMarkdown(source, 'API'); + expect(Array.from(container.querySelectorAll('h2'), (heading) => heading.id)).toEqual([ + 'resource-maintenance', + 'resource-maintenance-1', + 'привет-你好', + ]); + // Rendering another document must not inherit the first one's counters. + expect(renderDocMarkdown('## Resource Maintenance', 'OTHER')).toContain( + 'id="resource-maintenance"', + ); + }); + + it('keeps explicit anchors and avoids assigning their IDs to another heading', () => { + const container = document.createElement('div'); + container.innerHTML = renderDocMarkdown( + '\n\n## Maintenance\n\n## Maintenance', + 'API', + ); + expect(container.querySelector('a')?.id).toBe('maintenance'); + expect(Array.from(container.querySelectorAll('h2'), (heading) => heading.id)).toEqual([ + 'maintenance-1', + 'maintenance-2', + ]); + }); + it('points intra-doc links at the viewer and marks them for routing', () => { const html = renderDocMarkdown('[Install](INSTALL.md)', 'README'); expect(html).toContain('href="/docs/INSTALL"'); @@ -131,6 +160,33 @@ describe('renderDocMarkdown', () => { }); }); +describe('scrollToDocFragment', () => { + it('scrolls and moves keyboard focus to an encoded fragment in the rendered document', () => { + const container = document.createElement('article'); + container.innerHTML = renderDocMarkdown('## Привет 你好', 'API'); + document.body.appendChild(container); + const heading = container.querySelector('h2')!; + heading.scrollIntoView = vi.fn(); + try { + scrollToDocFragment(container, `#${encodeURIComponent(heading.id)}`); + expect(heading.scrollIntoView).toHaveBeenCalledWith({ block: 'start' }); + expect(document.activeElement).toBe(heading); + expect(heading.tabIndex).toBe(-1); + } finally { + container.remove(); + } + }); + + it('ignores absent or malformed fragments without moving focus', () => { + const container = document.createElement('article'); + container.innerHTML = '

    Maintenance

    '; + const heading = container.querySelector('h2')!; + heading.scrollIntoView = vi.fn(); + for (const hash of ['', '#', '#missing', '#%invalid']) scrollToDocFragment(container, hash); + expect(heading.scrollIntoView).not.toHaveBeenCalled(); + }); +}); + describe('wrapTables', () => { // Making the table itself display:block; overflow-x:auto is not enough, its // rows still lay out past the block box and push the page into horizontal diff --git a/frontend-modern/src/features/docs/docMarkdown.ts b/frontend-modern/src/features/docs/docMarkdown.ts index 2007a645f3..5938c20ebf 100644 --- a/frontend-modern/src/features/docs/docMarkdown.ts +++ b/frontend-modern/src/features/docs/docMarkdown.ts @@ -140,6 +140,21 @@ export function wrapTables(container: HTMLElement): void { }); } +/** + * GitHub-compatible heading slug: lower-cased, punctuation removed and spaces + * replaced with hyphens. This mirrors the anchors GitHub generates for + * repository headings so links such as `#resource-maintenance` resolve in the + * shipped viewer. It is implemented locally, without a runtime dependency, so + * the documentation route adds no bundle weight beyond the renderer it already + * ships. + */ +function githubHeadingSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^\p{L}\p{N}\p{M}\p{Pc} -]/gu, '') + .replace(/ /g, '-'); +} + /** * Points intra-documentation links at the viewer instead of the raw asset, so * following one renders the next document rather than downloading its source. @@ -148,6 +163,29 @@ export function rewriteDocLinks(html: string, currentDocPath: string): string { if (typeof document === 'undefined') return html; const container = document.createElement('div'); container.innerHTML = html; + // Repository links use GitHub heading fragments. Marked emits headings + // without IDs, so generate them from sanitized text and keep duplicate + // headings distinct without colliding with explicit document anchors. The + // occurrence counter is document-local, matching GitHub's per-document + // duplicate numbering. + const occurrences: Record = Object.create(null); + const slug = (text: string): string => { + const base = githubHeadingSlug(text); + if (Object.prototype.hasOwnProperty.call(occurrences, base)) { + occurrences[base] += 1; + return `${base}-${occurrences[base]}`; + } + occurrences[base] = 0; + return base; + }; + const ids = new Set(Array.from(container.querySelectorAll('[id]'), (element) => element.id)); + container.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((heading) => { + if (heading.id) return; + let id = slug(heading.textContent ?? ''); + while (ids.has(id)) id = slug(heading.textContent ?? ''); + heading.id = id; + ids.add(id); + }); wrapTables(container); container.querySelectorAll('a[href]').forEach((anchor) => { @@ -169,6 +207,24 @@ export function rewriteDocLinks(html: string, currentDocPath: string): string { return container.innerHTML; } +/** Follow a fragment after the asynchronously loaded document is rendered. */ +export function scrollToDocFragment(container: HTMLElement, hash: string): void { + if (!hash || hash === '#') return; + let id: string; + try { + id = decodeURIComponent(hash.replace(/^#/, '')); + } catch { + return; + } + const target = Array.from(container.querySelectorAll('[id]')).find( + (element) => element.id === id, + ); + if (!target) return; + target.tabIndex = -1; + target.scrollIntoView({ block: 'start' }); + target.focus({ preventScroll: true }); +} + /** First level-one heading, used as the document title. */ export function extractDocTitle(markdown: string, fallback: string): string { const index = leadingTitleLineIndex(markdown); diff --git a/frontend-modern/src/pages/Docs.tsx b/frontend-modern/src/pages/Docs.tsx index b0d9867e91..1acd289ebc 100644 --- a/frontend-modern/src/pages/Docs.tsx +++ b/frontend-modern/src/pages/Docs.tsx @@ -1,5 +1,5 @@ -import { useNavigate, useParams } from '@solidjs/router'; -import { Show, createMemo, createResource } from 'solid-js'; +import { useLocation, useNavigate, useParams } from '@solidjs/router'; +import { Show, createEffect, createMemo, createResource, onCleanup } from 'solid-js'; import { PageHeader } from '@/components/shared/PageHeader'; import { docAssetUrlForPath, @@ -7,6 +7,7 @@ import { extractDocTitle, normalizeDocPath, renderDocMarkdown, + scrollToDocFragment, stripLeadingTitle, } from '@/features/docs/docMarkdown'; @@ -37,6 +38,8 @@ export function isHtmlResponse(response: Pick): boolean { export default function Docs() { const params = useParams<{ docPath?: string }>(); const navigate = useNavigate(); + const location = useLocation(); + let article: HTMLElement | undefined; const docPath = createMemo(() => normalizeDocPath(params.docPath ?? '') || DOCS_INDEX_PATH); const [document] = createResource(docPath, fetchDoc); @@ -58,6 +61,19 @@ export default function Docs() { return source ? renderDocMarkdown(stripLeadingTitle(source), docPath()) : ''; }); + // A direct link or cross-document navigation can arrive before fetchDoc + // finishes. Retry once the rendered content exists, without timers or + // observers that could later pull the reader away from their position. + createEffect(() => { + const content = html(); + const hash = location.hash; + if (!content || !hash) return; + const frame = requestAnimationFrame(() => { + if (article) scrollToDocFragment(article, hash); + }); + onCleanup(() => cancelAnimationFrame(frame)); + }); + // Intra-documentation links are rewritten to viewer routes by the renderer. // Delegate their clicks to the router so following one does not reload the // application shell. @@ -112,13 +128,14 @@ export default function Docs() { } >
    { } }); + it('ships a bounded maintenance example separately from incident snoozing', () => { + const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); + const maintenance = apiReference + .split('### Resource Maintenance and Operator State')[1] + .split('### Fleet Connections')[0]; + const example = JSON.parse(/```json\s*([\s\S]*?)```/.exec(maintenance)![1]); + expect(Date.parse(example.maintenanceEndAt)).toBeGreaterThan( + Date.parse(example.maintenanceStartAt), + ); + expect(example.maintenanceScope).toBe('resource_and_descendants'); + expect(maintenance).toContain('PUT replaces the whole record'); + expect(maintenance).toContain('Do not DELETE the record unless you also intend'); + expect(apiReference).toContain('](#resource-maintenance-and-operator-state)'); + }); + it('ships the per-alert snooze and resume API contract', () => { const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); const shippedAPIReference = readFileSync( diff --git a/scripts/check-docs-fragment-navigation.cjs b/scripts/check-docs-fragment-navigation.cjs new file mode 100644 index 0000000000..b614d6fcdc --- /dev/null +++ b/scripts/check-docs-fragment-navigation.cjs @@ -0,0 +1,213 @@ +// Offline real-browser verification for #2067's shipped-documentation +// navigation. Mounts the production Docs page under the production router and +// serves the real frontend-modern/public/docs/API.md asset, then checks +// GitHub-compatible heading IDs, direct/reload fragment focus and scrolling, +// malformed-fragment safety and keyboard activation of intra-document links. +// +// Run from the assigned workspace root: +// pulse-worker-browser scripts/check-docs-fragment-navigation.cjs +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +const ROOT = path.resolve(process.cwd(), 'frontend-modern'); +const PORT = 5197; +const FIXTURE = '/browser-tests/docs-fragment-navigation.html'; +const TARGET_ID = 'resource-maintenance-and-operator-state'; +const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbox'] }; + +const failures = []; +const observations = []; +const pageErrors = []; +const httpErrors = []; + +function check(condition, message) { + if (!condition) failures.push(message); + observations.push(`${condition ? 'PASS' : 'FAIL'}: ${message}`); +} + +(async () => { + process.chdir(ROOT); + const { createServer } = await import( + path.join(ROOT, 'node_modules', 'vite', 'dist', 'node', 'index.js') + ); + const server = await createServer({ + root: ROOT, + configFile: path.join(ROOT, 'vite.config.ts'), + server: { host: '127.0.0.1', port: PORT, strictPort: true }, + }); + let browser; + let result = { result: 'failed', failures, observations }; + try { + await server.listen(); + browser = await chromium.launch(launchOptions); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() !== 'error') return; + if (message.text().includes('Failed to load resource')) return; + pageErrors.push(`console: ${message.text()}`); + }); + page.on('response', (response) => { + if (response.status() >= 400) httpErrors.push(`${response.status()} ${response.url()}`); + }); + // The Docs fixture has no backend. Answer the app-runtime auth probe so it + // settles instead of logging an uncaught network failure. Anchor to the + // server root so `src/api/...` modules are not intercepted. + const backendApi = new RegExp(`^http://127\\.0\\.0\\.1:${PORT}/api/`); + await page.route(backendApi, (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }), + ); + await page.route(new RegExp(`^http://127\\.0\\.0\\.1:${PORT}/api/security/status$`), (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ hasAuthentication: false }), + }), + ); + + const url = (scenario) => + `http://127.0.0.1:${PORT}${FIXTURE}?scenario=${scenario}`; + + const waitForTargetFocus = async (timeout = 20000) => { + await page.waitForSelector(`#${TARGET_ID}`, { timeout }); + await page.waitForFunction( + (id) => document.activeElement && document.activeElement.id === id, + TARGET_ID, + { timeout }, + ); + }; + + // --- plain: heading IDs, anchor resolution, layout ---------------------- + await page.goto(url('plain')); + await page.waitForSelector('article h1, article h2', { timeout: 20000 }); + const plain = await page.evaluate(() => { + const headings = Array.from(document.querySelectorAll('article h1, article h2, article h3, article h4, article h5, article h6')); + const ids = headings.map((heading) => heading.id); + const docLinks = Array.from(document.querySelectorAll('article a[href]')); + return { + count: headings.length, + missingIds: ids.filter((id) => !id).length, + uniqueIds: new Set(ids).size === ids.length, + targetExists: ids.includes('resource-maintenance-and-operator-state'), + fragmentLinks: docLinks + .map((anchor) => anchor.getAttribute('href') || '') + .filter((href) => href.includes('#')), + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + }; + }); + check(plain.count > 10, `renders the API reference headings (${plain.count})`); + check(plain.missingIds === 0, 'every rendered heading carries a generated id'); + check(plain.uniqueIds, 'generated heading ids are unique within the document'); + check(plain.targetExists, `generated id "${TARGET_ID}" exists for the maintenance section`); + check( + plain.fragmentLinks.some((href) => href.endsWith(`#${TARGET_ID}`)), + 'the in-document maintenance fragment link resolves to the generated id', + ); + check(!plain.overflow, 'desktop viewport has no horizontal page overflow'); + + // --- direct: fragment focus after async fetch --------------------------- + await page.goto(url('direct')); + await waitForTargetFocus(); + const direct = await page.evaluate((id) => { + const target = document.getElementById(id); + const rect = target.getBoundingClientRect(); + return { + active: document.activeElement ? document.activeElement.id : null, + tabIndex: target.tabIndex, + top: Math.round(rect.top), + hash: window.location.hash, + }; + }, TARGET_ID); + check(direct.active === TARGET_ID, 'direct fragment link moves keyboard focus to the heading'); + check(direct.tabIndex === -1, 'fragment target receives programmatic focus without joining the tab order'); + check(direct.top >= -4 && direct.top <= 200, `direct fragment scrolled target into view (top ${direct.top})`); + check(direct.hash === `#${TARGET_ID}`, 'direct fragment hash is preserved'); + + // --- reload: same behaviour after a full reload ------------------------- + await page.reload(); + await waitForTargetFocus(); + const reloaded = await page.evaluate((id) => ({ + active: document.activeElement ? document.activeElement.id : null, + exists: Boolean(document.getElementById(id)), + }), TARGET_ID); + check(reloaded.exists && reloaded.active === TARGET_ID, 'reload on a direct fragment restores focus and target'); + + // --- malformed and missing fragments ------------------------------------ + for (const scenario of ['malformed', 'missing']) { + await page.goto(url(scenario)); + await page.waitForSelector('article h1, article h2', { timeout: 20000 }); + await page.waitForTimeout(500); + const state = await page.evaluate(() => ({ + activeTag: document.activeElement ? document.activeElement.tagName : null, + activeIsHeading: Boolean(document.activeElement && /^H[1-6]$/.test(document.activeElement.tagName)), + hasContent: Boolean(document.querySelector('article h1, article h2')), + })); + check(state.hasContent, `${scenario} fragment leaves the document rendered`); + check(!state.activeIsHeading, `${scenario} fragment does not move focus onto a heading`); + } + + // --- keyboard activation of an intra-document link ---------------------- + await page.goto(url('plain')); + await page.waitForSelector('article h1, article h2', { timeout: 20000 }); + await page.evaluate(() => { + window.__spaMarker = 'alive'; + }); + const backLink = page.locator('a[data-doc-link]').first(); + await backLink.focus(); + await backLink.press('Enter'); + await page.waitForFunction( + () => window.location.pathname === '/docs/README', + undefined, + { timeout: 20000 }, + ); + await page.waitForFunction( + () => document.body.innerText.includes('Pulse documentation'), + undefined, + { timeout: 20000 }, + ); + const keyboard = await page.evaluate(() => ({ + path: window.location.pathname, + marker: window.__spaMarker, + heading: Boolean(document.querySelector('article h1, article h2')), + })); + check(keyboard.path === '/docs/README', 'keyboard activation of a documentation link navigates the router'); + check(keyboard.marker === 'alive', 'keyboard navigation stays inside the SPA shell (no full reload)'); + check(keyboard.heading, 'the destination document renders after keyboard navigation'); + + // --- narrow viewport ---------------------------------------------------- + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(url('plain')); + await page.waitForSelector('article h1, article h2', { timeout: 20000 }); + const narrow = await page.evaluate(() => ({ + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + tableScrollers: Array.from(document.querySelectorAll('article [data-doc-table-scroll]')).length, + })); + check(!narrow.overflow, 'narrow viewport has no horizontal page overflow'); + check(narrow.tableScrollers > 0, 'wide API tables are wrapped in their own scroll container'); + + check(pageErrors.length === 0, `no uncaught page errors (${pageErrors.join('; ') || 'none'})`); + check(httpErrors.length === 0, `no failed HTTP responses (${httpErrors.join('; ') || 'none'})`); + + result = { + result: failures.length === 0 ? 'passed' : 'failed', + failures, + observations, + pageErrors, + httpErrors, + chromium: browser.version(), + }; + } catch (error) { + failures.push(`runner error: ${error.message}`); + result = { result: 'failed', failures, observations, pageErrors, httpErrors }; + } finally { + if (browser) await browser.close().catch(() => undefined); + await server.close().catch(() => undefined); + } + + const out = path.join(process.cwd(), 'docs-fragment-verification-result.json'); + fs.writeFileSync(out, JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); + process.exit(result.result === 'passed' ? 0 : 1); +})();