mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 20:59:36 +00:00
Document maintenance API and repair documentation section links
The API reference omitted the released resource maintenance endpoint, and section links in the shipped viewer had no heading IDs to navigate to. Document bounded windows, descendant scope and full-record replacement, then make section links work after loading and reloading documentation. Refs https://github.com/rcourtman/Pulse/discussions/1603
This commit is contained in:
parent
c7c503994b
commit
5eeea72a3a
14 changed files with 354 additions and 26 deletions
67
docs/API.md
67
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=<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
7
frontend-modern/package-lock.json
generated
7
frontend-modern/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import GithubSlugger from 'github-slugger';
|
||||
|
||||
// Shipped documentation lives at /docs/<path>.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<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()}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,15 @@ interface PackageManifest {
|
|||
}
|
||||
|
||||
interface PackageLock {
|
||||
packages: Record<string, { version?: string }>;
|
||||
packages: Record<
|
||||
string,
|
||||
{
|
||||
version?: string;
|
||||
integrity?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue