Commit Graph

5900 Commits

Author SHA1 Message Date
rcourtman d6a68f8044 Add cmd/pulse-mcp — MCP adapter wrapping the agent substrate
The whole point of slice 39's hand-authored manifest with
snake_case names and stable error codes was to make adapter
projection cheap. This slice is the test: a minimal MCP (Model
Context Protocol) server that turns Pulse's manifest into a tool
surface Claude Desktop, Claude Code, and other MCP-speaking
clients can drive natively.

Every MCP tool is a one-line projection of a manifest capability.
Input schemas are auto-derived from path placeholders ({name}
segments become required string properties) and method (non-
GET/DELETE tools accept a free-form body object). Adding a
capability to the manifest automatically extends the tool surface
— no MCP-side changes required.

The adapter is stdlib-only, runs over stdio with line-delimited
JSON-RPC 2.0 framing, preserves Pulse's stable error envelope
verbatim through MCP's content-and-isError result so agents on
the MCP side branch on the same codes they would on the wire,
and skips subscribe_events (SSE streaming doesn't fit the
request/response tool shape; future slices can layer it as MCP
notifications).

Eleven tests pin the projection rules and the JSON-RPC contract:
path-placeholder schema generation, body-property method gating,
substitution failures producing stable errors, the initialize
handshake advertising tools, tools/list filtering subscribe_events,
tools/call proxying with the bearer token and preserving the
substrate's error envelope, unknown methods producing JSON-RPC
method-not-found, and notifications producing no response.

The substrate is now wrapped in two adapters, each demonstrating a
different consumer profile: agent-probe walks the substrate as an
HTTP client (slice 49); pulse-mcp wraps it for stdio MCP clients.
Both depend only on the standard library and resolve paths from
the manifest, so the substrate is the single source of truth and
adapter additions stay cheap.
2026-05-09 22:51:37 +01:00
rcourtman 1ca4ecccb6 Add hover titles to truncated table cells
Several truncated cells with no title attribute meant the user couldn't
read the full value when it overflowed:

  - Alert History → Resource column: "Tower - Unraid Array" became
    "Tower - Unraid A..." with no way to read the rest.
  - Alert History → Node column (visible at lg+): same.
  - Configured Node Tables (Settings → Infrastructure): node name and
    host fields used `truncate` without title, so long endpoints
    couldn't be read.

Add `title` attributes that mirror the cell contents so hovering shows
the full string. The Message column already had this pattern via
title={props.alert.description}; this commit applies the same shape
to the Resource and Node columns and to the configured-node fields.
2026-05-09 22:49:27 +01:00
rcourtman 3266056ca5 Show full version on hover in Updates panel
The CURRENT VERSION value on /settings/system-updates uses a `truncate`
class so long version strings (e.g. 6.0.0-rc.4+git.303.g956646a5c.dirty)
fit the narrow card column. The visible string was clipped to
"6.0.0-rc.4+git.303.g956646a..." with no title attribute, so a user who
needed the exact version (for bug reports, support requests, etc.) had
no way to read it from this surface.

Add a title attribute that mirrors the full version so hovering reveals
the complete string.
2026-05-09 22:47:05 +01:00
rcourtman 8aa22d0605 Surface action verification on the action.completed SSE payload
Closes the certainty loop for agents watching the substrate's push
channel. The action audit's read-after-write probe outcome was
already persisted on the audit record, but agents watching
action.completed only learned "the action ran" — they had to fetch
/api/actions/{id} to know whether the read-back probe confirmed
the intended state. That defeated the substrate's
push-notification guarantee for dispatch certainty.

The new agent-stable AgentResourceActionVerification projection
(ran, success, command, note, ranAt — output stays in the audit
record, deliberately omitted from events to keep payloads small)
is now carried on both:

  - the action.completed SSE payload, projected from
    record.Result.Verification by the router-side bridge in
    wireAIChatDependenciesForService, and
  - the resource-context bundle's recentActions surface, via the
    same shared projectAgentResourceVerification helper

so the bundle (depth) and the doorbell (push) speak the same
vocabulary. Refused-before-dispatch failures omit verification
(the probe never runs) so agents branch on field presence to
distinguish "no probe attempted" from "probe ran with empty
result". Three contract pins lock the symmetry: payload field
present, router bridge populates it, bundle parallels.

The capabilities manifest's subscribe_events description now
mentions the verification block so external agents discover the
field through the same path they already use to learn the rest
of the agent surface.
2026-05-09 22:45:15 +01:00
rcourtman 956646a5c1 Add cmd/agent-probe — worked example consuming the agent substrate
The substrate's read and write surfaces are end-to-end-tested
internally; this slice answers the harder question — "is the
substrate actually usable from the outside?" — by writing the
smallest standalone program that consumes it. agent-probe walks
the discovery → triage → depth → push flow against a running
Pulse instance using only the Go standard library, so it doubles
as a reference implementation for anyone building MCP servers,
Claude Code integrations, or custom agents on top of Pulse.

It resolves every path from the manifest rather than hardcoding
them — if discovery moves a path, the probe follows
automatically — and branches on the stable error envelope's
"error" code field, never on human-readable messages. The focus
rule (severity-lex-ordered) is intentionally simple so a reader
can predict what the probe will pick; real agents will have
richer policies.

