mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Document maintenance API and repair documentation section links
Lands contributor PR #2067 (head5eeea72a3a, 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 tree68465bc48d(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 atfdb2be15be. Change-source: pulse-maintainer
This commit is contained in:
+66
-1
@@ -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')!,
|
||||
);
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user