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
This commit is contained in:
pulse-triage[bot]
2026-09-21 07:23:24 +01:00
parent fdb2be15be
commit 1b422dd1a6
13 changed files with 610 additions and 24 deletions
+66 -1
View File
@@ -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=<alert-id>` (omit `alertIdentifier` to get the diagnosis array for every active alert)
- `GET /api/alerts/events?alertIdentifier=<alert-id>&type=<event-type,...>&since=<RFC3339>&limit=<n>` — append-only alert event log: lifecycle transitions and notification decisions, including suppressions with reasons; all parameters optional, newest first
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/docs-fragment-navigation.tsx"></script>
</body>
</html>
@@ -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<string, string> = {
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(
() => (
<Router>
<Route path="/docs" component={Docs} />
<Route path="/docs/*docPath" component={Docs} />
<Route path="*" component={() => <p>not found</p>} />
</Router>
),
document.getElementById('root')!,
);
+27 -17
View File
@@ -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."
}
+66 -1
View File
@@ -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=<alert-id>` (omit `alertIdentifier` to get the diagnosis array for every active alert)
- `GET /api/alerts/events?alertIdentifier=<alert-id>&type=<event-type,...>&since=<RFC3339>&limit=<n>` — append-only alert event log: lifecycle transitions and notification decisions, including suppressions with reasons; all parameters optional, newest first
@@ -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('<li>one</li>');
});
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(
'<a id="maintenance"></a>\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 = '<h2 id="maintenance">Maintenance</h2>';
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
@@ -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<string, number> = 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<HTMLElement>('[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);
+20 -3
View File
@@ -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<Response, 'headers'>): 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() {
}
>
<article
ref={article}
// Typography renders inline code wrapped in literal backticks by
// default, which reads as unrendered markdown. Tables get their
// scroll container from wrapTables rather than prose utilities.
// Inline code must be able to break, because a long unbreakable
// URL in one otherwise pushes the whole page into horizontal
// scrolling on a phone; code inside pre keeps its own scrollbar.
class="prose prose-sm max-w-none dark:prose-invert prose-pre:overflow-x-auto prose-code:before:content-none prose-code:after:content-none [&_:not(pre)>code]:break-words"
class="prose prose-sm max-w-none dark:prose-invert prose-headings:scroll-mt-20 prose-pre:overflow-x-auto prose-code:before:content-none prose-code:after:content-none [&_:not(pre)>code]:break-words"
onClick={handleClick}
// eslint-disable-next-line solid/no-innerhtml -- renderDocMarkdown sanitises with DOMPurify
innerHTML={html()}
@@ -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(
+213
View File
@@ -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);
})();