This is documentation as code: the program is short enough to
read top-to-bottom and reads like the agent's own narration of
what it's doing. The unit test pins the focus rule's lex
ordering so a refactor that swaps it for a weighted score (which
allowed many warnings to outrank one critical) cannot regress
silently.
2026-05-09 22:28:00 +01:00
rcourtman 5156c03eed End-to-end test the operator-state write loop through HTTP
Closes the e2e contract proof on the write side. The only write
capability the manifest declares is the operator-state intent
loop (set / get / clear), and this test boots the full router
stack to walk every state of that loop through the actual HTTP
boundary — proving the manifest's declared error codes for
set_operator_state and get_operator_state reach the wire from
the handlers, the URL canonical id authoritatively wins over
body-supplied ids (no scope-confusion writes), and SetAt/SetBy
are server-populated so attribution cannot be spoofed.

The flow exercised:
  GET unset → 404 operator_state_not_set
  PUT valid → 200 with persisted state + server SetAt
  GET → round-trips
  PUT invalid criticality → 400 operator_state_invalid
  DELETE → 204
  GET → 404 operator_state_not_set (loop closed)
  DELETE again → 204 (idempotent)

Two contract pins lock the audit-honesty and error-token
contracts so a future refactor of the handler can't silently
regress either: SetAt/SetBy populated server-side, URL-id wins
over body-id, and the validator's domain error maps to the
stable wire token via errors.Is rather than message-matching.

Together with the read-side e2e (slice 47), the agent surface —
read, write, push — has now been exercised end-to-end as one
substrate.
2026-05-09 22:22:47 +01:00
rcourtman a2d798a564 Improve mobile nav alert tab accessibility
The Alerts bottom-tab on mobile shows a count badge (e.g. "9") visually
adjacent to the "Alerts" label. Without an aria-label, screen readers
read the concatenated text "9Alerts" — the badge digits run into the
label. Added an aria-label that reads "Alerts: 9 critical, 2 warning"
(adapted to whichever counts are non-zero) so screen reader users hear
a coherent announcement, and marked the visual badge container
aria-hidden so it's not double-announced.
2026-05-09 22:19:07 +01:00
rcourtman 8cf15fe639 End-to-end test the agent substrate's discovery → triage → depth flow
The unit tests cover each piece in isolation; this test boots the
full router stack and proves the discovery → triage → depth chain
works as one substrate through the actual HTTP boundary an
external agent would hit. It found two real bugs slice 40
introduced and slice 45/46 didn't surface:

- /api/agent/capabilities was documented as unauthenticated but
  was missing from the router's publicPaths list, so the global
  auth middleware was 401'ing the discovery manifest. Fixed by
  adding the path to publicPaths and pinning the contract so it
  cannot regress.

- The error-envelope shape across the agent surface is
  {"error": "<stable_code>", "message": "<human>"}, written via
  writeJSONError — not the {"code": ...} shape I had assumed in
  the docs. Pinned the wire shape on api-contracts.md so the
  documented error contract matches what writeJSONError actually
  writes.

The e2e test exercises capabilities discovery, triage via
fleet-context, and depth via resource-context with an unknown id
to confirm the resource_not_found stable error code reaches the
wire under the canonical "error" key. The subscribe_events SSE
path is probed unauthenticated to confirm it's gated (401) rather
than 404 — discovery's claim is honest.
2026-05-09 22:16:32 +01:00
rcourtman 1305966849 Make AppLayout desktop tabs keyboard-accessible
The platform tabs (Infrastructure / Workloads / Storage / Recovery)
and utility tabs (Alerts / Patrol / Settings) at the top of the
desktop layout already had role="tab" and aria-label, but no tabIndex
or onKeyDown — keyboard users couldn't focus them and screen readers
saw a tab role that wasn't actually focusable.

Add tabIndex={0} and a keydown handler on Enter or Space to both tab
groups. The mouse path is unchanged. Verified live: tabbing to the
Workloads tab and pressing Enter navigates to /workloads.
2026-05-09 22:09:58 +01:00
rcourtman a168215f6a Add /api/agent/fleet-context for org-wide triage in one read
The substrate had a per-resource bundle but no fleet view, so
"where do I focus?" forced agents to walk every resource id and
bundle each — O(N) round trips that scale with fleet size. The
fleet endpoint returns a thin per-resource rollup in a single
read: identity, operator-intent flags (intentionallyOffline,
neverAutoRemediate, maintenanceWindowActive), per-severity
finding counts, and pending-approval count.

Same auth scope and same provider wiring as the per-resource
bundle — operator-state via the canonical unified store, findings
via AgentFindingsProvider, approvals via AgentApprovalsProvider —
so the fleet sweep is the per-resource bundle's wiring multiplied
by N with no new dependencies. Audit reads are deliberately
omitted from the rollup; agents that want depth on a flagged
resource follow up via /api/agent/resource-context/{id}.

The capabilities manifest declares get_fleet_context with
AgentFleetContext as the response shape so external agents
discover the triage entry point through the same path they
already use to learn the rest of the agent surface.
2026-05-09 22:08:50 +01:00
rcourtman de6deb0f86 Make alert metric edit cells keyboard-accessible
Each metric value cell on /alerts/thresholds is a clickable surface that
opens an inline editor for the threshold value. The wrapper was a plain
<div> with cursor-pointer and onClick — focusable for mouse but not for
keyboard, and screen readers didn't announce it as a button.

