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
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:
+66
-1
@@ -41,6 +41,7 @@ Public endpoints include:
|
|||||||
|
|
||||||
Some endpoints require admin privileges and/or scopes. Common scopes include:
|
Some endpoints require admin privileges and/or scopes. Common scopes include:
|
||||||
- `monitoring:read`
|
- `monitoring:read`
|
||||||
|
- `monitoring:write`
|
||||||
- `settings:read`
|
- `settings:read`
|
||||||
- `settings:write`
|
- `settings:write`
|
||||||
- `agent:config:read`
|
- `agent:config:read`
|
||||||
@@ -197,6 +198,66 @@ Report an incorrect merge (creates exclusions).
|
|||||||
{ "sources": ["proxmox", "agent"], "notes": "optional note" }
|
{ "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
|
### Fleet Connections
|
||||||
`GET /api/connections`
|
`GET /api/connections`
|
||||||
Returns the canonical fleet connections ledger with per-row fleet-governance state. Requires admin access with `settings:read`.
|
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`).
|
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`
|
- `GET /api/alerts/config`
|
||||||
- `PUT /api/alerts/config`
|
- `PUT /api/alerts/config`
|
||||||
- `GET /api/alerts/deadman/config` — returns only whether an external watchdog
|
- `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
|
- `GET /api/alerts/deadman/status` — live watchdog health, monitor-loop
|
||||||
progress, sanitized delivery failure state, and the most recent restart
|
progress, sanitized delivery failure state, and the most recent restart
|
||||||
interruption; never includes the URL or endpoint fingerprint
|
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/active`
|
||||||
- `GET /api/alerts/delivery-diagnosis?alertIdentifier=<alert-id>` (omit `alertIdentifier` to get the diagnosis array for every active alert)
|
- `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
|
- `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
|
## 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
|
### Ollama credential lifecycle
|
||||||
Settings return only `ollama_username` and `ollama_password_set`, never the
|
Settings return only `ollama_username` and `ollama_password_set`, never the
|
||||||
password. An omitted password preserves it, a supplied password replaces it
|
password. An omitted password preserves it, a supplied password replaces it
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
### Docker SDK dependency compatibility
|
||||||
|
|
||||||
The Docker consumers use Moby API v1.56.0 and client v0.6.0 together, without
|
The Docker consumers use Moby API v1.56.0 and client v0.6.0 together, without
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
### Disk mount scrolling
|
||||||
|
|
||||||
DisksCard keeps every supplied mount in its parent's scrolling flow. It must not
|
DisksCard keeps every supplied mount in its parent's scrolling flow. It must not
|
||||||
|
|||||||
@@ -5112,7 +5112,8 @@
|
|||||||
"lane": "L8",
|
"lane": "L8",
|
||||||
"contract": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
|
"contract": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
|
||||||
"owned_prefixes": [
|
"owned_prefixes": [
|
||||||
"frontend-modern/src/components/shared/"
|
"frontend-modern/src/components/shared/",
|
||||||
|
"frontend-modern/src/features/docs/"
|
||||||
],
|
],
|
||||||
"owned_files": [
|
"owned_files": [
|
||||||
"frontend-modern/scripts/__tests__/planning-doc-status-audit.test.mjs",
|
"frontend-modern/scripts/__tests__/planning-doc-status-audit.test.mjs",
|
||||||
@@ -5213,6 +5214,7 @@
|
|||||||
"frontend-modern/src/i18n/messages.ts",
|
"frontend-modern/src/i18n/messages.ts",
|
||||||
"frontend-modern/src/i18n/policy.ts",
|
"frontend-modern/src/i18n/policy.ts",
|
||||||
"frontend-modern/src/index.css",
|
"frontend-modern/src/index.css",
|
||||||
|
"frontend-modern/src/pages/Docs.tsx",
|
||||||
"frontend-modern/src/routing/routePreload.ts",
|
"frontend-modern/src/routing/routePreload.ts",
|
||||||
"frontend-modern/src/stores/aiChat.ts",
|
"frontend-modern/src/stores/aiChat.ts",
|
||||||
"frontend-modern/src/stores/updates.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/SharedPrimitives.guardrails.test.ts",
|
||||||
"frontend-modern/src/components/shared/TypeColumn.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
|
"match_files": null
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"base_sha": "72809bc1cf71e2493d1f0c492b926110a969bdb4",
|
"base_sha": "c7c503994b097023cc56f65434d35b46beac7918",
|
||||||
"verified_at": "2026-09-11T10:26:20.316799Z",
|
"verified_at": "2026-09-12T20:24:17Z",
|
||||||
"result": "passed",
|
"result": "passed",
|
||||||
"changed_paths": [
|
"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": {
|
"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": [
|
"routes": [
|
||||||
"/qualification/disks/?theme=light&count=2",
|
"/docs/API",
|
||||||
"/qualification/disks/?theme=dark&count=24"
|
"/docs/API#resource-maintenance-and-operator-state"
|
||||||
],
|
],
|
||||||
"viewports": [
|
"viewports": [
|
||||||
{
|
{
|
||||||
"width": 600,
|
"width": 1440,
|
||||||
"height": 500
|
"height": 900
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"width": 1200,
|
"width": 390,
|
||||||
"height": 500
|
"height": 844
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"states": [
|
"states": [
|
||||||
"2 and 24 mounts in light and dark themes",
|
"Final canonical and shipped API content agree",
|
||||||
"No nested clipping before scrolling; all mounts remain in outer flow"
|
"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": [
|
"interactions": [
|
||||||
"Focus Before disks, keyboard End then Tab",
|
"Hover and focus Alerts maintenance link, activate with Enter",
|
||||||
"Verify final mount in viewport and After disks focused",
|
"Follow direct section URL and reload after delayed document fetch",
|
||||||
"Inspect full-page Firefox dark narrow and Chromium light desktop screenshots"
|
"Scroll to example and expiry instructions",
|
||||||
],
|
"Scroll narrow endpoint table horizontally and back",
|
||||||
"command": "pulse-heavy-run -- node tests/qualification/disk-mounts/check.mjs",
|
"Inspect desktop and narrow screenshots with no page overflow"
|
||||||
"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."
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+7
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@solidjs/router": "^0.10.10",
|
"@solidjs/router": "^0.10.10",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
|
"github-slugger": "2.0.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-solid": "^0.545.0",
|
"lucide-solid": "^0.545.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
@@ -3979,6 +3980,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@solidjs/router": "^0.10.10",
|
"@solidjs/router": "^0.10.10",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
|
"github-slugger": "2.0.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-solid": "^0.545.0",
|
"lucide-solid": "^0.545.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Public endpoints include:
|
|||||||
|
|
||||||
Some endpoints require admin privileges and/or scopes. Common scopes include:
|
Some endpoints require admin privileges and/or scopes. Common scopes include:
|
||||||
- `monitoring:read`
|
- `monitoring:read`
|
||||||
|
- `monitoring:write`
|
||||||
- `settings:read`
|
- `settings:read`
|
||||||
- `settings:write`
|
- `settings:write`
|
||||||
- `agent:config:read`
|
- `agent:config:read`
|
||||||
@@ -197,6 +198,66 @@ Report an incorrect merge (creates exclusions).
|
|||||||
{ "sources": ["proxmox", "agent"], "notes": "optional note" }
|
{ "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
|
### Fleet Connections
|
||||||
`GET /api/connections`
|
`GET /api/connections`
|
||||||
Returns the canonical fleet connections ledger with per-row fleet-governance state. Requires admin access with `settings:read`.
|
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`).
|
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`
|
- `GET /api/alerts/config`
|
||||||
- `PUT /api/alerts/config`
|
- `PUT /api/alerts/config`
|
||||||
- `GET /api/alerts/deadman/config` — returns only whether an external watchdog
|
- `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
|
- `GET /api/alerts/deadman/status` — live watchdog health, monitor-loop
|
||||||
progress, sanitized delivery failure state, and the most recent restart
|
progress, sanitized delivery failure state, and the most recent restart
|
||||||
interruption; never includes the URL or endpoint fingerprint
|
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/active`
|
||||||
- `GET /api/alerts/delivery-diagnosis?alertIdentifier=<alert-id>` (omit `alertIdentifier` to get the diagnosis array for every active alert)
|
- `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
|
- `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 {
|
import {
|
||||||
docAssetUrlForPath,
|
docAssetUrlForPath,
|
||||||
docRouteForPath,
|
docRouteForPath,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
normalizeDocPath,
|
normalizeDocPath,
|
||||||
renderDocMarkdown,
|
renderDocMarkdown,
|
||||||
resolveDocLink,
|
resolveDocLink,
|
||||||
|
scrollToDocFragment,
|
||||||
stripLeadingTitle,
|
stripLeadingTitle,
|
||||||
wrapTables,
|
wrapTables,
|
||||||
} from '../docMarkdown';
|
} from '../docMarkdown';
|
||||||
@@ -86,6 +87,34 @@ describe('renderDocMarkdown', () => {
|
|||||||
expect(html).toContain('<li>one</li>');
|
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', () => {
|
it('points intra-doc links at the viewer and marks them for routing', () => {
|
||||||
const html = renderDocMarkdown('[Install](INSTALL.md)', 'README');
|
const html = renderDocMarkdown('[Install](INSTALL.md)', 'README');
|
||||||
expect(html).toContain('href="/docs/INSTALL"');
|
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', () => {
|
describe('wrapTables', () => {
|
||||||
// Making the table itself display:block; overflow-x:auto is not enough, its
|
// 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
|
// rows still lay out past the block box and push the page into horizontal
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import GithubSlugger from 'github-slugger';
|
||||||
|
|
||||||
// Shipped documentation lives at /docs/<path>.md as a static asset. The
|
// Shipped documentation lives at /docs/<path>.md as a static asset. The
|
||||||
// rendered viewer is the same path without the .md suffix, which cannot
|
// 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;
|
if (typeof document === 'undefined') return html;
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.innerHTML = html;
|
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);
|
wrapTables(container);
|
||||||
|
|
||||||
container.querySelectorAll('a[href]').forEach((anchor) => {
|
container.querySelectorAll('a[href]').forEach((anchor) => {
|
||||||
@@ -169,6 +182,24 @@ export function rewriteDocLinks(html: string, currentDocPath: string): string {
|
|||||||
return container.innerHTML;
|
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. */
|
/** First level-one heading, used as the document title. */
|
||||||
export function extractDocTitle(markdown: string, fallback: string): string {
|
export function extractDocTitle(markdown: string, fallback: string): string {
|
||||||
const index = leadingTitleLineIndex(markdown);
|
const index = leadingTitleLineIndex(markdown);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate, useParams } from '@solidjs/router';
|
import { useLocation, useNavigate, useParams } from '@solidjs/router';
|
||||||
import { Show, createMemo, createResource } from 'solid-js';
|
import { Show, createEffect, createMemo, createResource, onCleanup } from 'solid-js';
|
||||||
import { PageHeader } from '@/components/shared/PageHeader';
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import {
|
import {
|
||||||
docAssetUrlForPath,
|
docAssetUrlForPath,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
extractDocTitle,
|
extractDocTitle,
|
||||||
normalizeDocPath,
|
normalizeDocPath,
|
||||||
renderDocMarkdown,
|
renderDocMarkdown,
|
||||||
|
scrollToDocFragment,
|
||||||
stripLeadingTitle,
|
stripLeadingTitle,
|
||||||
} from '@/features/docs/docMarkdown';
|
} from '@/features/docs/docMarkdown';
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ export function isHtmlResponse(response: Pick<Response, 'headers'>): boolean {
|
|||||||
export default function Docs() {
|
export default function Docs() {
|
||||||
const params = useParams<{ docPath?: string }>();
|
const params = useParams<{ docPath?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
let article: HTMLElement | undefined;
|
||||||
|
|
||||||
const docPath = createMemo(() => normalizeDocPath(params.docPath ?? '') || DOCS_INDEX_PATH);
|
const docPath = createMemo(() => normalizeDocPath(params.docPath ?? '') || DOCS_INDEX_PATH);
|
||||||
const [document] = createResource(docPath, fetchDoc);
|
const [document] = createResource(docPath, fetchDoc);
|
||||||
@@ -58,6 +61,19 @@ export default function Docs() {
|
|||||||
return source ? renderDocMarkdown(stripLeadingTitle(source), docPath()) : '';
|
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.
|
// Intra-documentation links are rewritten to viewer routes by the renderer.
|
||||||
// Delegate their clicks to the router so following one does not reload the
|
// Delegate their clicks to the router so following one does not reload the
|
||||||
// application shell.
|
// application shell.
|
||||||
@@ -112,13 +128,14 @@ export default function Docs() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
|
ref={article}
|
||||||
// Typography renders inline code wrapped in literal backticks by
|
// Typography renders inline code wrapped in literal backticks by
|
||||||
// default, which reads as unrendered markdown. Tables get their
|
// default, which reads as unrendered markdown. Tables get their
|
||||||
// scroll container from wrapTables rather than prose utilities.
|
// scroll container from wrapTables rather than prose utilities.
|
||||||
// Inline code must be able to break, because a long unbreakable
|
// Inline code must be able to break, because a long unbreakable
|
||||||
// URL in one otherwise pushes the whole page into horizontal
|
// URL in one otherwise pushes the whole page into horizontal
|
||||||
// scrolling on a phone; code inside pre keeps its own scrollbar.
|
// 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}
|
onClick={handleClick}
|
||||||
// eslint-disable-next-line solid/no-innerhtml -- renderDocMarkdown sanitises with DOMPurify
|
// eslint-disable-next-line solid/no-innerhtml -- renderDocMarkdown sanitises with DOMPurify
|
||||||
innerHTML={html()}
|
innerHTML={html()}
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ interface PackageManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PackageLock {
|
interface PackageLock {
|
||||||
packages: Record<string, { version?: string }>;
|
packages: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
version?: string;
|
||||||
|
integrity?: string;
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
hasInstallScript?: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = JSON.parse(
|
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]);
|
/^\^3\.\d+\.\d+$/.test(range) && atLeast(range.slice(1), [3, 4, 13]);
|
||||||
|
|
||||||
describe('frontend dependency security floors', () => {
|
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', () => {
|
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.
|
// GHSA-82fw-gwwq-j7x9: the maintained 4.x fix starts at 4.1.11.
|
||||||
expect(manifest.devDependencies.vitest).toBe('^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', () => {
|
it('ships the per-alert snooze and resume API contract', () => {
|
||||||
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
||||||
const shippedAPIReference = readFileSync(
|
const shippedAPIReference = readFileSync(
|
||||||
|
|||||||
Reference in New Issue
Block a user