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 5f27e02b1f..3554e0dc8b 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/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 54f50653c0..4f00da9b6b 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -15,6 +15,14 @@ ## Purpose +### Documentation heading dependency + +The frontend locks `github-slugger` 2.0.0 for GitHub-compatible documentation +heading IDs. It adds no transitive dependency or installation script. The +dependency-security proof verifies its integrity-backed lock entry and bounded +installation footprint, while the shared documentation renderer tests cover +heading behavior. Existing DOMPurify sanitization remains the HTML boundary. + ### Docker SDK dependency compatibility The Docker consumers use Moby API v1.56.0 and client v0.6.0 together, without diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index de97ab6f53..a6219c8e68 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 d405bc9a2b..2b00c30d8b 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5112,7 +5112,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", @@ -5213,6 +5214,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", @@ -5881,6 +5883,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-verification.json b/frontend-modern/browser-verification.json index 0a4681caa6..38741be95d 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,37 +1,41 @@ { "version": 1, - "base_sha": "72809bc1cf71e2493d1f0c492b926110a969bdb4", - "verified_at": "2026-09-11T10:26:20.316799Z", + "base_sha": "c7c503994b097023cc56f65434d35b46beac7918", + "verified_at": "2026-09-12T20:24:17Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/cards/DisksCard.tsx" + "frontend-modern/src/features/docs/docMarkdown.ts", + "frontend-modern/src/pages/Docs.tsx" ], "content_sha256": { - "frontend-modern/src/components/shared/cards/DisksCard.tsx": "cdda554f93b7aecb925d803b0192260ef1a445065c501468a0412b824dc08028" + "frontend-modern/src/features/docs/docMarkdown.ts": "b6408f3475721522eb22a9b8910e34b326b1c4cec045931baec05f4683c97c80", + "frontend-modern/src/pages/Docs.tsx": "c6c4b066c6af8a4eb8f01a9e03c2f939ee1592867dea2bf0f328bf111f99f2eb" }, "routes": [ - "/qualification/disks/?theme=light&count=2", - "/qualification/disks/?theme=dark&count=24" + "/docs/API", + "/docs/API#resource-maintenance-and-operator-state" ], "viewports": [ { - "width": 600, - "height": 500 + "width": 1440, + "height": 900 }, { - "width": 1200, - "height": 500 + "width": 390, + "height": 844 } ], "states": [ - "2 and 24 mounts in light and dark themes", - "No nested clipping before scrolling; all mounts remain in outer flow" + "Final canonical and shipped API content agree", + "Maintenance endpoint table, example, expiry and preservation warnings readable", + "Fragment target visible and keyboard focused after delayed Markdown fetch", + "Malformed fragment leaves viewer usable" ], "interactions": [ - "Focus Before disks, keyboard End then Tab", - "Verify final mount in viewport and After disks focused", - "Inspect full-page Firefox dark narrow and Chromium light desktop screenshots" - ], - "command": "pulse-heavy-run -- node tests/qualification/disk-mounts/check.mjs", - "notes": "16 synthetic production-card/CSS cases passed in Firefox142.0.1 and Chromium141.0.7390.37. Screenshots retained in lane outcome screenshots directory. No full installed drawer or reporter Firefox140 ESR reproduction claimed. Production runtime unchanged during completion repair." + "Hover and focus Alerts maintenance link, activate with Enter", + "Follow direct section URL and reload after delayed document fetch", + "Scroll to example and expiry instructions", + "Scroll narrow endpoint table horizontally and back", + "Inspect desktop and narrow screenshots with no page overflow" + ] } diff --git a/frontend-modern/package-lock.json b/frontend-modern/package-lock.json index 1cd1759333..f7fec95f1c 100644 --- a/frontend-modern/package-lock.json +++ b/frontend-modern/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@solidjs/router": "^0.10.10", "dompurify": "^3.4.13", + "github-slugger": "2.0.0", "highlight.js": "^11.11.1", "lucide-solid": "^0.545.0", "marked": "^17.0.1", @@ -3979,6 +3980,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", diff --git a/frontend-modern/package.json b/frontend-modern/package.json index 6289dc6c49..432b35b0a7 100644 --- a/frontend-modern/package.json +++ b/frontend-modern/package.json @@ -54,6 +54,7 @@ "dependencies": { "@solidjs/router": "^0.10.10", "dompurify": "^3.4.13", + "github-slugger": "2.0.0", "highlight.js": "^11.11.1", "lucide-solid": "^0.545.0", "marked": "^17.0.1", 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..5e94a5d74c 100644 --- a/frontend-modern/src/features/docs/docMarkdown.ts +++ b/frontend-modern/src/features/docs/docMarkdown.ts @@ -1,5 +1,6 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; +import GithubSlugger from 'github-slugger'; // Shipped documentation lives at /docs/.md as a static asset. The // rendered viewer is the same path without the .md suffix, which cannot @@ -148,6 +149,18 @@ 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. + const slugger = new GithubSlugger(); + 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 = slugger.slug(heading.textContent ?? ''); + while (ids.has(id)) id = slugger.slug(heading.textContent ?? ''); + heading.id = id; + ids.add(id); + }); wrapTables(container); container.querySelectorAll('a[href]').forEach((anchor) => { @@ -169,6 +182,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() { } >
    ; + packages: Record< + string, + { + version?: string; + integrity?: string; + dependencies?: Record; + hasInstallScript?: boolean; + } + >; } const manifest = JSON.parse( @@ -65,6 +73,15 @@ const dompurifyRangeIsPatched = (range: string): boolean => /^\^3\.\d+\.\d+$/.test(range) && atLeast(range.slice(1), [3, 4, 13]); describe('frontend dependency security floors', () => { + it('locks the documentation heading helper without install scripts or transitive dependencies', () => { + const slugger = lock.packages['node_modules/github-slugger']; + expect(slugger).toBeDefined(); + expect(manifest.dependencies['github-slugger']).toBe(slugger.version); + expect(slugger.integrity).toMatch(/^sha512-/); + expect(slugger.dependencies ?? {}).toEqual({}); + expect(slugger.hasInstallScript).not.toBe(true); + }); + it('keeps Vitest and its mocker above the redirect-mock file-read floor', () => { // GHSA-82fw-gwwq-j7x9: the maintained 4.x fix starts at 4.1.11. expect(manifest.devDependencies.vitest).toBe('^4.1.11'); diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index f501a20f76..e7e4cded01 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -131,6 +131,21 @@ describe('docsLinks', () => { } }); + 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(