Add role="button", tabIndex={0}, onKeyDown for Enter/Space activation,
and an aria-label that mirrors the existing title hint. The mouse path
is unchanged; keyboard users can now Tab into the cells and press Enter
to open the editor.
2026-05-09 22:06:05 +01:00
rcourtman d8f6b1e508 Bundle pending approvals into the agent resource-context endpoint
The substrate's "everything an agent needs in one read" guarantee
covered identity, operator state, findings, and recent actions but
forced a separate /api/approvals call for pending governance
state. AgentResourceContext now carries pendingApprovals as a
lightweight AgentResourceApprovalSummary projection — same
vocabulary as approval.pending SSE events, so the doorbell and
the bundle agree on shape. AgentApprovalsProvider is the parallel
seam to AgentFindingsProvider; the router wires a closure that
resolves approval.GetStore() at request time, scopes via
BelongsToOrg, and filters by CanonicalResourceID so cross-tenant
or cross-resource pending requests don't leak. Empty arrays
preserve the iteration-safe contract the existing sections
already follow.
2026-05-09 22:00:53 +01:00
rcourtman 2a12018af0 Use cursor-help on TagBadges hover-only +N indicator
The "+N" overflow indicator on TagBadges was styled with
`cursor-pointer`, which signals a clickable affordance — but the
element only listens for mouseenter/mouseleave to show a tooltip and
has no click handler. Switch to `cursor-help` so the cursor matches
the actual interaction (hover for more info), avoiding a phantom
click expectation.
2026-05-09 21:57:27 +01:00
rcourtman 7fe9b1c492 Use cursor-help on TagBadges hover-only +N indicator
The "+N" overflow indicator on TagBadges was styled with
`cursor-pointer`, which signals a clickable affordance — but the
element only listens for mouseenter/mouseleave to show a tooltip and
has no click handler. Switch to `cursor-help` so the cursor matches
the actual interaction (hover for more info), avoiding a phantom
click expectation.
2026-05-09 21:52:34 +01:00
rcourtman e7512aee14 Make Patrol finding rows keyboard-accessible
Each Patrol finding row was a plain <div> with cursor-pointer and an
onClick handler that toggled the row's expanded state. Mouse users got
the toggle, but the row had no role, no tabIndex, and no key handler,
so keyboard users couldn't focus or activate it and screen readers
didn't announce it as a button.

Add role="button", tabIndex={0}, aria-expanded, and aria-controls (with
a matching id on the expanded details container) plus a keydown handler
that toggles on Enter or Space. The mouse path is unchanged.
2026-05-09 21:47:36 +01:00
rcourtman 1484e83963 Match connected-systems card layout breakpoint to table min-width
The infrastructure connected-systems UI swapped to a card layout below
767px and to a table layout otherwise — but the table itself sets
`min-w-[820px]`, so any container width between 768px and 819px
rendered the table only for it to overflow horizontally inside the
settings panel. Tablet-class viewports were the worst case: scroll
stayed visible and the Action column was clipped.

Bump CARD_LAYOUT_MAX_WIDTH_PX from 767 to 819 so the breakpoint matches
the table's actual minimum width. The card layout now renders for any
container that can't fit the table cleanly, and the table only appears
when the columns can render at full width.
2026-05-09 21:34:31 +01:00
rcourtman 52669128e6 Drop redundant policy gates in resource-link routing
Tail of the operator-local-UI redaction sweep (abdde303a, a17f879a1).

resolveKubernetesContextForResource gated on requiresGovernedResourceDisplay
to choose between getPreferredInfrastructureDisplayName and a manual
displayName-or-name fallback. Both branches produce a raw infra name
once we trust that displayName never carries a redacted summary in
local rendering, so the gate is dead complexity. Collapse to a single
call and drop the now-unused requiresGovernedResourceDisplay import.

problemResourcePresentation.getProblemResourceDisplayName has no
production consumers today, but it still routes through the governed
helper. Reclassify it now (same as every other operator-local helper)
so the rule is consistent across the codebase if the surface ever gets
adopted.
2026-05-09 21:31:45 +01:00
rcourtman d31c8ea9bd Hoist whitespace-normal to DiscoveryTab wrapper
Follow-up to the previous Discovery banner fix. Move the
`whitespace-normal` class from the per-banner text wrapper up to the
DiscoveryTab's outer container, so that any text inside the tab — the
banner copy, the per-command descriptions in the "Commands that will
run" disclosure, and any future explanatory copy — wraps without
needing a per-element override. The fix sits at the boundary where the
inherited `.table-fixed td/th` rule (white-space: nowrap) reaches the
expanded-row content.
2026-05-09 21:22:17 +01:00
rcourtman d433daab6c Restore wrapping on Discovery banner inside Workloads table
The "What Discovery Does" info banner inside an expanded Workloads row
was rendering on a single line and getting clipped at the row width —
the user could only read up to "...the configured analysis prov" before
overflow. Cause: the parent <table> sets `whitespace-nowrap` on the
whole table for the metric cells, and CSS inheritance pulled that into
the expanded-row content.

Add `whitespace-normal` to the banner's text wrapper so its multi-line
copy wraps as intended. As a side effect, the dismiss "×" button now
also lands inside the visible viewport on this surface.
2026-05-09 21:13:41 +01:00
rcourtman 0070369ea7 Collapse disabled-state body padding on Alert Schedule cards
Quiet hours, Alert cooldown, Smart grouping, and Alert escalation cards
on /alerts/schedule each wrap their form fields in a `<Show
when={enabled}>` block. When disabled, the body content disappears but
the SettingsPanel's outer body padding (`p-4 sm:p-6`) still rendered as
~50px of empty whitespace per card — five cards stacked added almost
half a viewport of vacant space and pushed the Configuration summary
card off-screen.

Pass `noPadding={!enabled}` to each panel so the body div has no
padding when there's nothing to show. The disabled cards now collapse
to just the header strip + toggle, and the Configuration summary lands
above the fold. Recovery notifications already shows persistent body
text and is left unchanged.
2026-05-09 21:04:13 +01:00
rcourtman 51c5d344ce Plumb operator-state and operational memory into investigation findings
Closes the "has context vs uses context" gap that defines Pulse's
agent-paradigm differentiation. The orchestrator (in pulse-pro) used
to receive a Finding with no awareness of the operator's
commitments — Patrol could investigate a resource the operator had
marked never-auto-remediate and propose a restart fix that the
action broker would refuse downstream. The proposal shouldn't have
happened in the first place.

Adds two optional fields to aicontracts.Finding:

- OperatorContext: intentionally offline, never auto-remediate,
  maintenance window with computed active flag, criticality, note.
  Populated in MaybeInvestigateFinding from the same operator-state
  projection the suppression hot path consumes, so investigation
  reasoning and suppression behavior cannot drift apart.
- OperationalMemory: regression count, previous resolved fix
  summary, last regression timestamp, times raised. Populated in
  ToCoreFinding from fields the internal Finding already carries.

ResourceOperatorStateProjection grew a NeverAutoRemediate field —
the investigation read path needs it (so the orchestrator can avoid
proposing fixes the broker would refuse) even though the
suppression hot path doesn't. Same projection serves both reads.

Both fields are nil when there's no signal (fresh finding, no
operator state) so the orchestrator branches on absence rather
than parsing zero-valued structs. The pulse-pro orchestrator
consumes the fields in a separate slice; this slice ships the
in-repo half of the data path.
2026-05-09 21:03:15 +01:00
rcourtman c38a46b0b5 Hide low-priority Alert History columns below lg breakpoint
The Alert History table had 10 columns and `min-w-[max-content]` on the
table, totalling ~1310px natural width. At narrow viewports (e.g. an
~900px content area), the user had to horizontally scroll past the
high-priority columns (Timestamp / Resource / Severity / Message) just
to see the lower-priority Duration / Status / Node columns.

Drop `min-w-[max-content]` and add `hidden lg:table-cell` to the
Duration, Status, and Node header + data cells. At < lg the table now
fits the available width while keeping the most-useful columns visible
upfront. Status is still implied by the row coloring + Severity badge,
Duration is rarely the deciding factor at-a-glance, and Node is
redundant when Resource already names the host.
2026-05-09 20:59:38 +01:00
rcourtman a17f879a19 Stop redacting resource names in remaining operator-local UI
Continues the policy-redaction scope fix from abdde303a. The same wrong
invariant -- "operator-local UI redacts when policy says redact" -- was
also encoded in:

- alertResourceTableModel.getAlertResourceLabel (the /alerts table that
  feeds AlertResourceTableMobile and AlertResourceTableRow)
- AlertResourceIncidentsPanel (the incident card shown after the operator
  clicks into a resource)
- infrastructureSelectors.matchesSearch (/infrastructure search filter,
  whose haystack already includes raw hostname/ips so redacting the
  displayName was internally inconsistent)
- useResources filter().search (the shared resource hook's search
  predicate, used by Settings panels, /storage, /alerts, FindingsPanel)
- recoveryRecordPresentation.getRecoveryLinkedResourceLabel (recovery
  rollup labels rendered in /recovery)
- Settings/ResourcePicker (the operator's selection UI for the Reporting
  Panel; the picker is local even when the report itself transmits)

All six surfaces are operator-local. Per docs/PRIVACY.md, resource-policy
redaction applies "Before non-local model requests leave the instance" --
it is a transmission-boundary policy, not a local-rendering policy. The
operator must see their own resource names to recognize, search, and
configure them.

Switch each callsite to getPreferredInfrastructureDisplayName. Invert the
five test fixtures (ResourceTable, ResourcePicker, infrastructureSelectors,
UnifiedResourceTable performance contract, WorkloadsSurface performance
contract) that locked in the old redaction-in-local-UI invariant.

Cloud-bound callers (AI Chat, organization sharing prepare-payload, the
resource-detail drawer's governance meta panel) still call
getPreferredResourceDisplayName and continue to redact -- their tests
are unchanged. The only call site of the governed helper that is left
not yet reclassified is problemResourcePresentation, which currently has
no production consumers; leaving it untouched.
2026-05-09 20:16:37 +01:00
rcourtman 7637f1efac Reword Patrol schedule note for natural English
The Patrol Configuration popover summarised the schedule as "Full
patrols run on the {label} schedule.", which yielded ungrammatical
phrasings like "on the 3 hours schedule" or — when the schedule was
disabled — "on the disabled schedule". Switch to "Full patrols run
every {label}." for any positive interval, and "Full patrols are
disabled." when the interval is 0. Reads correctly across all preset
options (10 min, 30 min, 1 hour, 3 hours, 24 hours, …).
2026-05-09 20:15:37 +01:00
rcourtman 94bfd48a9d Add /api/agent/events SSE stream for real-time agent notifications
Third slice on the agent-paradigm pivot, closing the substrate
triangle (discovery + bundled reads + push). Agents subscribe once
to a long-lived SSE connection and receive real-time events instead
of polling: finding.created when a new finding is raised, heartbeat
every 15 seconds for keepalive. Each event carries a monotonic ID so
agents can dedupe and reason about ordering across reconnects.

The broadcaster fan-outs to multiple subscribers and drops events
for slow consumers rather than blocking the publish path —
publishers cannot stall on consumer slowness. The findings-runtime
hook in router.go publishes finding.created when the finding is new
AND not auto-dismissed by operator-state suppression (operator
already said to stay quiet about that resource); patrol-cycle
re-detection of existing findings doesn't fire the event.

Capabilities manifest declares the stream under subscribe_events so
external agents discover it through the same channel as the REST
surface. SSE chosen over WebSocket because it's simpler, works
through every HTTP proxy without special-casing, and matches the
existing deploy_handlers pattern; agents that need bidirectional
comms call REST endpoints in parallel.

Tests pin the broadcaster's pub/sub semantics (fan-out, unsubscribe,
slow-consumer drop, monotonic IDs), the SSE handler's stream
contract (text/event-stream, no-cache, X-Accel-Buffering=no), and
the connected/published-event delivery via httptest.NewServer. A
contract test pins the publish-gate semantics so operator-state
suppression and stream notifications stay aligned.
2026-05-09 20:13:31 +01:00
rcourtman e51580186a Use 3-tick Y-axis on Recovery activity chart
The Recovery events activity chart was rendering 5 evenly-spaced Y-axis
ticks. With the 1/2/5 × 10ⁿ axisMax produced by getRecoveryNiceAxisMax,
dividing the range by 4 yielded ugly intermediates — e.g. an axisMax of
50 rendered as 50 / 38 / 25 / 13 / 0 (rounded from 50 / 37.5 / 25 /
12.5 / 0).

Drop to 3 ticks (top / midpoint / 0). Halving the standard nice-axis
maxima always produces clean integers (50 → 25, 100 → 50, 200 → 100,
20 → 10, 10 → 5). The chart loses one tick of granularity but reads
correctly at every scale.
2026-05-09 20:11:52 +01:00
rcourtman 2e79e82195 Rename FilterBar 'Filter filters' search placeholder to 'Search filters'
The Add-filter menu type-ahead input had placeholder and aria-label
'Filter filters...' / 'Filter values...' — using 'Filter' as both verb
and noun reads as a tongue-twister and obscures that the input narrows
the visible list. Switch to 'Search filters' / 'Search values' so the
verb is unambiguous. Update the guardrail test fixture to match.
2026-05-09 20:05:46 +01:00
rcourtman abdde303ac Stop redacting resource names in operator-local alert UI
Alert threshold tables, override pickers, and incident panels were
running every resource name through the policy-governed display helper.
A Tower-policied row read "agent (warning)" in /settings -> Alerts ->
Thresholds even though the same resource read "Tower" on /infrastructure
and /storage Pools, which already use the raw infrastructure display
name.

Per docs/PRIVACY.md, resource-policy redaction applies "Before non-local
model requests leave the instance" -- it gates AI-bound, cross-org, and
relay paths, not the operator's own browser. Redacting in local /settings
UI was inconsistent with the policy intent and with the rest of the
operator surface.

Switch the alert-resource label helper to
getPreferredInfrastructureDisplayName so threshold rows match
Infrastructure and Storage. Drop the now-dead governance gate inside
getFriendlyAlertNodeName (and the unused policy parameter all three
threshold hooks were threading through). Invert the six ThresholdsTable
test fixtures plus the alerts-helpers test to assert the new local-UI
invariant.

Cloud-bound surfaces (AI Chat, organization sharing, the resource-detail
drawer's governance meta panel) still call getPreferredResourceDisplayName
and continue to redact -- their tests are unchanged.
2026-05-09 20:04:50 +01:00
rcourtman b2dfae0715 Align Apprise Send test button with Email Send test styling
Both buttons sit at the top of their respective notification sections
on /alerts/notifications and have the same role: trigger a test send.
Email used a blue-accent style with `disabled:opacity-50`; Apprise used
a default-color style with `disabled:opacity-60`, so when both
notification channels were disabled the Email button visibly muted but
the Apprise button still read as enabled. Match Apprise to the Email
styling so the disabled state is unambiguous and the two test buttons
look like the same kind of action.
2026-05-09 20:03:00 +01:00
rcourtman ca2a642bc8 Fix invalid ResourceStatus value in Unraid storage tests
The new Unraid presentation tests used 'warning' for the resource status,
which isn't a valid ResourceStatus literal and tripped the pre-push
type check. Use 'degraded', which is the canonical attention-status value.
2026-05-09 19:57:01 +01:00
rcourtman 2f771baf3d Distinguish Unraid issue and protection columns on the storage page
Tower Array (Unraid) was rendering the same long sentence "Unraid array is
running ..." in both the ISSUE and PROT columns of the storage pools table,
and the PROT cell's tooltip pulled from the issue summary instead of the
protection state, so the visible cell text and its hover tooltip disagreed
("Parity check" vs. "Unraid array is running without parity protection").

ISSUE now renders short noun-phrases for Unraid risk reasons (e.g. "No
parity protection", "Parity check"), and PROT renders a short status
("Parity check", "Unprotected", "Parity rebuild (47%)") derived from the
sync action and protection state. The PROT tooltip is sourced from a new
record-level protectionSummary that mirrors the cell's protection state,
falling back to the issue summary only when the row is showing a
protection problem and the protection summary is otherwise redundant.
2026-05-09 19:53:44 +01:00
rcourtman 71797f9b21 Add /api/agent/capabilities discovery manifest for agent integrations
Second slice on the agent-paradigm pivot: the discovery document any
external agent (Claude Code, custom integrations, future MCP servers)
needs to learn what Pulse exposes. Each capability declares its
agent-stable name (snake_case), description, category, REST surface,
required scope, response shape, and the closed set of stable error
codes the response may carry. Agents branch on the codes
(operator_state_invalid, resource_not_found, etc.) rather than
parsing human messages.

The manifest is hand-authored, not auto-generated, because the
contract decisions (what's agent-stable, which categories, which
error codes) are product-shaping and must not drift behind code
changes. Adding a capability is a deliberate "this is part of the
agent surface" commitment.

v1 surface includes: get_resource_context (substrate from slice 39),
get/set/clear_operator_state (slice 30), and the finding-lifecycle
actions (acknowledge, snooze, dismiss, resolve). Action-broker
capabilities are not in v1 because they go through approval flow,
not direct dispatch — those need their own contract design.

Tests pin: stable shape, version contract, unique-and-snake_case
names, every capability has method/path/scope, closed category set,
required error codes for the most consequential capabilities. The
manifest is unauthenticated and cacheable (5min); the underlying
capabilities keep their own auth scopes.
2026-05-09 19:52:17 +01:00
rcourtman f72f3ca994 Fix invisible Physical Disks column headers
The Storage Physical Disks table had four responsively-revealed
columns — Host, Role, Belongs, Temp — whose data cells rendered fine
at the appropriate breakpoints but whose <th> headers were blank. Cause:
the header classes used `sm:table-column` / `md:table-column` /
`xl:table-column`, which is the right `display` value for `<col>` but
not for `<th>` — `display: table-column` does not render header cell
content. The corresponding cell classes correctly used
`sm:table-cell` / `md:table-cell` / `xl:table-cell`.

Switch the affected header classes to extend the cell-responsive
classes so the headers actually display when their breakpoint is hit.
Verified live: HOST and TEMP headers now render correctly above the
delly / minipc / Tower disk rows.
2026-05-09 19:48:45 +01:00
rcourtman 14f9270a5e Add /api/agent/resource-context/{id} substrate endpoint for agents
First slice on the agent-paradigm pivot: instead of building more
human-glance UI, expose substrate that any agent (in-process Patrol,
external Claude Code, future MCP-driven setups) can consume in one
read. The endpoint returns the full situated picture of a resource —
identity, operator-set state with server-computed
maintenanceWindowActive flag, active findings as a lightweight
seven-question-schema projection, and recent action audits with
refusal tokens (resource_remediation_locked:, plan_drift:) preserved
verbatim for agent branching.

Substrate is the right shape here: an agent reasoning about a
resource gets everything it needs without chaining four or five calls,
and the projection types decouple agent-stable wire shape from
internal type evolution. Active findings flow through an
AgentFindingsProvider adapter wired in router.go from the patrol
service, keeping the api package free of an internal/ai import.

Always-array fields (activeFindings, recentActions) and
omitempty-on-absent (operatorState) give agents stable iteration and
clean field-presence branching. AgentContextHandler owns the agent
surface as its own type so it evolves independently of resource CRUD.
Each test pins a specific contract: identity round-trip, operator-state
projection with computed flag, empty-state shape, refusal-token
preservation, 404 shape, method gating.
2026-05-09 19:46:16 +01:00
rcourtman 845827eac5 Make resource table action header and disk count discoverable
Two small UX rough edges on /infrastructure:

  - The unified resource table's action column was labelled 'Open' in
    compact and narrow layouts but 'Action' in the wide layout. Other
    Pulse tables (Settings → Connected systems, Alert Thresholds) use
    Action / Actions. Align all three modes on 'Action' so the header
    reads consistently regardless of viewport. Update the compact-mode
    test to expect the new label.

  - The bracketed disk count rendered after the disk usage bar (e.g.
    "67% [9]" for an Unraid array) had no inline affordance explaining
    what the number means. The rich hover tooltip already breaks down
    each disk, but the bracketed number itself was unlabelled. Add a
    title="N disks" so a user hovering directly on the bracket gets
    a quick explanation.
2026-05-09 19:36:09 +01:00
rcourtman 7fc6fc8e16 Use 'Temp' header in narrow-layout resource table
The narrow layout of the unified resource table labelled the temperature
column as just 'C' — a single letter that doesn't read as "Celsius
temperature" without prior context. The compact and wide layouts already
use 'Temp', and the Alert Thresholds table uses "TEMP °C". Align the
narrow layout on 'Temp' so the column name is unambiguous regardless of
viewport.

The column is normally hidden at narrow widths, so this is mostly
defensive — but the symbol-only header was the kind of quiet UX rough
edge that could surface if a future change exposes the column at narrow
widths.
2026-05-09 19:30:07 +01:00
rcourtman 3f783a7d98 Distinguish auto-suppressed dismissals from manual operator dismissals on the row
Both serialize as DismissedReason="expected_behavior" but tell
different stories — the auto-dismiss is Pulse staying quiet because
the operator scheduled maintenance or marked the resource
intentionally offline, while the manual dismiss is the operator
deciding the finding is expected. The lifecycle metadata
(operator_state_cause) already distinguishes them; this slice surfaces
the distinction on the FindingsPanel row.

Adds getOperatorStateDismissCause and formatOperatorStateDismissCauseLabel
helpers (the TS mirror of findOperatorStateDismissCause from slice 37).
Renders an "auto: maintenance" / "auto: intentionally offline" badge
next to the existing dismissed-reason badge when the lifecycle scan
finds operator_state_cause on the most-recent dismissed event.

The newest-first scan order is the same as the Go side: a manual
dismissal that supersedes an earlier auto-dismiss reports as manual,
so a stale auto-dismiss cause does not falsely badge the row after
an operator override.
2026-05-09 19:10:27 +01:00
rcourtman 86861959ff Shorten Recovery inventory status column header
The "Protection State" column header on /recovery was truncated to
"PROTECTION ST" in the protected items table because the column is
narrow and the cells already render single-word badges (Healthy /
Stale). Rename the column to "Status" so the header fits and reads
naturally with the badge content.
2026-05-09 19:09:00 +01:00
rcourtman 3ddfd224aa Clarify the 24h alert overview stat label
The Acknowledged / Last 24 Hours / Workload Overrides stat row on
/alerts had an ambiguous middle card: "Last 24 Hours" with a bare
number doesn't say what is being counted. Rename the label to
"Triggered (24h)" so the metric (alerts that fired in the window)
is readable at a glance. The underlying total24h calculation is
unchanged.
2026-05-09 18:39:42 +01:00
rcourtman 2adbb96836 Drop duplicate intros on seven more settings panels
The page-level header (SETTINGS_HEADER_META) already shows the same title
and a near-identical description these panels were re-emitting in their
card header. Drop the panel-level description on:

  - APIAccessPanel (api)
  - DiagnosticsPanel (support-diagnostics)
  - SystemLogsPanel (support-logs)
  - RolesPanel (security-roles)
  - UserAssignmentsPanel (security-users)
  - AuditLogPanel (security-audit)
  - AuditWebhookPanel (security-webhooks, both gated and normal shells)
2026-05-09 18:35:12 +01:00
rcourtman 4001259467 Drop duplicate intros on five more settings panels
The page-level header (SETTINGS_HEADER_META) already shows the same title
and description these panels were re-emitting in their card header. Drop
the panel-level description on:

  - NetworkSettingsPanel (system-network)
  - SSOProvidersPanel (security-sso)
  - SecurityAuthPanel (security-auth)
  - SecurityOverviewPanel (security-overview)
  - UpdatesSettingsPanel (system-updates)
2026-05-09 18:31:33 +01:00
rcourtman 8507f1d08a Rename General page's first card to Appearance
The first card on /settings/system-general was titled "General" with the
same description as the page-level header, so the user saw the page title
and description repeated as a card header right below it. Rename the card
to "Appearance" (which matches its actual contents — theme, temperature,
full-width) and drop the duplicate description. Other cards already have
distinct headers.

Update the architecture test to pin the canonical copy in
settingsHeaderMeta.ts instead of the panel source.
2026-05-09 18:28:41 +01:00
rcourtman 60068af0c9 Drop duplicate intros on org and billing settings panels
The page-level header (SETTINGS_HEADER_META) already shows the same title
and description these panels were re-emitting in their card header. Drop
the panel-level description on:

  - BillingAdminPanel (organization-billing-admin)
  - OrganizationOverviewPanel (organization-overview)
  - OrganizationSharingPanel (organization-sharing)
  - OrganizationBillingPanel (organization-billing)
  - ProLicensePanel normal shell (system-billing)

Make CommercialBillingShell.description optional so the normal billing
shell can omit it; the demo and policy-loading shells keep their distinct
contextual copy.
2026-05-09 18:25:18 +01:00
rcourtman 92584c636b Drop duplicate intros on three more settings panels
Continuing the dedupe pass: each of these settings sub-pages
rendered its panel description identically (or near-identically)
to the page-level header from SETTINGS_HEADER_META, so users read
the same sentence twice on entering the page.

Affected:
- DataHandlingPanel: 'Review resource classifications, handling
  boundaries, and redaction coverage.' (matched 'security-data-
  handling' page meta exactly)
- OrganizationAccessPanel: 'Manage organization invitations,
  member roles, and ownership transfers.' (matched
  'organization-access' page meta exactly)
- RecoverySettingsPanel: 'Manage backup/snapshot polling and
  configuration export/import.' (close paraphrase of
  'system-recovery' page meta)

Card titles stay so each section retains a heading anchor.
2026-05-09 18:17:31 +01:00
rcourtman eae8ca2a68 Wake operator-state-suppressed findings when the suppression lifts
Real product gap exposed by closing the operator-state feature: a
finding auto-dismissed while a maintenance window covered `now` would
stay dismissed forever even after the window ended. Same for findings
auto-dismissed under IntentionallyOffline once the operator cleared
the flag. The time-bounded suppression silently became permanent.

Adds a third wake condition to the dismissed-branch in
FindingsStore.Add: when a finding's most recent dismissed lifecycle
event carries operator_state_cause metadata, AND the provider reports
no current suppression for the resource, clear the dismissal and emit
a suppression_lifted lifecycle event naming the previous cause.

Manual operator dismissals (no operator_state_cause) are unaffected —
the findOperatorStateDismissCause helper stops at the first dismissed
event when scanning newest first, so a manual dismissal that
supersedes an earlier auto-dismiss is not falsely re-awakened. Tests
cover both signal types, the manual-dismissal isolation, and the
helper's newest-first scan order.
2026-05-09 18:15:32 +01:00
rcourtman 5dba50200f Drop duplicate intros on Assistant & Patrol and Remote Access
Same anti-pattern as the Infrastructure settings card: each page
rendered its own header (from SETTINGS_HEADER_META) and then
SettingsPanel rendered the SAME title and description directly
beneath. The user read the same sentence twice on the same screen.

Drop the duplicate description from both AISettings and
RelaySettingsPanel. Card titles stay so the user has a section
anchor; the page-level description carries the detail without
repeating it inline.
2026-05-09 18:13:39 +01:00
rcourtman b822ef17e6 Pin: operator-state-suppressed findings skip autonomous investigation
Cross-slice contract worth making explicit: when slices 31/32
auto-dismiss a finding because the operator's per-resource state
suppresses it, that finding must not also burn investigation budget.
The existing chain already delivers this — findings.Add sets
DismissedReason="expected_behavior", and ShouldInvestigate gates on
DismissedReason != "" — but the relationship was implicit. Without a
test, a future refactor of either branch could silently start
investigating operator-suppressed findings again.

Pins the contract with a table-driven test covering both signals
(intentionally_offline and maintenance_window) at every autonomy
level (approval/assisted/full), plus the lifecycle-cause metadata
attribution. No runtime change — only the test and a contract
paragraph naming the dependency.
2026-05-09 18:05:13 +01:00
rcourtman 6e0b951a27 Strip Proxmox pve-manager wrapper from version badges
Proxmox APIs return version strings in the form
'pve-manager/9.1.9/ee7bad0a3d1546c9' (package-name/version/git-hash).
Without stripping the wrapper, badges rendered as
'PVE pve-manager/9.1.9/ee7bad0a3d1546c9', which truncates to the
unreadable 'PVE pve-manage...' in the SYSTEM column on the
infrastructure dashboard.

Extend normalizeVersion to detect and unwrap the package/version/hash
shape (also covers 'proxmox-backup-server/4.0/...' and similar). Now
badges read 'PVE 9.1.9', 'PBS 4.0', etc. as expected.
2026-05-09 18:03:42 +01:00
rcourtman bfa7ba3b58 Add maintenance-window scheduler to the operator-overrides section
Closes the per-resource operator-state feature: an operator can now
schedule a maintenance window directly from the resource detail
drawer, with HTML5 datetime-local inputs, 1h/4h/24h quick presets, an
optional reason field, and client-side validation that end > start.
The scheduled window flows through the existing
/api/resources/{id}/operator-state PUT path so the action broker and
findings runtime see it on the next dispatch and re-detection
respectively.

Distinguishes a future-scheduled window (start > now) from an active
one (now within [start, end)) so the operator sees "scheduled" before
the window opens, "active" while it covers now, and clean state once
it ends. Scheduler saves preserve the toggle state and toggle saves
preserve the window so the two facets stay decoupled — editing one
does not lose work on the other.

Also exposes Edit window and Cancel window controls in the compact
view so operators can adjust or clear an existing window without
re-entering its details.
2026-05-09 15:50:23 +01:00
rcourtman 7617795bdc Surface operator-set per-resource state on the resource detail drawer
Frontend wedge for the per-resource operator-state feature: an operator
working on a resource can now toggle Intentionally offline and Never
auto-remediate without curling the API. The section lives on the
overview tab next to the action audit history so the "what overrides
has the operator set" and "what actions has Pulse taken" stories read
together.

NeverAutoRemediate is a safety override — flipping it on requires an
explicit confirmation prompt naming what the lock means, while flipping
it back off is permissive (releasing a lock is the recoverable action).
Maintenance windows are surfaced read-only this slice; scheduling lives
in a follow-up that owns the date-picker UX.

Uses createNonSuspendingQuery rather than createResource so the
drawer's parent Suspense boundary does not flicker the page-level
"Loading view..." fallback while operator state is in flight. The save
path preserves any currently-persisted maintenance-window data so this
toggle slice does not clobber the future window-scheduler slice.

Adds a TS API client (getResourceOperatorState /
setResourceOperatorState / clearResourceOperatorState) that mirrors
the canonical Go shape from slice 30, with 404 -> null normalization
on the GET path so callers see "no state" as a clean default.
2026-05-09 15:20:53 +01:00