mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Build canonical Patrol attention workbench
This commit is contained in:
+28
-1
@@ -96,6 +96,31 @@ model-reported findings ── validated, deduplicated, stored
|
||||
MaybeInvestigateFinding() ── model investigation + governed fix planning/execution
|
||||
```
|
||||
|
||||
### The Patrol attention queue
|
||||
|
||||
The first thing Patrol shows is **Needs attention**, a single operator queue
|
||||
projected from Pulse's canonical alert lifecycle. It combines current
|
||||
operational state with evidence quality and protection context; it does not
|
||||
create a second finding lifecycle.
|
||||
|
||||
- **Active** contains open work plus stale or unknown collection states that
|
||||
still require a decision.
|
||||
- **Acknowledged** and **Suppressed** remain inspectable without being counted
|
||||
as active work.
|
||||
- **Recent resolved** preserves the explanation and transition history without
|
||||
presenting old work as live.
|
||||
- **Stale or unknown** means Pulse lacks current enough evidence. It is never
|
||||
shown as healthy or resolved.
|
||||
- A calm message appears only when the lifecycle evaluation succeeded, coverage
|
||||
is current, and no active item exists. An unavailable evaluation says so
|
||||
explicitly.
|
||||
|
||||
Select an item to see the affected resource, impact, next step, typed evidence,
|
||||
protection posture, and lifecycle timeline. **Explain with Assistant** appears
|
||||
only inside selected context and receives policy-shaped summaries and
|
||||
references. Assistant can explain the item, but it cannot create lifecycle
|
||||
truth, invent action authority, or hide uncertainty.
|
||||
|
||||
### What Patrol Sees
|
||||
|
||||
Every patrol run passes the LLM comprehensive context about your environment:
|
||||
@@ -500,7 +525,9 @@ Pulse includes a model-context layer that aggregates evidence from AI runtime su
|
||||
|
||||
### Health Scoring
|
||||
|
||||
The Patrol UI can show an operational score (A-F) based on active findings, Patrol coverage, runtime errors, and structured evidence. This score is a presentation aid, not a replacement for model diagnosis.
|
||||
Historical Patrol checks and the legacy operational score remain available in
|
||||
the collapsed supporting-context section. They are not the primary daily
|
||||
monitoring answer and do not replace the canonical attention queue.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+23
@@ -1038,6 +1038,29 @@ Kubernetes-specific analysis as a standalone plan pillar.
|
||||
Runs a focused investigation for an alert payload (used by the UI).
|
||||
|
||||
### Patrol
|
||||
- `GET /api/ai/patrol/attention`
|
||||
- Returns the typed Patrol attention queue projected from canonical
|
||||
operational lifecycle records. This is the active-count and queue source
|
||||
used by both navigation and Patrol.
|
||||
- Query params: `filter` (`active` | `open` | `acknowledged` | `suppressed` |
|
||||
`stale_unknown` | `resolved` | `all`), `page` (minimum 1), and `limit`
|
||||
(1–200).
|
||||
- The response includes `data`, a lifecycle-wide `summary`, and bounded
|
||||
pagination `meta`. Protection context is joined in one bounded batch, not
|
||||
fetched per item.
|
||||
- `GET /api/ai/patrol/attention/summary`
|
||||
- Returns the canonical active, open, acknowledged, suppressed,
|
||||
stale/unknown, and recent-resolved counts plus `calm`, `coverageState`, and
|
||||
`evaluatedAt`.
|
||||
- A lifecycle-read failure returns a typed unavailable error. It never
|
||||
returns a synthetic zero or healthy state.
|
||||
- `GET /api/ai/patrol/attention/{id}`
|
||||
- Returns one attention item with its operational record, lifecycle
|
||||
timeline, typed evidence, recommended next step, relationships, and
|
||||
protection posture.
|
||||
- All three attention routes require `monitoring:read`. They are read-side
|
||||
projections only; alert lifecycle mutations remain owned by the canonical
|
||||
alert routes.
|
||||
- `GET /api/ai/patrol/autonomy`
|
||||
- `PUT /api/ai/patrol/autonomy`
|
||||
- `GET /api/ai/patrol/status`
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
# Operational Trust: Patrol Attention Workbench
|
||||
|
||||
Date: 2026-07-19
|
||||
Specification:
|
||||
`docs/release-control/v6/internal/OPERATIONAL_TRUST_IMPLEMENTATION_SPEC.md`
|
||||
Phase: 3, Patrol attention workbench
|
||||
Candidate: `protection-posture-attention-queue`
|
||||
|
||||
## Outcome
|
||||
|
||||
Phase 3 is a product-grade canonical read path over the Phase 1 lifecycle and
|
||||
Phase 2 protection posture:
|
||||
|
||||
1. `internal/ai/attention.go` projects canonical alert operational records,
|
||||
evidence, transitions, and recovery-owned posture into typed attention
|
||||
items. It owns ordering, lifecycle filters, pagination, summary, and honest
|
||||
calm evaluation, not writable truth.
|
||||
2. `internal/api/attention_handlers.go` exposes bounded list, summary, and
|
||||
detail routes protected by `monitoring:read`. The summary avoids recovery
|
||||
history; list posture joins are batched at 200 subjects.
|
||||
3. `frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx` is the
|
||||
primary Patrol surface. Navigation and queue counts use the same summary.
|
||||
Legacy checks, investigations, and run history are demoted to collapsed
|
||||
supporting context.
|
||||
4. Selected detail carries affected and related resources, impact,
|
||||
recommended next step, typed evidence and limitations, protection posture,
|
||||
timeline, owning-resource navigation, and an explanation-only Assistant
|
||||
handoff.
|
||||
5. Lifecycle failure remains unavailable, posture failure remains partial, and
|
||||
neither can become a false zero, calm, healthy, resolved, or protected
|
||||
state.
|
||||
6. Historical snapshots contribute recent resolved context only. A
|
||||
non-terminal history entry without a matching active record cannot revive
|
||||
false current work. Canonical record IDs containing `/` remain valid,
|
||||
percent-encoded deep-link identities.
|
||||
|
||||
The queue contains no action-execution affordance in this phase. Governed
|
||||
offers, approval, execution, and verification remain Phase 5 work.
|
||||
|
||||
## User Lens
|
||||
|
||||
Operator job, in the least-expert plausible user's words:
|
||||
|
||||
> Show me what needs attention now, why it matters, what it affects, whether it
|
||||
> is protected, and what I should do next.
|
||||
|
||||
Live exercise:
|
||||
|
||||
1. The authenticated launch remains monitor-first. Patrol is one navigation
|
||||
action away.
|
||||
2. Patrol answers the job in its first bordered section: one heading/count,
|
||||
six lifecycle filters, an ordered queue, and refresh.
|
||||
3. One item selection reaches the deepest state. It exposes impact, resource,
|
||||
evidence, protection, timeline, next step, and supporting links.
|
||||
4. A 390-by-844 viewport has no document-width overflow. Selection and a
|
||||
direct deep-link bring detail into view; close restores focus to the item.
|
||||
5. Reduced-motion, keyboard activation, stable screen-reader names, calm,
|
||||
partial/unavailable, acknowledged, suppressed, stale/unknown, and recent
|
||||
resolved states are covered by browser proof.
|
||||
6. The live deepest state exposed 64 repeated observations for one disk
|
||||
incident. The default detail now shows the latest three and collapses the
|
||||
remaining 61 under an explicit older-observations disclosure, preserving
|
||||
forensic evidence without turning the monitor into a raw event browser.
|
||||
|
||||
Default-visible element decisions:
|
||||
|
||||
| Element | Operator action | Decision |
|
||||
| --- | --- | --- |
|
||||
| Active count | Judge current workload and open Patrol | Keep |
|
||||
| Lifecycle filters | Inspect work without losing acknowledged, suppressed, uncertain, or resolved history | Keep |
|
||||
| Ordered item row | Choose the next issue and open evidence | Keep |
|
||||
| Resource, evidence, protection, age labels | Decide urgency and whether the item is trustworthy enough to act on | Keep |
|
||||
| Refresh | Request a new evaluation | Keep |
|
||||
| Provider evidence and timeline | Explain one selected item | Demote to selected detail |
|
||||
| Repeated older evidence observations | Inspect forensic history when needed | Demote to an explicit disclosure inside selected detail |
|
||||
| Patrol run history, investigations, score | Forensic or historical context | Demote to collapsed supporting context |
|
||||
| Large Assistant prompt | No action before an item is selected | Cut |
|
||||
| Generic trust score/proof strip | No direct operational action | Cut from the primary queue |
|
||||
|
||||
Vocabulary:
|
||||
|
||||
- `Stale or unknown` replaces implementation vocabulary about collectors and
|
||||
explicitly says that evidence is insufficient for health.
|
||||
- `Protection unknown` means no complete subject-linked provider history, not
|
||||
unprotected.
|
||||
- Provider/collector identity appears only in detail.
|
||||
|
||||
GitHub issue comparison:
|
||||
|
||||
1. [#1244](https://github.com/rcourtman/Pulse/issues/1244) asks for findings
|
||||
“listed at a glance”; the queue is the primary at-a-glance destination.
|
||||
2. [#1234](https://github.com/rcourtman/Pulse/issues/1234) says recommendations
|
||||
are “really difficult to find”; selected detail keeps the next step beside
|
||||
impact and evidence.
|
||||
3. [#1580](https://github.com/rcourtman/Pulse/issues/1580) reports confusion
|
||||
after acknowledging stale backup alerts; lifecycle filters keep
|
||||
acknowledged and stale/unknown distinct and inspectable.
|
||||
4. [#1553](https://github.com/rcourtman/Pulse/issues/1553) identifies recovery
|
||||
notification noise without a preceding alert; the queue consumes the
|
||||
canonical lifecycle rather than notification delivery.
|
||||
5. [#1056](https://github.com/rcourtman/Pulse/issues/1056) asks not to be
|
||||
bothered by intentionally archived backups; suppressed work leaves the
|
||||
default active queue but remains inspectable.
|
||||
6. [#1215](https://github.com/rcourtman/Pulse/issues/1215) reports incorrect
|
||||
Patrol reachability information; evidence freshness, completeness,
|
||||
confidence, and limiting caveats are visible before Assistant explanation.
|
||||
7. [#1223](https://github.com/rcourtman/Pulse/issues/1223) and
|
||||
[#1131](https://github.com/rcourtman/Pulse/issues/1131) document mobile
|
||||
scaling failures; phone-width deepest-state proof is now explicit.
|
||||
|
||||
Verdict: `product`. The primary surface answers the operator job without a
|
||||
second lifecycle, generic object browser, proof strip, or Assistant-first
|
||||
detour.
|
||||
|
||||
## Proof
|
||||
|
||||
Backend:
|
||||
|
||||
```text
|
||||
go test ./internal/alerts ./internal/ai ./internal/api \
|
||||
-run 'OperationalContract|Attention|PatrolMetrics|RouteInventory' -count=1
|
||||
```
|
||||
|
||||
Focused migration and route regressions additionally prove that orphaned
|
||||
non-terminal history is excluded, resolved history remains available, and
|
||||
canonical IDs containing encoded slashes open their detail route.
|
||||
|
||||
Frontend:
|
||||
|
||||
```text
|
||||
npm run type-check
|
||||
npm run lint
|
||||
npm exec vitest run \
|
||||
src/api/__tests__/patrolAttention.test.ts \
|
||||
src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx \
|
||||
src/__tests__/App.architecture.test.ts \
|
||||
src/__tests__/AppLayout.test.tsx \
|
||||
src/pages/__tests__/AIIntelligence.test.tsx \
|
||||
src/routing/__tests__/resourceLinks.test.ts
|
||||
bash scripts/tests/test-hot-dev-runtime.sh
|
||||
```
|
||||
|
||||
Browser:
|
||||
|
||||
```text
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173 \
|
||||
npx playwright test \
|
||||
tests/91-operational-trust-attention-workbench.spec.ts \
|
||||
--project=chromium
|
||||
```
|
||||
|
||||
The browser proof renders the real frontend and uses bounded deterministic API
|
||||
evidence without a live credential or mutable first-run setup. It passes active
|
||||
work, every Phase 3 lifecycle filter, evidence/protection/timeline detail,
|
||||
navigation-count consistency, keyboard/focus, narrow viewport, reduced motion,
|
||||
calm, and unavailable-without-false-health.
|
||||
|
||||
`npm test -- tests/91-operational-trust-attention-workbench.spec.ts
|
||||
--project=chromium` was not the correct managed-runtime invocation because that
|
||||
wrapper requires Docker, which was not running. A broader `npm run dev:verify`
|
||||
also encountered the pre-existing first-session setup-wizard helper timeout in
|
||||
`16-dev-runtime-recovery.spec.ts`; the Phase 3 feature proof above avoids that
|
||||
unrelated setup mutation and passes directly against the managed runtime.
|
||||
|
||||
The managed live runtime was then rebuilt from the same Development-SSD source
|
||||
tree. Its live contract reported one current active item, a current
|
||||
non-calm coverage state, and the migrated generic next step. The selected disk
|
||||
item deep-linked successfully on a 390-by-844 viewport with a 390-pixel
|
||||
document width; its default evidence section showed the latest 3 of 64
|
||||
observations with 61 older observations collapsed.
|
||||
|
||||
Performance proof:
|
||||
|
||||
- `internal/ai/attention_performance_test.go` projects 10,000 lifecycle records
|
||||
and proves a bounded 200-item page.
|
||||
- The summary route performs no posture-store read.
|
||||
- The list performs one bounded posture batch and no per-item fetch.
|
||||
|
||||
## Remaining Specification Work
|
||||
|
||||
This record accepts Phase 3 only. It does not close the overall operational
|
||||
trust goal, candidate lane, or coverage gap. Availability attachment (Phase
|
||||
4), governed actions and verification (Phase 5), and rollout hardening (Phase
|
||||
6) remain required before the specification's 14 completion criteria can
|
||||
close.
|
||||
@@ -8732,6 +8732,11 @@
|
||||
"path": "docs/release-control/v6/internal/records/operational-trust-lifecycle-evidence-notification-linkage-2026-07-19.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/operational-trust-patrol-attention-workbench-2026-07-19.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/operational-trust-protection-posture-2026-07-19.md",
|
||||
@@ -8801,6 +8806,11 @@
|
||||
"repo": "pulse",
|
||||
"path": "tests/integration/tests/90-operational-trust-protection-posture.spec.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "tests/integration/tests/91-operational-trust-attention-workbench.spec.ts",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -4625,3 +4625,8 @@ lifecycle boundary. Protection posture is a read-only `monitoring:read`
|
||||
projection over recovery points and provider collection evidence. It does not
|
||||
register agents, issue or rotate credentials, interpret observer responses,
|
||||
or grant backup, restore, command, or remote-configuration authority.
|
||||
|
||||
The adjacent Patrol attention handlers and shared router registration also do
|
||||
not widen agent authority. They read the canonical alert lifecycle under
|
||||
`monitoring:read`; they do not accept agent reports, mint credentials, deliver
|
||||
commands, or reinterpret an observer response as configuration.
|
||||
|
||||
@@ -369,6 +369,8 @@ without requiring the model to understand an internal goroutine schedule.
|
||||
## Canonical Files
|
||||
|
||||
1. `internal/ai/`
|
||||
1e. `internal/ai/attention.go`
|
||||
1f. `internal/ai/patrol_metrics.go`
|
||||
1a. `cmd/pulse-mcp/main.go`
|
||||
1b. `cmd/pulse-mcp/README.md`
|
||||
1c. `internal/agentcapabilities/`
|
||||
@@ -3868,6 +3870,21 @@ resolve canonical/source IDs and unique aliases before collection, reject
|
||||
so Patrol approval/finding handoffs and alert-investigation handoffs are
|
||||
named by their source rather than as generic dashboard briefs.
|
||||
|
||||
### Operational attention projection boundary
|
||||
|
||||
`internal/ai/attention.go` is the canonical Patrol attention read-model
|
||||
projection over alert-owned operational records, transition timelines,
|
||||
evidence envelopes, and recovery-owned protection postures. It may order,
|
||||
filter, paginate, summarize, and shape selected Assistant context, but it must
|
||||
not create a second writable lifecycle or infer a healthy state when lifecycle
|
||||
coverage is unavailable. `internal/ai/patrol_metrics.go` exports only
|
||||
low-cardinality lifecycle-state counts, queue age, acknowledgement time, and
|
||||
calm-evaluation age; raw resource IDs are forbidden as metric labels.
|
||||
|
||||
Assistant receives a selected attention item only after the operator opens it.
|
||||
The handoff is explanation-only and cannot infer a capability, approve an
|
||||
action, or replace evidence and lifecycle truth.
|
||||
|
||||
## Current State
|
||||
|
||||
First-session assistant discoverability is now a contract concern. Successful
|
||||
|
||||
@@ -213,6 +213,19 @@ transition references recovery evidence separate from its trigger evidence.
|
||||
3. Route runtime changes through the explicit alert proof policies in `registry.json`; default fallback proof routing is not allowed
|
||||
4. Tighten or add guardrails when an old alert path is removed
|
||||
|
||||
### Attention projection source contract
|
||||
|
||||
Canonical alert operational records, evidence envelopes, and lifecycle
|
||||
transitions are the only writable source for Patrol attention. The legacy alert
|
||||
adapter must preserve an existing provider-authored recommended next step, use
|
||||
the canonical `incidentAction` when present, and otherwise add the safe
|
||||
operator instruction to open the affected resource and verify current state
|
||||
before changing it. This is migration guidance, not action authority.
|
||||
|
||||
The attention read model may project and filter alert lifecycle state, but it
|
||||
must not reinterpret acknowledgement as resolution, omit suppressed state from
|
||||
inspectability, or convert missing/stale evidence into health.
|
||||
|
||||
## Current State
|
||||
|
||||
The alert resource-incident panel
|
||||
|
||||
@@ -32,6 +32,7 @@ product API routes free of maintainer commercial analytics.
|
||||
2. `internal/api/resources.go`
|
||||
3. `internal/api/discovery_handlers.go`
|
||||
4. `internal/api/alerts.go`
|
||||
4a. `internal/api/attention_handlers.go`
|
||||
5. `internal/api/activity_audit_handlers.go`
|
||||
6. `internal/api/actions.go`
|
||||
5a. `internal/api/action_executor.go`
|
||||
@@ -50,6 +51,7 @@ product API routes free of maintainer commercial analytics.
|
||||
12a. `frontend-modern/src/types/operationalTrust.ts`
|
||||
13. `frontend-modern/src/types/actionAudit.ts`
|
||||
14. `frontend-modern/src/api/actionAudit.ts`
|
||||
14a. `frontend-modern/src/api/patrolAttention.ts`
|
||||
7a. `frontend-modern/src/api/resourceActions.ts`
|
||||
7b. `frontend-modern/src/api/agentCapabilities.ts`
|
||||
7c. `frontend-modern/src/api/generated/agentCapabilities.ts`
|
||||
@@ -999,6 +1001,7 @@ payload shape change when the portal presents compact client rows.
|
||||
bounded freshness window, but it must not mint a second freshness endpoint,
|
||||
rewrite the stable Patrol payload, or present old run evidence as a current
|
||||
healthy all-clear.
|
||||
12. `frontend-modern/src/api/patrolAttention.ts` shared with `patrol-intelligence`: the Patrol attention client is both the Patrol read-model transport and a canonical typed API boundary.
|
||||
12. `frontend-modern/src/api/rbac.ts` shared with `organization-settings`: the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary.
|
||||
13. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
14. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
|
||||
@@ -3466,6 +3469,24 @@ successful targeted check from a queued response alone.
|
||||
API outcome instead of panicking or leaving browser-visible state half
|
||||
rewired.
|
||||
|
||||
### Patrol attention transport
|
||||
|
||||
`internal/api/attention_handlers.go` and
|
||||
`frontend-modern/src/api/patrolAttention.ts` own the typed read transport for
|
||||
`GET /api/ai/patrol/attention`,
|
||||
`GET /api/ai/patrol/attention/summary`, and
|
||||
`GET /api/ai/patrol/attention/{id}`. All routes require `monitoring:read`.
|
||||
Lists use bounded pagination with a maximum of 200 records and one bounded
|
||||
protection-posture batch. The summary path does not read recovery history.
|
||||
|
||||
Lifecycle read failure returns a typed unavailable response and never a
|
||||
synthetic zero or calm state. Protection lookup failure may return lifecycle
|
||||
work only with `coverageState: partial`; it cannot erase work. Alert lifecycle
|
||||
mutations remain on their canonical alert routes. Route, payload, and failure
|
||||
proof lives in `internal/api/attention_handlers_test.go`,
|
||||
`frontend-modern/src/api/__tests__/patrolAttention.test.ts`, and
|
||||
`internal/api/route_inventory_test.go`.
|
||||
|
||||
## Current State
|
||||
|
||||
The public Patrol investigation boundary now carries independent
|
||||
|
||||
@@ -1511,6 +1511,11 @@ the shared monitored-system presentation helper instead of branching on
|
||||
demo/billing state inside settings panels or inventing a second mock-only
|
||||
license explanation path.
|
||||
|
||||
The app-layout Patrol attention count is a license-neutral operational read.
|
||||
It may show canonical current work after authenticated bootstrap, but it must
|
||||
not infer a paid entitlement, expose an upgrade prompt, or convert missing
|
||||
commercial state into feature access.
|
||||
|
||||
Hosted organization and Billing Admin browser clients must prefer an active
|
||||
`pulse_session` cookie when the same browser also retains an API token from
|
||||
first-run setup. Platform-admin and session-required organization operations
|
||||
|
||||
@@ -1753,6 +1753,10 @@ same installability boundary.
|
||||
points for a coherent local Pulse runtime, so frontend shell health, proxy
|
||||
health, backend health, and listener ownership diagnostics may not drift into
|
||||
ad hoc shell snippets or undocumented operator lore outside those scripts.
|
||||
The managed launcher must tolerate a canonical dev environment file that does
|
||||
not yet contain `PULSE_MOCK_MODE`. Missing mock-mode configuration falls back
|
||||
to the existing environment/default instead of terminating under `set -e`;
|
||||
`scripts/tests/test-hot-dev-runtime.sh` pins this startup contract.
|
||||
Root and frontend workspace dependency manifests, their lockfiles, the
|
||||
frontend build config, and the Go module graph are canonical inputs to that
|
||||
developer/runtime bootstrap. Changes to `package.json`, `package-lock.json`,
|
||||
|
||||
@@ -2323,6 +2323,22 @@ default` instead of fusing provider and badge text such as
|
||||
and the parameterized search cases in
|
||||
`frontend-modern/src/components/Settings/__tests__/useSettingsAccess.test.tsx`.
|
||||
|
||||
### Attention workbench shell contract
|
||||
|
||||
The app shell and Patrol attention workbench share the same canonical summary
|
||||
for desktop and mobile accessible navigation counts. The label remains
|
||||
`Patrol`; its accessible description may add the active attention count.
|
||||
`frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx` uses native
|
||||
buttons, visible focus, selected-state semantics, focus restoration, narrow
|
||||
viewport ordering, and reduced-motion-safe behavior. Detail deep links use
|
||||
`frontend-modern/src/routing/resourceLinks.ts` and remain stable across reload.
|
||||
|
||||
The default queue may show resource, impact, evidence, protection, next-step,
|
||||
and age facts. Provider evidence and lifecycle history belong in selected
|
||||
detail, while legacy Patrol analytics belong in collapsed supporting context.
|
||||
Unavailable and partial states must use explicit copy rather than success
|
||||
styling.
|
||||
|
||||
## Current State
|
||||
|
||||
Assistant availability in the app shell is derived from the
|
||||
|
||||
@@ -52,10 +52,14 @@ Patrol-specific presentation helpers.
|
||||
29. `frontend-modern/src/utils/textPresentation.ts`
|
||||
30. `tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts`
|
||||
31. `tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts`
|
||||
32. `frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx`
|
||||
33. `frontend-modern/src/stores/patrolAttention.ts`
|
||||
34. `tests/integration/tests/91-operational-trust-attention-workbench.spec.ts`
|
||||
35. `frontend-modern/src/api/patrolAttention.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
1. None.
|
||||
1. `frontend-modern/src/api/patrolAttention.ts` shared with `api-contracts`: the Patrol attention client is both the Patrol read-model transport and a canonical typed API boundary.
|
||||
|
||||
## Extension Points
|
||||
|
||||
@@ -937,6 +941,24 @@ fix`, or `Explain` based on current finding state), while secondary
|
||||
owning Findings, Runs, or `Details` surfaces
|
||||
instead of reopening the compact strip into a sparse status panel.
|
||||
|
||||
### Canonical operational attention workbench
|
||||
|
||||
The primary Patrol workspace is now
|
||||
`frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx`. It consumes
|
||||
`frontend-modern/src/stores/patrolAttention.ts` and the typed attention API; it
|
||||
must not derive lifecycle state from legacy Patrol findings. Active, open,
|
||||
acknowledged, suppressed, stale/unknown, recent-resolved, calm, partial, and
|
||||
unavailable states all come from the same backend projection. Navigation and
|
||||
the queue consume the same active summary.
|
||||
|
||||
The default view owns only the ordered action queue. Legacy Patrol checks,
|
||||
investigations, and run history remain available under collapsed supporting
|
||||
context. The selected detail owns impact, related resources, evidence quality,
|
||||
protection posture, timeline, next step, resource navigation, and the
|
||||
contextual Assistant handoff. Assistant is absent before selection and receives
|
||||
explanation-only typed context without action or approval authority. Browser
|
||||
proof is `tests/integration/tests/91-operational-trust-attention-workbench.spec.ts`.
|
||||
|
||||
## Current State
|
||||
|
||||
The active Patrol queue now uses compact severity-accented rows for
|
||||
|
||||
@@ -1920,3 +1920,8 @@ boundary. Exact retries do not append evidence or rewrite timestamps.
|
||||
This is a per-tenant, single-process serialized config boundary, not a claim of
|
||||
distributed multi-writer CAS. Provider-wide inheritance, distributed budgets,
|
||||
and cross-process policy mutation remain outside this backend prerequisite.
|
||||
|
||||
The authenticated app bootstrap performs one bounded Patrol attention summary
|
||||
read and stores only low-cardinality counts. Queue list reads are paginated to
|
||||
at most 200 items, posture joins batch at 200 subjects, and the summary path
|
||||
does not scan recovery history or perform per-resource reads.
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
"api-contracts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "frontend-modern/src/api/patrolAttention.ts",
|
||||
"rationale": "the Patrol attention client is both the Patrol read-model transport and a canonical typed API boundary",
|
||||
"subsystems": [
|
||||
"api-contracts",
|
||||
"patrol-intelligence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "frontend-modern/src/api/rbac.ts",
|
||||
"rationale": "the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary",
|
||||
@@ -5461,6 +5469,7 @@
|
||||
"contract": "docs/release-control/v6/internal/subsystems/patrol-intelligence.md",
|
||||
"owned_prefixes": [],
|
||||
"owned_files": [
|
||||
"frontend-modern/src/api/patrolAttention.ts",
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx",
|
||||
"frontend-modern/src/components/Brand/PulsePatrolLogo.tsx",
|
||||
"frontend-modern/src/components/patrol/ApprovalBanner.tsx",
|
||||
@@ -5473,6 +5482,7 @@
|
||||
"frontend-modern/src/components/patrol/RunHistoryEntry.tsx",
|
||||
"frontend-modern/src/components/patrol/RunHistoryPanel.tsx",
|
||||
"frontend-modern/src/components/patrol/RunToolCallTrace.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
|
||||
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts",
|
||||
"frontend-modern/src/features/patrol/patrolControlPresentation.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
|
||||
@@ -5484,6 +5494,7 @@
|
||||
"frontend-modern/src/pages/AIIntelligence.tsx",
|
||||
"frontend-modern/src/stores/aiIntelligence.ts",
|
||||
"frontend-modern/src/stores/aiIntelligenceSummaryModel.ts",
|
||||
"frontend-modern/src/stores/patrolAttention.ts",
|
||||
"frontend-modern/src/types/aiIntelligence.ts",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts",
|
||||
"frontend-modern/src/utils/aiPatrolSchedulePresentation.ts",
|
||||
@@ -5515,8 +5526,10 @@
|
||||
"label": "patrol page and state proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/api/patrolAttention.ts",
|
||||
"frontend-modern/src/components/Brand/PulsePatrolLogo.tsx",
|
||||
"frontend-modern/src/components/patrol/index.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
|
||||
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts",
|
||||
"frontend-modern/src/features/patrol/patrolControlPresentation.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
|
||||
@@ -5528,13 +5541,16 @@
|
||||
"frontend-modern/src/pages/AIIntelligence.tsx",
|
||||
"frontend-modern/src/stores/aiIntelligence.ts",
|
||||
"frontend-modern/src/stores/aiIntelligenceSummaryModel.ts",
|
||||
"frontend-modern/src/stores/patrolAttention.ts",
|
||||
"frontend-modern/src/types/aiIntelligence.ts",
|
||||
"frontend-modern/src/utils/patrolPagePresentation.ts"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/api/__tests__/patrolAttention.test.ts",
|
||||
"frontend-modern/src/components/Brand/__tests__/PulsePatrolLogo.test.tsx",
|
||||
"frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx",
|
||||
"frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts",
|
||||
"frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts",
|
||||
"frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
|
||||
@@ -5545,7 +5561,8 @@
|
||||
"frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts",
|
||||
"tests/integration/tests/18-patrol-runtime-state.spec.ts",
|
||||
"tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts",
|
||||
"tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts"
|
||||
"tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts",
|
||||
"tests/integration/tests/91-operational-trust-attention-workbench.spec.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -179,6 +179,12 @@ resource or alert metadata. Those fields are operational usage telemetry only;
|
||||
they must not be expanded into command lines, environment variables, secret
|
||||
material, or unbounded container inspection output at the API boundary.
|
||||
|
||||
Patrol attention list, summary, and detail routes require `monitoring:read`.
|
||||
They expose bounded canonical evidence and resource references only; no route
|
||||
grants action authority, returns credentials, or turns Assistant explanation
|
||||
into approval. Canonical record IDs are compared as opaque identities even
|
||||
when their percent-encoded route representation contains `/`.
|
||||
|
||||
Scheduled report management under `/api/admin/reports/schedules` is a
|
||||
settings/reporting control surface, not a new public data export. It must reuse
|
||||
the existing reporting feature gate and settings read/write scopes, persist
|
||||
|
||||
@@ -1802,6 +1802,19 @@ must not treat starter
|
||||
so adjacent browser surfaces inherit a fail-closed API response instead of
|
||||
a panic after the archive import succeeds.
|
||||
|
||||
### Attention posture join
|
||||
|
||||
Patrol attention reads protection posture through the canonical recovery store
|
||||
as a bounded subject-ID batch. Recovery remains the sole owner of protected,
|
||||
attention, unprotected, and unknown derivation and its provider evidence.
|
||||
Attention projection may order and display that posture but cannot recompute it
|
||||
or call the store once per item.
|
||||
|
||||
If posture lookup fails, lifecycle work remains visible with partial coverage.
|
||||
If no subject-linked posture exists, the UI says protection is unknown rather
|
||||
than unprotected. The summary-only navigation path does not query recovery
|
||||
history.
|
||||
|
||||
## Current State
|
||||
|
||||
Shared `internal/api/ai_handlers.go` now projects separate Patrol investigation
|
||||
|
||||
@@ -1790,6 +1790,12 @@ through the canonical resource model, but unified-resource consumers must not
|
||||
reintroduce removed workload aliases or feature-local resource-type shims just
|
||||
to satisfy one table, drawer, or badge surface.
|
||||
|
||||
Patrol attention resource navigation carries the canonical subject resource ID
|
||||
through shared route builders as an opaque query value. Attention deep links
|
||||
carry the canonical operational-record ID separately. Neither link may derive
|
||||
a replacement resource identity from display text, provider labels, or alert
|
||||
metadata.
|
||||
|
||||
### Protection posture identity consumer
|
||||
|
||||
`ProxmoxCoverageTable` remains a unified-resource identity consumer while
|
||||
|
||||
@@ -52,7 +52,7 @@ import { updateStore } from '@/stores/updates';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { getActionApprovalBadgePresentation } from '@/features/actions/actionPresentation';
|
||||
import { actionInboxStore } from '@/stores/actionInbox';
|
||||
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
|
||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||
import { isPro } from '@/stores/licenseCommercial';
|
||||
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
|
||||
import { getAssistantPageContext } from '@/utils/assistantPageContext';
|
||||
@@ -401,11 +401,11 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
const actionApprovalBadge = createMemo(() =>
|
||||
getActionApprovalBadgePresentation(actionInboxStore.pendingActionCount),
|
||||
);
|
||||
const patrolOpenWorkCount = createMemo(() => aiIntelligenceStore.patrolOpenWorkCount);
|
||||
const patrolOpenWorkCountLabel = createMemo(() => {
|
||||
const count = patrolOpenWorkCount();
|
||||
const patrolAttentionCount = createMemo(() => patrolAttentionStore.summary()?.activeCount ?? 0);
|
||||
const patrolAttentionCountLabel = createMemo(() => {
|
||||
const count = patrolAttentionCount();
|
||||
if (count <= 0) return undefined;
|
||||
return `${count} open work ${count === 1 ? 'item' : 'items'}`;
|
||||
return `${count} active attention ${count === 1 ? 'item' : 'items'}`;
|
||||
});
|
||||
|
||||
// Platform/runtime nav is resource-admitted. A platform or runtime lens only
|
||||
@@ -534,10 +534,10 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
id: 'ai',
|
||||
label: 'Patrol',
|
||||
route: '/patrol',
|
||||
tooltip: 'Review Patrol checks, findings, and approvals',
|
||||
tooltip: 'Review active operational attention and recent Patrol checks',
|
||||
badge: null,
|
||||
count: patrolOpenWorkCount() > 0 ? patrolOpenWorkCount() : undefined,
|
||||
countLabel: patrolOpenWorkCountLabel(),
|
||||
count: patrolAttentionCount() > 0 ? patrolAttentionCount() : undefined,
|
||||
countLabel: patrolAttentionCountLabel(),
|
||||
breakdown: undefined,
|
||||
icon: PulsePatrolLogo,
|
||||
},
|
||||
|
||||
@@ -321,9 +321,11 @@ describe('App architecture', () => {
|
||||
expect(appLayoutSource).not.toContain("'/operations', '/patrol', '/ai'");
|
||||
expect(appLayoutSource).toContain("route: '/patrol',");
|
||||
expect(appLayoutSource).toContain("label: 'Patrol'");
|
||||
expect(appLayoutSource).toContain("tooltip: 'Review Patrol checks, findings, and approvals'");
|
||||
expect(appLayoutSource).toContain('const patrolOpenWorkCount = createMemo(');
|
||||
expect(appLayoutSource).toContain('countLabel: patrolOpenWorkCountLabel()');
|
||||
expect(appLayoutSource).toContain(
|
||||
"tooltip: 'Review active operational attention and recent Patrol checks'",
|
||||
);
|
||||
expect(appLayoutSource).toContain('const patrolAttentionCount = createMemo(');
|
||||
expect(appLayoutSource).toContain('countLabel: patrolAttentionCountLabel()');
|
||||
expect(appLayoutSource).not.toContain("label: 'Needs Attention'");
|
||||
expect(appLayoutSource).not.toContain("route: '/operations',");
|
||||
expect(appLayoutSource).not.toContain('props.connected()');
|
||||
@@ -503,6 +505,6 @@ describe('App architecture', () => {
|
||||
expect(runtimeHomeSource).not.toContain('aiIntelligenceStore');
|
||||
expect(runtimeHomeSource).not.toContain('patrolOpenWork');
|
||||
expect(appLayoutSource).toContain("label: 'Patrol'");
|
||||
expect(appLayoutSource).toContain('countLabel: patrolOpenWorkCountLabel()');
|
||||
expect(appLayoutSource).toContain('countLabel: patrolAttentionCountLabel()');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,15 +10,15 @@ import { aiChatStore } from '@/stores/aiChat';
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
const aiIntelligenceMockState = vi.hoisted(() => ({
|
||||
patrolOpenWorkCount: 0,
|
||||
const patrolAttentionMockState = vi.hoisted(() => ({
|
||||
activeCount: 0,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/aiIntelligence', () => ({
|
||||
aiIntelligenceStore: {
|
||||
get patrolOpenWorkCount() {
|
||||
return aiIntelligenceMockState.patrolOpenWorkCount;
|
||||
},
|
||||
vi.mock('@/stores/patrolAttention', () => ({
|
||||
patrolAttentionStore: {
|
||||
summary: () => ({
|
||||
activeCount: patrolAttentionMockState.activeCount,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('AppLayout navigation icons', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/settings/infrastructure');
|
||||
resetPrimaryNavigationRouteMemory();
|
||||
aiIntelligenceMockState.patrolOpenWorkCount = 0;
|
||||
patrolAttentionMockState.activeCount = 0;
|
||||
aiChatStore.close();
|
||||
aiChatStore.setEnabled(true);
|
||||
});
|
||||
@@ -170,8 +170,8 @@ describe('AppLayout navigation icons', () => {
|
||||
expect(container).toHaveTextContent('Infrastructure body');
|
||||
});
|
||||
|
||||
it('surfaces Patrol open work as a count without renaming Patrol', () => {
|
||||
aiIntelligenceMockState.patrolOpenWorkCount = 2;
|
||||
it('surfaces canonical Patrol attention as a count without renaming Patrol', () => {
|
||||
patrolAttentionMockState.activeCount = 2;
|
||||
renderLayout();
|
||||
|
||||
const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' });
|
||||
@@ -179,7 +179,7 @@ describe('AppLayout navigation icons', () => {
|
||||
expect(systemGroup).toBeTruthy();
|
||||
|
||||
const desktopPatrolTab = within(systemGroup as HTMLElement).getByRole('tab', {
|
||||
name: 'Patrol: 2 open work items',
|
||||
name: 'Patrol: 2 active attention items',
|
||||
});
|
||||
expect(desktopPatrolTab).toHaveTextContent('Patrol');
|
||||
expect(desktopPatrolTab).toHaveTextContent('2');
|
||||
@@ -187,7 +187,7 @@ describe('AppLayout navigation icons', () => {
|
||||
|
||||
const mobileTablist = screen.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
const mobilePatrolTab = within(mobileTablist).getByRole('button', {
|
||||
name: 'Patrol: 2 open work items',
|
||||
name: 'Patrol: 2 active attention items',
|
||||
});
|
||||
expect(mobilePatrolTab).toHaveTextContent('Patrol');
|
||||
expect(mobilePatrolTab).toHaveTextContent('2');
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetchJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
getPatrolAttention,
|
||||
getPatrolAttentionDetail,
|
||||
getPatrolAttentionSummary,
|
||||
} from '@/api/patrolAttention';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
describe('Patrol attention API', () => {
|
||||
const fetchMock = vi.mocked(apiFetchJSON);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('uses one bounded typed list query', async () => {
|
||||
await getPatrolAttention('stale_unknown', 2, 40);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/ai/patrol/attention?filter=stale_unknown&page=2&limit=40',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the canonical summary and encoded stable item detail routes', async () => {
|
||||
await getPatrolAttentionSummary();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/summary');
|
||||
|
||||
await getPatrolAttentionDetail('record/one');
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/record%2Fone');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type {
|
||||
EvidenceCompleteness,
|
||||
EvidenceEnvelope,
|
||||
EvidenceFreshness,
|
||||
LifecycleTransition,
|
||||
OperationalRecord,
|
||||
OperationalSeverity,
|
||||
OperationalState,
|
||||
} from '@/types/operationalTrust';
|
||||
import type { ProtectionPosture } from '@/types/recovery';
|
||||
|
||||
export type AttentionFilter =
|
||||
| 'active'
|
||||
| 'open'
|
||||
| 'acknowledged'
|
||||
| 'suppressed'
|
||||
| 'stale_unknown'
|
||||
| 'resolved'
|
||||
| 'all';
|
||||
|
||||
export type AttentionVerificationState =
|
||||
| 'not_available'
|
||||
| 'pending'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'unknown';
|
||||
|
||||
export interface AttentionActionOffer {
|
||||
capability: string;
|
||||
label: string;
|
||||
risk: string;
|
||||
requiresApproval: boolean;
|
||||
}
|
||||
|
||||
export interface AttentionResource {
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export interface AttentionItem {
|
||||
id: string;
|
||||
operationalRecordId: string;
|
||||
subjectResourceId: string;
|
||||
subjectResourceName: string;
|
||||
subjectResourceType?: string;
|
||||
title: string;
|
||||
plainLanguageSummary: string;
|
||||
severity: OperationalSeverity;
|
||||
state: OperationalState;
|
||||
firstObservedAt: string;
|
||||
lastObservedAt: string;
|
||||
evidenceFreshness: EvidenceFreshness;
|
||||
evidenceCompleteness: EvidenceCompleteness;
|
||||
impact?: string;
|
||||
protectionPosture?: ProtectionPosture;
|
||||
relatedResources: AttentionResource[];
|
||||
recommendedNextStep?: string;
|
||||
availableActions: AttentionActionOffer[];
|
||||
verificationState: AttentionVerificationState;
|
||||
}
|
||||
|
||||
export interface AttentionItemDetail {
|
||||
item: AttentionItem;
|
||||
operationalRecord: OperationalRecord;
|
||||
timeline: LifecycleTransition[];
|
||||
evidence: EvidenceEnvelope[];
|
||||
}
|
||||
|
||||
export interface AttentionSummary {
|
||||
activeCount: number;
|
||||
openCount: number;
|
||||
acknowledgedCount: number;
|
||||
suppressedCount: number;
|
||||
uncertainCount: number;
|
||||
resolvedCount: number;
|
||||
calm: boolean;
|
||||
coverageState: 'current' | 'partial' | 'unavailable';
|
||||
evaluatedAt: string;
|
||||
}
|
||||
|
||||
export interface AttentionListResponse {
|
||||
data: AttentionItem[];
|
||||
summary: AttentionSummary;
|
||||
meta: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPatrolAttention(
|
||||
filter: AttentionFilter = 'active',
|
||||
page = 1,
|
||||
limit = 50,
|
||||
): Promise<AttentionListResponse> {
|
||||
const search = new URLSearchParams({
|
||||
filter,
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
});
|
||||
return apiFetchJSON<AttentionListResponse>(`/api/ai/patrol/attention?${search.toString()}`);
|
||||
}
|
||||
|
||||
export async function getPatrolAttentionSummary(): Promise<AttentionSummary> {
|
||||
return apiFetchJSON<AttentionSummary>('/api/ai/patrol/attention/summary');
|
||||
}
|
||||
|
||||
export async function getPatrolAttentionDetail(itemId: string): Promise<AttentionItemDetail> {
|
||||
return apiFetchJSON<AttentionItemDetail>(
|
||||
`/api/ai/patrol/attention/${encodeURIComponent(itemId)}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import { useLocation } from '@solidjs/router';
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
untrack,
|
||||
} from 'solid-js';
|
||||
import AlertTriangleIcon from 'lucide-solid/icons/triangle-alert';
|
||||
import CheckCircleIcon from 'lucide-solid/icons/circle-check';
|
||||
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
|
||||
import ClockIcon from 'lucide-solid/icons/clock';
|
||||
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
|
||||
import RefreshIcon from 'lucide-solid/icons/refresh-cw';
|
||||
import SparklesIcon from 'lucide-solid/icons/sparkles';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import type {
|
||||
AttentionFilter,
|
||||
AttentionItem,
|
||||
AttentionItemDetail,
|
||||
} from '@/api/patrolAttention';
|
||||
import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { MetadataBadge, type MetadataBadgeTone } from '@/components/shared/MetadataBadge';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||
import {
|
||||
buildPatrolAttentionPath,
|
||||
buildStandalonePath,
|
||||
buildWorkloadsRouteSearch,
|
||||
parsePatrolAttentionItemId,
|
||||
} from '@/routing/resourceLinks';
|
||||
import type { EvidenceEnvelope } from '@/types/operationalTrust';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
|
||||
const PRIMARY_EVIDENCE_LIMIT = 3;
|
||||
|
||||
const FILTERS: Array<{ id: AttentionFilter; label: string }> = [
|
||||
{ id: 'active', label: 'Active' },
|
||||
{ id: 'open', label: 'Open' },
|
||||
{ id: 'acknowledged', label: 'Acknowledged' },
|
||||
{ id: 'suppressed', label: 'Suppressed' },
|
||||
{ id: 'stale_unknown', label: 'Stale or unknown' },
|
||||
{ id: 'resolved', label: 'Recent resolved' },
|
||||
];
|
||||
|
||||
export function PatrolAttentionWorkbench() {
|
||||
const location = useLocation();
|
||||
const [selectedItemId, setSelectedItemId] = createSignal('');
|
||||
const itemButtons = new Map<string, HTMLButtonElement>();
|
||||
let detailPanel: HTMLDivElement | undefined;
|
||||
|
||||
const selectedDetail = () => patrolAttentionStore.selectedDetail();
|
||||
const summary = () => patrolAttentionStore.summary();
|
||||
const filterCount = (filter: AttentionFilter): number | undefined => {
|
||||
const value = summary();
|
||||
if (!value) return undefined;
|
||||
switch (filter) {
|
||||
case 'active':
|
||||
return value.activeCount;
|
||||
case 'open':
|
||||
return value.openCount;
|
||||
case 'acknowledged':
|
||||
return value.acknowledgedCount;
|
||||
case 'suppressed':
|
||||
return value.suppressedCount;
|
||||
case 'stale_unknown':
|
||||
return value.uncertainCount;
|
||||
case 'resolved':
|
||||
return value.resolvedCount;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCurrentFilter = () => patrolAttentionStore.load(patrolAttentionStore.filter());
|
||||
const scrollDetailIntoView = () => {
|
||||
queueMicrotask(() => {
|
||||
if (window.matchMedia?.('(max-width: 1023px)').matches) {
|
||||
detailPanel?.scrollIntoView?.({ block: 'start' });
|
||||
}
|
||||
});
|
||||
};
|
||||
const selectItem = (itemId: string) => {
|
||||
setSelectedItemId(itemId);
|
||||
replaceAttentionLocation(itemId);
|
||||
void patrolAttentionStore.select(itemId);
|
||||
scrollDetailIntoView();
|
||||
};
|
||||
const closeDetail = () => {
|
||||
const previous = selectedItemId();
|
||||
setSelectedItemId('');
|
||||
replaceAttentionLocation('');
|
||||
void patrolAttentionStore.select(null);
|
||||
queueMicrotask(() => itemButtons.get(previous)?.focus());
|
||||
};
|
||||
const changeFilter = (filter: AttentionFilter) => {
|
||||
closeDetail();
|
||||
void patrolAttentionStore.load(filter);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void patrolAttentionStore.load('active');
|
||||
const interval = window.setInterval(loadCurrentFilter, 30000);
|
||||
onCleanup(() => window.clearInterval(interval));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const deepLinkedItem = parsePatrolAttentionItemId(location.search);
|
||||
const currentItem = untrack(selectedItemId);
|
||||
if (deepLinkedItem && deepLinkedItem !== currentItem) {
|
||||
setSelectedItemId(deepLinkedItem);
|
||||
void patrolAttentionStore.select(deepLinkedItem);
|
||||
scrollDetailIntoView();
|
||||
} else if (!deepLinkedItem && currentItem) {
|
||||
setSelectedItemId('');
|
||||
void patrolAttentionStore.select(null);
|
||||
}
|
||||
});
|
||||
|
||||
const activeCountLabel = createMemo(() => {
|
||||
const count = summary()?.activeCount;
|
||||
if (count === undefined) return 'Attention count unavailable';
|
||||
return `${count} active attention ${count === 1 ? 'item' : 'items'}`;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="patrol-attention-heading"
|
||||
class="overflow-hidden rounded-lg border border-border bg-surface shadow-sm"
|
||||
>
|
||||
<div class="border-b border-border px-4 py-4 sm:px-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 id="patrol-attention-heading" class="text-base font-semibold text-base-content">
|
||||
Needs attention
|
||||
</h2>
|
||||
<Show when={summary()}>
|
||||
<MetadataBadge
|
||||
tone={(summary()?.activeCount ?? 0) > 0 ? 'warning' : 'success'}
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
aria-label={activeCountLabel()}
|
||||
>
|
||||
{summary()?.activeCount ?? 0}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
|
||||
Current operational issues, ordered by urgency, affected resources, protection
|
||||
concern, evidence quality, and age.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
onClick={() => void loadCurrentFilter()}
|
||||
disabled={patrolAttentionStore.loading()}
|
||||
aria-label="Refresh Patrol attention"
|
||||
>
|
||||
<RefreshIcon
|
||||
class={`h-4 w-4 ${patrolAttentionStore.loading() ? 'motion-safe:animate-spin' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2 overflow-x-auto pb-1" role="group" aria-label="Attention filter">
|
||||
<For each={FILTERS}>
|
||||
{(option) => {
|
||||
const selected = () => patrolAttentionStore.filter() === option.id;
|
||||
const count = () => filterCount(option.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
|
||||
selected()
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200'
|
||||
: 'border-border bg-surface text-muted hover:bg-surface-hover hover:text-base-content'
|
||||
}`}
|
||||
aria-pressed={selected()}
|
||||
onClick={() => changeFilter(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
<Show when={count() !== undefined}>
|
||||
<span class="tabular-nums text-[10px]">{count()}</span>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`grid min-w-0 ${selectedItemId() ? 'lg:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.2fr)]' : ''}`}
|
||||
>
|
||||
<div
|
||||
class={`min-w-0 ${selectedItemId() ? 'border-b border-border lg:border-b-0 lg:border-r' : ''}`}
|
||||
>
|
||||
<AttentionList
|
||||
selectedItemId={selectedItemId()}
|
||||
itemButtons={itemButtons}
|
||||
onSelect={selectItem}
|
||||
/>
|
||||
</div>
|
||||
<Show when={selectedItemId()}>
|
||||
<div ref={detailPanel} class="order-first min-w-0 scroll-mt-4 lg:order-none">
|
||||
<AttentionDetail
|
||||
detail={selectedDetail()}
|
||||
loading={patrolAttentionStore.detailLoading()}
|
||||
onClose={closeDetail}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function replaceAttentionLocation(itemId: string) {
|
||||
const nextPath = buildPatrolAttentionPath(itemId);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.replaceState(window.history.state, '', nextPath);
|
||||
}
|
||||
}
|
||||
|
||||
function AttentionList(props: {
|
||||
selectedItemId: string;
|
||||
itemButtons: Map<string, HTMLButtonElement>;
|
||||
onSelect: (itemId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div aria-live="polite">
|
||||
<Show when={patrolAttentionStore.error()}>
|
||||
{(message) => (
|
||||
<div class="m-4 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-200">
|
||||
<AlertTriangleIcon class="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Patrol attention is unavailable</h3>
|
||||
<p class="mt-1 text-xs leading-5">{message()}</p>
|
||||
<p class="mt-1 text-xs leading-5">
|
||||
Pulse has not inferred a calm or healthy state from this failure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!patrolAttentionStore.loading() || patrolAttentionStore.items().length > 0}
|
||||
fallback={
|
||||
<div class="flex min-h-40 items-center justify-center gap-2 text-sm text-muted">
|
||||
<LoadingSpinner size="sm" />
|
||||
Loading current attention
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={patrolAttentionStore.items().length > 0}
|
||||
fallback={<AttentionEmptyState />}
|
||||
>
|
||||
<ul class="divide-y divide-border" aria-label="Patrol attention items">
|
||||
<For each={patrolAttentionStore.items()}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<button
|
||||
ref={(element) => props.itemButtons.set(item.id, element)}
|
||||
type="button"
|
||||
class={`w-full px-4 py-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5 ${
|
||||
props.selectedItemId === item.id
|
||||
? 'bg-blue-50/80 dark:bg-blue-950/30'
|
||||
: 'hover:bg-surface-hover'
|
||||
}`}
|
||||
aria-pressed={props.selectedItemId === item.id}
|
||||
aria-label={`Open ${item.title}`}
|
||||
onClick={() => props.onSelect(item.id)}
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<SeverityMarker item={item} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold text-base-content">
|
||||
{item.title}
|
||||
</span>
|
||||
<StateBadge item={item} />
|
||||
</div>
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">
|
||||
{item.plainLanguageSummary}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||
<span class="truncate font-medium text-base-content">
|
||||
{item.subjectResourceName}
|
||||
</span>
|
||||
<EvidenceLabel item={item} />
|
||||
<ProtectionLabel item={item} />
|
||||
<span>{formatRelativeTime(item.firstObservedAt, { compact: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon class="mt-1 h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionEmptyState() {
|
||||
const summary = () => patrolAttentionStore.summary();
|
||||
const activeFilter = () => patrolAttentionStore.filter() === 'active';
|
||||
const trustworthyCalm = () =>
|
||||
activeFilter() &&
|
||||
summary()?.calm === true &&
|
||||
summary()?.coverageState === 'current' &&
|
||||
!patrolAttentionStore.error();
|
||||
|
||||
return (
|
||||
<div class="flex min-h-52 flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<Show
|
||||
when={trustworthyCalm()}
|
||||
fallback={
|
||||
<>
|
||||
<ClockIcon class="h-8 w-8 text-muted" aria-hidden="true" />
|
||||
<h3 class="mt-3 text-sm font-semibold text-base-content">
|
||||
No items in this view
|
||||
</h3>
|
||||
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
|
||||
{summary()?.coverageState === 'partial'
|
||||
? 'The lifecycle queue is empty, but protection context is incomplete. Pulse is not treating that gap as proof of health.'
|
||||
: 'Choose another lifecycle filter or refresh the current evaluation.'}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CheckCircleIcon class="h-9 w-9 text-emerald-500" aria-hidden="true" />
|
||||
<h3 class="mt-3 text-sm font-semibold text-base-content">
|
||||
Nothing needs your attention
|
||||
</h3>
|
||||
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
|
||||
The current operational lifecycle evaluation has no active items.
|
||||
<Show when={summary()?.evaluatedAt}>
|
||||
{(evaluatedAt) => ` Checked ${formatRelativeTime(evaluatedAt(), { compact: true })}.`}
|
||||
</Show>
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionDetail(props: {
|
||||
detail: AttentionItemDetail | null;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const detail = () => props.detail;
|
||||
const item = () => detail()?.item;
|
||||
const orderedEvidence = createMemo(() =>
|
||||
[...(detail()?.evidence ?? [])].sort(
|
||||
(left, right) =>
|
||||
new Date(right.observedAt).getTime() - new Date(left.observedAt).getTime(),
|
||||
),
|
||||
);
|
||||
const primaryEvidence = createMemo(() => orderedEvidence().slice(0, PRIMARY_EVIDENCE_LIMIT));
|
||||
const olderEvidence = createMemo(() => orderedEvidence().slice(PRIMARY_EVIDENCE_LIMIT));
|
||||
const resourceHref = () => {
|
||||
const value = item();
|
||||
if (!value) return buildStandalonePath('machines');
|
||||
return `${buildStandalonePath('machines')}${buildWorkloadsRouteSearch({
|
||||
resource: value.subjectResourceId,
|
||||
})}`;
|
||||
};
|
||||
const openAssistant = () => {
|
||||
const value = detail();
|
||||
if (!value) return;
|
||||
const current = value.item;
|
||||
const evidence = value.evidence.map(
|
||||
(entry) =>
|
||||
`${entry.source.provider}/${entry.source.collector}: ${entry.completeness}, ${entry.confidence}, observed ${entry.observedAt}`,
|
||||
);
|
||||
aiChatStore.open({
|
||||
targetType: current.subjectResourceType || 'resource',
|
||||
targetId: current.subjectResourceId,
|
||||
autonomousMode: false,
|
||||
handoffResources: [
|
||||
{
|
||||
id: current.subjectResourceId,
|
||||
name: current.subjectResourceName,
|
||||
type: current.subjectResourceType,
|
||||
},
|
||||
],
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Selected attention item',
|
||||
subject: current.title,
|
||||
statusLabel: `${formatLabel(current.severity)} · ${formatLabel(current.state)}`,
|
||||
detailLines: [
|
||||
current.plainLanguageSummary,
|
||||
current.impact ? `Impact: ${current.impact}` : undefined,
|
||||
current.recommendedNextStep
|
||||
? `Next step: ${current.recommendedNextStep}`
|
||||
: undefined,
|
||||
].filter((line): line is string => Boolean(line)),
|
||||
evidence: evidence.slice(0, 5),
|
||||
actionLabel: `Explain ${current.title}`,
|
||||
safetyNote:
|
||||
'This context explains evidence only. It does not grant approval or action authority.',
|
||||
},
|
||||
handoffContext: [
|
||||
`Attention Item: ${current.id}`,
|
||||
`Operational Record: ${current.operationalRecordId}`,
|
||||
`Resource: ${current.subjectResourceName} (${current.subjectResourceId})`,
|
||||
`State: ${current.state}`,
|
||||
`Severity: ${current.severity}`,
|
||||
`Summary: ${current.plainLanguageSummary}`,
|
||||
`Evidence: ${current.evidenceFreshness}/${current.evidenceCompleteness}`,
|
||||
current.impact ? `Impact: ${current.impact}` : '',
|
||||
current.recommendedNextStep ? `Recommended Next Step: ${current.recommendedNextStep}` : '',
|
||||
'Authority Boundary: Explain selected evidence only. Do not infer capabilities or bypass approval.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
context: {
|
||||
attentionItemId: current.id,
|
||||
operationalRecordId: current.operationalRecordId,
|
||||
lifecycleState: current.state,
|
||||
evidenceFreshness: current.evidenceFreshness,
|
||||
evidenceCompleteness: current.evidenceCompleteness,
|
||||
protectionPosture: current.protectionPosture,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
class="min-w-0 bg-surface"
|
||||
aria-labelledby="attention-detail-title"
|
||||
aria-busy={props.loading}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 border-b border-border px-4 py-3 sm:px-5">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||
Attention detail
|
||||
</p>
|
||||
<h3 id="attention-detail-title" class="mt-1 text-sm font-semibold text-base-content">
|
||||
{item()?.title ?? 'Loading attention item'}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-muted hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Close attention detail"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<XIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!props.loading && detail()}
|
||||
fallback={
|
||||
<div class="flex min-h-48 items-center justify-center gap-2 text-sm text-muted">
|
||||
<LoadingSpinner size="sm" />
|
||||
Loading evidence
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(loaded) => (
|
||||
<div class="space-y-5 px-4 py-4 sm:px-5">
|
||||
<section>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StateBadge item={loaded().item} />
|
||||
<EvidenceLabel item={loaded().item} badge />
|
||||
<ProtectionLabel item={loaded().item} badge />
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-base-content">
|
||||
{loaded().item.plainLanguageSummary}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<DetailSection title="Affected resource">
|
||||
<p class="text-sm font-medium text-base-content">
|
||||
{loaded().item.subjectResourceName}
|
||||
</p>
|
||||
<p class="mt-1 break-all text-xs text-muted">
|
||||
{loaded().item.subjectResourceId}
|
||||
</p>
|
||||
<Show when={loaded().item.relatedResources.length > 0}>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{loaded().item.relatedResources.length} related{' '}
|
||||
{loaded().item.relatedResources.length === 1 ? 'resource' : 'resources'}
|
||||
</p>
|
||||
</Show>
|
||||
</DetailSection>
|
||||
|
||||
<Show when={loaded().item.impact || loaded().item.recommendedNextStep}>
|
||||
<DetailSection title="What to do">
|
||||
<Show when={loaded().item.impact}>
|
||||
{(impact) => <p class="text-xs leading-5 text-muted">Impact: {impact()}</p>}
|
||||
</Show>
|
||||
<Show when={loaded().item.recommendedNextStep}>
|
||||
{(nextStep) => (
|
||||
<p class="mt-2 text-sm font-medium leading-5 text-base-content">
|
||||
{nextStep()}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
</DetailSection>
|
||||
</Show>
|
||||
|
||||
<DetailSection title="Evidence">
|
||||
<Show
|
||||
when={orderedEvidence().length > 0}
|
||||
fallback={
|
||||
<p class="text-xs leading-5 text-amber-700 dark:text-amber-300">
|
||||
Evidence detail is unavailable. Pulse has not presented this as confirmed.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Show when={olderEvidence().length > 0}>
|
||||
<p class="mb-2 text-xs text-muted">
|
||||
Showing the latest {primaryEvidence().length} of {orderedEvidence().length}{' '}
|
||||
observations.
|
||||
</p>
|
||||
</Show>
|
||||
<ul class="space-y-2">
|
||||
<For each={primaryEvidence()}>
|
||||
{(evidence) => <EvidenceObservation evidence={evidence} />}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={olderEvidence().length > 0}>
|
||||
<details class="mt-2 rounded-md border border-border-subtle bg-surface-alt/30">
|
||||
<summary class="cursor-pointer px-3 py-2 text-xs font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500">
|
||||
Show {olderEvidence().length} older{' '}
|
||||
{olderEvidence().length === 1 ? 'observation' : 'observations'}
|
||||
</summary>
|
||||
<ul class="space-y-2 border-t border-border-subtle p-2">
|
||||
<For each={olderEvidence()}>
|
||||
{(evidence) => <EvidenceObservation evidence={evidence} />}
|
||||
</For>
|
||||
</ul>
|
||||
</details>
|
||||
</Show>
|
||||
</Show>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Protection">
|
||||
<Show
|
||||
when={loaded().item.protectionPosture}
|
||||
fallback={
|
||||
<p class="text-xs leading-5 text-muted">
|
||||
No current protection posture is attached to this resource. This means unknown,
|
||||
not unprotected.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{(posture) => (
|
||||
<>
|
||||
<p class="text-xs leading-5 text-muted">{posture().explanation}</p>
|
||||
<Show when={posture().providerStates.length > 0}>
|
||||
<ul class="mt-2 space-y-2">
|
||||
<For each={posture().providerStates}>
|
||||
{(provider) => (
|
||||
<li class="rounded-md border border-border-subtle px-3 py-2 text-xs">
|
||||
<span class="font-medium text-base-content">
|
||||
{formatProvider(provider.provider)}
|
||||
</span>
|
||||
<span class="text-muted">
|
||||
{' '}
|
||||
· {formatLabel(provider.jobState)} ·{' '}
|
||||
{formatLabel(provider.historyCompleteness)}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Timeline">
|
||||
<Show
|
||||
when={loaded().timeline.length > 0}
|
||||
fallback={
|
||||
<p class="text-xs leading-5 text-muted">
|
||||
No earlier lifecycle transitions are recorded. This is the first observed state.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ol class="space-y-3">
|
||||
<For each={loaded().timeline}>
|
||||
{(transition) => (
|
||||
<li class="border-l-2 border-border pl-3">
|
||||
<p class="text-xs font-medium text-base-content">
|
||||
{formatLabel(transition.from)} to {formatLabel(transition.to)}
|
||||
</p>
|
||||
<p class="mt-0.5 text-[11px] text-muted">
|
||||
{formatRelativeTime(transition.at, { compact: true })} ·{' '}
|
||||
{formatLabel(transition.cause)}
|
||||
</p>
|
||||
<Show when={transition.reason}>
|
||||
{(reason) => <p class="mt-1 text-xs leading-5 text-muted">{reason()}</p>}
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</Show>
|
||||
</DetailSection>
|
||||
|
||||
<div class="flex flex-wrap gap-2 border-t border-border pt-4">
|
||||
<ButtonLink href={resourceHref()} variant="secondary" size="sm" class="gap-1.5">
|
||||
<ExternalLinkIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Open resource
|
||||
</ButtonLink>
|
||||
<Show when={aiChatStore.enabled === true}>
|
||||
<Button variant="secondary" size="sm" class="gap-1.5" onClick={openAssistant}>
|
||||
<SparklesIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Explain with Assistant
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection(props: { title: string; children: import('solid-js').JSX.Element }) {
|
||||
return (
|
||||
<section>
|
||||
<h4 class="text-[11px] font-semibold uppercase tracking-wider text-muted">{props.title}</h4>
|
||||
<div class="mt-2">{props.children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceObservation(props: { evidence: EvidenceEnvelope }) {
|
||||
return (
|
||||
<li class="rounded-md border border-border-subtle bg-surface-alt/60 p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-semibold text-base-content">
|
||||
{formatProvider(props.evidence.source.provider)}
|
||||
</span>
|
||||
<MetadataBadge
|
||||
tone={props.evidence.completeness === 'complete' ? 'success' : 'warning'}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
>
|
||||
{formatLabel(props.evidence.completeness)}
|
||||
</MetadataBadge>
|
||||
<MetadataBadge
|
||||
tone={props.evidence.confidence === 'confirmed' ? 'info' : 'muted'}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
>
|
||||
{formatLabel(props.evidence.confidence)}
|
||||
</MetadataBadge>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{props.evidence.source.collector} · observed{' '}
|
||||
{formatRelativeTime(props.evidence.observedAt, { compact: true })}
|
||||
</p>
|
||||
<Show when={props.evidence.reason?.message}>
|
||||
{(reason) => <p class="mt-1 text-xs leading-5 text-muted">{reason()}</p>}
|
||||
</Show>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SeverityMarker(props: { item: AttentionItem }) {
|
||||
const classes = () => {
|
||||
switch (props.item.severity) {
|
||||
case 'critical':
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'bg-amber-500';
|
||||
case 'info':
|
||||
return 'bg-blue-500';
|
||||
default:
|
||||
return 'bg-slate-400';
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span
|
||||
class={`mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${classes()}`}
|
||||
title={`${formatLabel(props.item.severity)} severity`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StateBadge(props: { item: AttentionItem }) {
|
||||
return (
|
||||
<MetadataBadge
|
||||
tone={stateTone(props.item)}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
appearance="outline"
|
||||
>
|
||||
{formatLabel(props.item.state)}
|
||||
</MetadataBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceLabel(props: { item: AttentionItem; badge?: boolean }) {
|
||||
const label = () =>
|
||||
props.item.evidenceFreshness === 'fresh' &&
|
||||
props.item.evidenceCompleteness === 'complete'
|
||||
? 'Evidence current'
|
||||
: `${formatLabel(props.item.evidenceFreshness)} / ${formatLabel(props.item.evidenceCompleteness)}`;
|
||||
if (props.badge) {
|
||||
return (
|
||||
<MetadataBadge
|
||||
tone={
|
||||
props.item.evidenceFreshness === 'fresh' &&
|
||||
props.item.evidenceCompleteness === 'complete'
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
>
|
||||
{label()}
|
||||
</MetadataBadge>
|
||||
);
|
||||
}
|
||||
return <span>{label()}</span>;
|
||||
}
|
||||
|
||||
function ProtectionLabel(props: { item: AttentionItem; badge?: boolean }) {
|
||||
const state = () => props.item.protectionPosture?.state ?? 'unknown';
|
||||
const label = () => `Protection ${formatLabel(state())}`;
|
||||
if (props.badge) {
|
||||
return (
|
||||
<MetadataBadge
|
||||
tone={state() === 'protected' ? 'success' : state() === 'unknown' ? 'muted' : 'warning'}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
>
|
||||
{label()}
|
||||
</MetadataBadge>
|
||||
);
|
||||
}
|
||||
return <span>{label()}</span>;
|
||||
}
|
||||
|
||||
function stateTone(item: AttentionItem): MetadataBadgeTone {
|
||||
switch (item.state) {
|
||||
case 'resolved':
|
||||
return 'success';
|
||||
case 'acknowledged':
|
||||
return 'info';
|
||||
case 'suppressed':
|
||||
return 'muted';
|
||||
case 'stale':
|
||||
case 'unknown':
|
||||
return 'warning';
|
||||
default:
|
||||
return item.severity === 'critical' ? 'danger' : 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
function formatLabel(value: string): string {
|
||||
const normalized = value.trim().replace(/[_-]+/g, ' ');
|
||||
return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : 'Unknown';
|
||||
}
|
||||
|
||||
function formatProvider(value: string): string {
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case 'pbs':
|
||||
return 'Proxmox Backup Server';
|
||||
case 'pve':
|
||||
return 'Proxmox VE';
|
||||
default:
|
||||
return formatLabel(value);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { usePatrolIntelligenceState } from './usePatrolIntelligenceState';
|
||||
import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader';
|
||||
import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners';
|
||||
import { PatrolIntelligenceWorkspace } from './PatrolIntelligenceWorkspace';
|
||||
import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench';
|
||||
|
||||
export function PatrolIntelligenceSurface() {
|
||||
const state = usePatrolIntelligenceState();
|
||||
@@ -10,12 +11,18 @@ export function PatrolIntelligenceSurface() {
|
||||
<div class="space-y-6">
|
||||
<PatrolIntelligenceHeader state={state} />
|
||||
<PatrolIntelligenceBanners state={state} />
|
||||
<PatrolAttentionWorkbench />
|
||||
|
||||
<div
|
||||
class={`space-y-4 transition-opacity ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
<PatrolIntelligenceWorkspace state={state} />
|
||||
</div>
|
||||
<details class="rounded-lg border border-border bg-surface">
|
||||
<summary class="cursor-pointer px-4 py-3 text-sm font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5">
|
||||
Patrol checks, investigations, and run history
|
||||
</summary>
|
||||
<div
|
||||
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
<PatrolIntelligenceWorkspace state={state} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
AttentionItem,
|
||||
AttentionItemDetail,
|
||||
AttentionListResponse,
|
||||
AttentionSummary,
|
||||
} from '@/api/patrolAttention';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getList: vi.fn(),
|
||||
getDetail: vi.fn(),
|
||||
getSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/patrolAttention', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/api/patrolAttention')>();
|
||||
return {
|
||||
...original,
|
||||
getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args),
|
||||
getPatrolAttentionDetail: (...args: unknown[]) => apiMocks.getDetail(...args),
|
||||
getPatrolAttentionSummary: (...args: unknown[]) => apiMocks.getSummary(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { PatrolAttentionWorkbench } from '../PatrolAttentionWorkbench';
|
||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||
|
||||
const evaluatedAt = '2026-07-19T08:00:00Z';
|
||||
|
||||
const summary = (overrides: Partial<AttentionSummary> = {}): AttentionSummary => ({
|
||||
activeCount: 0,
|
||||
openCount: 0,
|
||||
acknowledgedCount: 0,
|
||||
suppressedCount: 0,
|
||||
uncertainCount: 0,
|
||||
resolvedCount: 0,
|
||||
calm: true,
|
||||
coverageState: 'current',
|
||||
evaluatedAt,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const item = (overrides: Partial<AttentionItem> = {}): AttentionItem => ({
|
||||
id: 'record-1',
|
||||
operationalRecordId: 'record-1',
|
||||
subjectResourceId: 'pve:vm:101',
|
||||
subjectResourceName: 'Database VM',
|
||||
subjectResourceType: 'vm',
|
||||
title: 'Disk pressure on Database VM',
|
||||
plainLanguageSummary: 'The database disk is nearly full.',
|
||||
severity: 'critical',
|
||||
state: 'open',
|
||||
firstObservedAt: '2026-07-19T07:00:00Z',
|
||||
lastObservedAt: evaluatedAt,
|
||||
evidenceFreshness: 'fresh',
|
||||
evidenceCompleteness: 'complete',
|
||||
impact: 'Writes may fail.',
|
||||
protectionPosture: {
|
||||
subjectResourceId: 'pve:vm:101',
|
||||
state: 'attention',
|
||||
freshness: 'current',
|
||||
verification: 'unverified',
|
||||
coverage: 'complete',
|
||||
providerStates: [],
|
||||
repositoryResourceIds: [],
|
||||
evidenceIds: [],
|
||||
explanation: 'The latest backup has not been verified.',
|
||||
evaluatedAt,
|
||||
},
|
||||
relatedResources: [{ resourceId: 'pbs:datastore:main' }],
|
||||
recommendedNextStep: 'Free disk space or expand the volume.',
|
||||
availableActions: [],
|
||||
verificationState: 'not_available',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const listResponse = (
|
||||
items: AttentionItem[],
|
||||
responseSummary: AttentionSummary,
|
||||
): AttentionListResponse => ({
|
||||
data: items,
|
||||
summary: responseSummary,
|
||||
meta: { page: 1, limit: 50, total: items.length, totalPages: items.length ? 1 : 0 },
|
||||
});
|
||||
|
||||
const detail = (value: AttentionItem): AttentionItemDetail => ({
|
||||
item: value,
|
||||
operationalRecord: {
|
||||
id: value.operationalRecordId,
|
||||
canonicalSpecId: 'disk-pressure',
|
||||
subjectResourceId: value.subjectResourceId,
|
||||
state: value.state,
|
||||
severity: value.severity,
|
||||
firstObservedAt: value.firstObservedAt,
|
||||
lastObservedAt: value.lastObservedAt,
|
||||
stateChangedAt: value.lastObservedAt,
|
||||
evidenceIds: ['evidence-1'],
|
||||
causeKey: 'disk-pressure:pve:vm:101',
|
||||
relatedResourceIds: value.relatedResources.map((resource) => resource.resourceId),
|
||||
impactSummary: value.impact,
|
||||
recommendedNextStep: value.recommendedNextStep,
|
||||
},
|
||||
timeline: [
|
||||
{
|
||||
id: 'transition-1',
|
||||
operationalRecordId: value.operationalRecordId,
|
||||
from: 'observing',
|
||||
to: 'open',
|
||||
at: value.firstObservedAt,
|
||||
cause: 'detector_decision',
|
||||
causeKey: 'disk-pressure:pve:vm:101',
|
||||
evidenceIds: ['evidence-1'],
|
||||
},
|
||||
],
|
||||
evidence: [
|
||||
{
|
||||
id: 'evidence-1',
|
||||
source: { provider: 'pve', collector: 'resource-monitor' },
|
||||
subject: { resourceId: value.subjectResourceId },
|
||||
observedAt: value.lastObservedAt,
|
||||
ingestedAt: value.lastObservedAt,
|
||||
completeness: 'complete',
|
||||
confidence: 'confirmed',
|
||||
permissions: 'sufficient',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('PatrolAttentionWorkbench', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/patrol');
|
||||
patrolAttentionStore.clear();
|
||||
apiMocks.getList.mockReset();
|
||||
apiMocks.getDetail.mockReset();
|
||||
apiMocks.getSummary.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
patrolAttentionStore.clear();
|
||||
});
|
||||
|
||||
const renderWorkbench = () =>
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route path="/patrol" component={PatrolAttentionWorkbench} />
|
||||
</Router>
|
||||
));
|
||||
|
||||
it('renders a plain trustworthy calm state without a proof strip', async () => {
|
||||
const calm = summary();
|
||||
apiMocks.getList.mockResolvedValue(listResponse([], calm));
|
||||
renderWorkbench();
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Nothing needs your attention' }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText(/current operational lifecycle evaluation/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/trust score/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/auto-resolved/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens deepest typed evidence, protection, and timeline detail from one queue item', async () => {
|
||||
const active = item();
|
||||
apiMocks.getList.mockResolvedValue(
|
||||
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
|
||||
);
|
||||
apiMocks.getDetail.mockResolvedValue(detail(active));
|
||||
renderWorkbench();
|
||||
|
||||
const openButton = await screen.findByRole('button', {
|
||||
name: 'Open Disk pressure on Database VM',
|
||||
});
|
||||
fireEvent.click(openButton);
|
||||
|
||||
const detailRegion = await screen.findByRole('complementary', {
|
||||
name: 'Disk pressure on Database VM',
|
||||
});
|
||||
expect(within(detailRegion).getByText(/Impact: Writes may fail\./)).toBeInTheDocument();
|
||||
expect(within(detailRegion).getByText(/latest backup has not been verified/i))
|
||||
.toBeInTheDocument();
|
||||
expect(within(detailRegion).getByText('Proxmox VE')).toBeInTheDocument();
|
||||
expect(within(detailRegion).getByText('Observing to Open')).toBeInTheDocument();
|
||||
expect(window.location.search).toBe('?attention=record-1');
|
||||
});
|
||||
|
||||
it('keeps repeated evidence observations available without overwhelming the default detail', async () => {
|
||||
const active = item();
|
||||
const expandedDetail = detail(active);
|
||||
expandedDetail.evidence = Array.from({ length: 5 }, (_, index) => ({
|
||||
...expandedDetail.evidence[0],
|
||||
id: `evidence-${index + 1}`,
|
||||
observedAt: new Date(Date.parse(active.lastObservedAt) - index * 60_000).toISOString(),
|
||||
}));
|
||||
apiMocks.getList.mockResolvedValue(
|
||||
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
|
||||
);
|
||||
apiMocks.getDetail.mockResolvedValue(expandedDetail);
|
||||
renderWorkbench();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Open Disk pressure on Database VM',
|
||||
}),
|
||||
);
|
||||
|
||||
const detailRegion = await screen.findByRole('complementary', {
|
||||
name: 'Disk pressure on Database VM',
|
||||
});
|
||||
expect(
|
||||
within(detailRegion).getByText('Showing the latest 3 of 5 observations.'),
|
||||
).toBeInTheDocument();
|
||||
expect(within(detailRegion).getByText('Show 2 older observations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not turn partial coverage and an empty queue into a healthy claim', async () => {
|
||||
apiMocks.getList.mockResolvedValue(
|
||||
listResponse([], summary({ coverageState: 'partial', calm: true })),
|
||||
);
|
||||
renderWorkbench();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'No items in this view' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/protection context is incomplete/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Nothing needs your attention' }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1916,7 +1916,7 @@ describe('AIIntelligence entitlement gating', () => {
|
||||
expect(screen.queryByTestId('patrol-status-bar')).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Open work' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Active' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Active' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Resolved' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Details' })).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPatrolAttentionPath,
|
||||
API_TOKEN_CREATE_ANCHOR,
|
||||
API_TOKEN_PRESET_QUERY_PARAM,
|
||||
DOCKER_PATH,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
buildVmwarePath,
|
||||
buildWorkloadsRouteSearch,
|
||||
parseRecoveryLinkSearch,
|
||||
parsePatrolAttentionItemId,
|
||||
parsePatrolControlStarter,
|
||||
parsePatrolOperationsLoopStarter,
|
||||
parseStorageLinkSearch,
|
||||
@@ -415,4 +417,10 @@ describe('resource link routing contract', () => {
|
||||
expect(url.searchParams.get('range')).toBe('30');
|
||||
expect(parseRecoveryLinkSearch('?range=90')).toMatchObject({ range: '90' });
|
||||
});
|
||||
|
||||
it('deep-links one stable Patrol attention item without changing the route owner', () => {
|
||||
expect(buildPatrolAttentionPath('record/one')).toBe('/patrol?attention=record%2Fone');
|
||||
expect(buildPatrolAttentionPath('')).toBe('/patrol');
|
||||
expect(parsePatrolAttentionItemId('?attention=record%2Fone')).toBe('record/one');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export const VMWARE_PATH = '/vmware';
|
||||
export const VMWARE_DEFAULT_TAB = 'overview';
|
||||
export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway';
|
||||
export const PATROL_PATH = '/patrol';
|
||||
export const PATROL_ATTENTION_QUERY_PARAM = 'attention';
|
||||
export const ACTIONS_PATH = '/actions';
|
||||
export const PATROL_CONTROL_ANCHOR = 'patrol-control';
|
||||
export const PATROL_CONTROL_STARTER_QUERY_PARAM = 'patrolControlStarter';
|
||||
@@ -223,6 +224,18 @@ export const buildPatrolControlPath = (
|
||||
|
||||
export const buildPatrolOperationsLoopPath = buildPatrolControlPath;
|
||||
|
||||
export const buildPatrolAttentionPath = (attentionItemId: string): string => {
|
||||
const params = new URLSearchParams();
|
||||
const normalized = normalizeQueryValue(attentionItemId);
|
||||
if (normalized) {
|
||||
params.set(PATROL_ATTENTION_QUERY_PARAM, normalized);
|
||||
}
|
||||
return `${PATROL_PATH}${serializedRouteSearch(params)}`;
|
||||
};
|
||||
|
||||
export const parsePatrolAttentionItemId = (search: string): string =>
|
||||
normalizeQueryValue(new URLSearchParams(search).get(PATROL_ATTENTION_QUERY_PARAM));
|
||||
|
||||
export const PATROL_CONTROL_PATH_WITH_STARTER = buildPatrolControlPath({
|
||||
starter: PATROL_CONTROL_STARTER,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import {
|
||||
getPatrolAttention,
|
||||
getPatrolAttentionDetail,
|
||||
getPatrolAttentionSummary,
|
||||
type AttentionFilter,
|
||||
type AttentionItem,
|
||||
type AttentionItemDetail,
|
||||
type AttentionSummary,
|
||||
} from '@/api/patrolAttention';
|
||||
|
||||
const [summary, setSummary] = createSignal<AttentionSummary | null>(null);
|
||||
const [items, setItems] = createSignal<AttentionItem[]>([]);
|
||||
const [selectedDetail, setSelectedDetail] = createSignal<AttentionItemDetail | null>(null);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [detailLoading, setDetailLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [filter, setFilter] = createSignal<AttentionFilter>('active');
|
||||
|
||||
let listRequest = 0;
|
||||
let detailRequest = 0;
|
||||
|
||||
export const patrolAttentionStore = {
|
||||
summary,
|
||||
items,
|
||||
selectedDetail,
|
||||
loading,
|
||||
detailLoading,
|
||||
error,
|
||||
filter,
|
||||
|
||||
setFilter,
|
||||
|
||||
async loadSummary() {
|
||||
try {
|
||||
const value = await getPatrolAttentionSummary();
|
||||
setSummary(value);
|
||||
return value;
|
||||
} catch (cause) {
|
||||
setSummary(null);
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
|
||||
async load(nextFilter: AttentionFilter = filter()) {
|
||||
const request = ++listRequest;
|
||||
setFilter(nextFilter);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await getPatrolAttention(nextFilter);
|
||||
if (request !== listRequest) return;
|
||||
setItems(response.data);
|
||||
setSummary(response.summary);
|
||||
} catch (cause) {
|
||||
if (request !== listRequest) return;
|
||||
setItems([]);
|
||||
setSummary(null);
|
||||
setError(cause instanceof Error ? cause.message : 'Patrol attention is unavailable.');
|
||||
} finally {
|
||||
if (request === listRequest) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async select(itemId: string | null) {
|
||||
const request = ++detailRequest;
|
||||
if (!itemId) {
|
||||
setSelectedDetail(null);
|
||||
setDetailLoading(false);
|
||||
return;
|
||||
}
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const detail = await getPatrolAttentionDetail(itemId);
|
||||
if (request === detailRequest) {
|
||||
setSelectedDetail(detail);
|
||||
}
|
||||
} catch {
|
||||
if (request === detailRequest) {
|
||||
setSelectedDetail(null);
|
||||
}
|
||||
} finally {
|
||||
if (request === detailRequest) {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
listRequest++;
|
||||
detailRequest++;
|
||||
setSummary(null);
|
||||
setItems([]);
|
||||
setSelectedDetail(null);
|
||||
setLoading(false);
|
||||
setDetailLoading(false);
|
||||
setError(null);
|
||||
setFilter('active');
|
||||
},
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import { updateStore } from '@/stores/updates';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import { actionInboxStore } from '@/stores/actionInbox';
|
||||
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
|
||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||
import {
|
||||
applyThemeClass,
|
||||
computeIsDark,
|
||||
@@ -520,6 +521,7 @@ export const useAppRuntimeState = () => {
|
||||
void Promise.allSettled([
|
||||
aiIntelligenceStore.loadPatrolFindings(),
|
||||
aiIntelligenceStore.loadPendingApprovals(),
|
||||
patrolAttentionStore.loadSummary(),
|
||||
actionInboxStore.loadPendingActionCount(),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
recoverymodel "github.com/rcourtman/pulse-go-rewrite/internal/recovery/model"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultAttentionPageSize = 50
|
||||
MaxAttentionPageSize = 200
|
||||
)
|
||||
|
||||
type AttentionFilter string
|
||||
|
||||
const (
|
||||
AttentionFilterActive AttentionFilter = "active"
|
||||
AttentionFilterOpen AttentionFilter = "open"
|
||||
AttentionFilterAcknowledged AttentionFilter = "acknowledged"
|
||||
AttentionFilterSuppressed AttentionFilter = "suppressed"
|
||||
AttentionFilterUncertain AttentionFilter = "stale_unknown"
|
||||
AttentionFilterResolved AttentionFilter = "resolved"
|
||||
AttentionFilterAll AttentionFilter = "all"
|
||||
)
|
||||
|
||||
func (filter AttentionFilter) Valid() bool {
|
||||
switch filter {
|
||||
case AttentionFilterActive,
|
||||
AttentionFilterOpen,
|
||||
AttentionFilterAcknowledged,
|
||||
AttentionFilterSuppressed,
|
||||
AttentionFilterUncertain,
|
||||
AttentionFilterResolved,
|
||||
AttentionFilterAll:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type AttentionVerificationState string
|
||||
|
||||
const (
|
||||
AttentionVerificationNotAvailable AttentionVerificationState = "not_available"
|
||||
AttentionVerificationPending AttentionVerificationState = "pending"
|
||||
AttentionVerificationSucceeded AttentionVerificationState = "succeeded"
|
||||
AttentionVerificationFailed AttentionVerificationState = "failed"
|
||||
AttentionVerificationUnknown AttentionVerificationState = "unknown"
|
||||
)
|
||||
|
||||
type AttentionActionOffer struct {
|
||||
Capability string `json:"capability"`
|
||||
Label string `json:"label"`
|
||||
Risk string `json:"risk"`
|
||||
RequiresApproval bool `json:"requiresApproval"`
|
||||
}
|
||||
|
||||
type AttentionResource struct {
|
||||
ResourceID string `json:"resourceId"`
|
||||
}
|
||||
|
||||
type AttentionItem struct {
|
||||
ID string `json:"id"`
|
||||
OperationalRecordID string `json:"operationalRecordId"`
|
||||
SubjectResourceID string `json:"subjectResourceId"`
|
||||
SubjectResourceName string `json:"subjectResourceName"`
|
||||
SubjectResourceType string `json:"subjectResourceType,omitempty"`
|
||||
Title string `json:"title"`
|
||||
PlainLanguageSummary string `json:"plainLanguageSummary"`
|
||||
Severity operationaltrust.OperationalSeverity `json:"severity"`
|
||||
State operationaltrust.OperationalState `json:"state"`
|
||||
FirstObservedAt time.Time `json:"firstObservedAt"`
|
||||
LastObservedAt time.Time `json:"lastObservedAt"`
|
||||
EvidenceFreshness operationaltrust.EvidenceFreshness `json:"evidenceFreshness"`
|
||||
EvidenceCompleteness operationaltrust.EvidenceCompleteness `json:"evidenceCompleteness"`
|
||||
Impact string `json:"impact,omitempty"`
|
||||
ProtectionPosture *recoverymodel.ProtectionPosture `json:"protectionPosture,omitempty"`
|
||||
RelatedResources []AttentionResource `json:"relatedResources"`
|
||||
RecommendedNextStep string `json:"recommendedNextStep,omitempty"`
|
||||
AvailableActions []AttentionActionOffer `json:"availableActions"`
|
||||
VerificationState AttentionVerificationState `json:"verificationState"`
|
||||
}
|
||||
|
||||
type AttentionItemDetail struct {
|
||||
Item AttentionItem `json:"item"`
|
||||
OperationalRecord operationaltrust.OperationalRecord `json:"operationalRecord"`
|
||||
Timeline []operationaltrust.LifecycleTransition `json:"timeline"`
|
||||
Evidence []operationaltrust.EvidenceEnvelope `json:"evidence"`
|
||||
}
|
||||
|
||||
type AttentionSummary struct {
|
||||
ActiveCount int `json:"activeCount"`
|
||||
OpenCount int `json:"openCount"`
|
||||
AcknowledgedCount int `json:"acknowledgedCount"`
|
||||
SuppressedCount int `json:"suppressedCount"`
|
||||
UncertainCount int `json:"uncertainCount"`
|
||||
ResolvedCount int `json:"resolvedCount"`
|
||||
Calm bool `json:"calm"`
|
||||
CoverageState string `json:"coverageState"`
|
||||
EvaluatedAt time.Time `json:"evaluatedAt"`
|
||||
}
|
||||
|
||||
type AttentionProjection struct {
|
||||
Details []AttentionItemDetail `json:"details"`
|
||||
Summary AttentionSummary `json:"summary"`
|
||||
}
|
||||
|
||||
// ProjectAttentionItems is the single Patrol read-model projection over the
|
||||
// canonical alert lifecycle. It does not inspect loose alert metadata to
|
||||
// invent work and it never creates a second writable lifecycle.
|
||||
func ProjectAttentionItems(
|
||||
active []alerts.Alert,
|
||||
history []alerts.Alert,
|
||||
postures map[string]recoverymodel.ProtectionPosture,
|
||||
now time.Time,
|
||||
) AttentionProjection {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
now = now.UTC()
|
||||
|
||||
records := make(map[string]alerts.Alert, len(active)+len(history))
|
||||
for _, alert := range history {
|
||||
addAttentionAlert(records, alert, false)
|
||||
}
|
||||
for _, alert := range active {
|
||||
addAttentionAlert(records, alert, true)
|
||||
}
|
||||
|
||||
details := make([]AttentionItemDetail, 0, len(records))
|
||||
for _, alert := range records {
|
||||
detail, ok := projectAttentionAlert(alert, postures, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
details = append(details, detail)
|
||||
}
|
||||
sort.SliceStable(details, func(i, j int) bool {
|
||||
return attentionItemLess(details[i].Item, details[j].Item)
|
||||
})
|
||||
|
||||
return AttentionProjection{
|
||||
Details: details,
|
||||
Summary: summarizeAttention(details, now),
|
||||
}
|
||||
}
|
||||
|
||||
func addAttentionAlert(records map[string]alerts.Alert, alert alerts.Alert, active bool) {
|
||||
if alert.OperationalRecord == nil {
|
||||
return
|
||||
}
|
||||
if !active && alert.OperationalRecord.State != operationaltrust.OperationalResolved {
|
||||
return
|
||||
}
|
||||
recordID := strings.TrimSpace(alert.OperationalRecord.ID)
|
||||
if recordID == "" {
|
||||
return
|
||||
}
|
||||
existing, found := records[recordID]
|
||||
if !found || active || attentionAlertNewer(alert, existing) {
|
||||
records[recordID] = *alert.Clone()
|
||||
}
|
||||
}
|
||||
|
||||
func attentionAlertNewer(candidate, existing alerts.Alert) bool {
|
||||
if candidate.OperationalRecord == nil {
|
||||
return false
|
||||
}
|
||||
if existing.OperationalRecord == nil {
|
||||
return true
|
||||
}
|
||||
if !candidate.OperationalRecord.StateChangedAt.Equal(existing.OperationalRecord.StateChangedAt) {
|
||||
return candidate.OperationalRecord.StateChangedAt.After(existing.OperationalRecord.StateChangedAt)
|
||||
}
|
||||
return candidate.OperationalRecord.LastObservedAt.After(existing.OperationalRecord.LastObservedAt)
|
||||
}
|
||||
|
||||
func projectAttentionAlert(
|
||||
alert alerts.Alert,
|
||||
postures map[string]recoverymodel.ProtectionPosture,
|
||||
now time.Time,
|
||||
) (AttentionItemDetail, bool) {
|
||||
if alert.OperationalRecord == nil {
|
||||
return AttentionItemDetail{}, false
|
||||
}
|
||||
record := alert.OperationalRecord.Clone()
|
||||
if err := record.Validate(); err != nil {
|
||||
return AttentionItemDetail{}, false
|
||||
}
|
||||
|
||||
evidence := cloneAttentionEvidence(alert.Evidence)
|
||||
timeline := cloneAttentionTimeline(alert.Transitions)
|
||||
freshness, completeness := summarizeAttentionEvidence(record.State, evidence, now)
|
||||
resourceName := firstAttentionText(alert.ResourceName, alert.Instance, record.SubjectResourceID)
|
||||
title := attentionTitle(alert, resourceName)
|
||||
summary := firstAttentionText(alert.Message, record.ImpactSummary, title)
|
||||
|
||||
related := make([]AttentionResource, 0, len(record.RelatedResourceIDs))
|
||||
for _, resourceID := range canonicalAttentionIDs(record.RelatedResourceIDs) {
|
||||
related = append(related, AttentionResource{ResourceID: resourceID})
|
||||
}
|
||||
|
||||
var posture *recoverymodel.ProtectionPosture
|
||||
if candidate, found := postures[record.SubjectResourceID]; found {
|
||||
value := candidate.Clone()
|
||||
posture = &value
|
||||
}
|
||||
|
||||
item := AttentionItem{
|
||||
ID: record.ID,
|
||||
OperationalRecordID: record.ID,
|
||||
SubjectResourceID: record.SubjectResourceID,
|
||||
SubjectResourceName: resourceName,
|
||||
SubjectResourceType: attentionResourceType(alert),
|
||||
Title: title,
|
||||
PlainLanguageSummary: summary,
|
||||
Severity: record.Severity,
|
||||
State: record.State,
|
||||
FirstObservedAt: record.FirstObservedAt,
|
||||
LastObservedAt: record.LastObservedAt,
|
||||
EvidenceFreshness: freshness,
|
||||
EvidenceCompleteness: completeness,
|
||||
Impact: record.ImpactSummary,
|
||||
ProtectionPosture: posture,
|
||||
RelatedResources: related,
|
||||
RecommendedNextStep: record.RecommendedNextStep,
|
||||
AvailableActions: []AttentionActionOffer{},
|
||||
VerificationState: AttentionVerificationNotAvailable,
|
||||
}
|
||||
return AttentionItemDetail{
|
||||
Item: item,
|
||||
OperationalRecord: record,
|
||||
Timeline: timeline,
|
||||
Evidence: evidence,
|
||||
}, true
|
||||
}
|
||||
|
||||
func summarizeAttentionEvidence(
|
||||
state operationaltrust.OperationalState,
|
||||
evidence []operationaltrust.EvidenceEnvelope,
|
||||
now time.Time,
|
||||
) (operationaltrust.EvidenceFreshness, operationaltrust.EvidenceCompleteness) {
|
||||
if state == operationaltrust.OperationalStale {
|
||||
return operationaltrust.EvidenceStale, worstAttentionCompleteness(evidence)
|
||||
}
|
||||
if state == operationaltrust.OperationalUnknown {
|
||||
return operationaltrust.EvidenceFreshnessUnknown, worstAttentionCompleteness(evidence)
|
||||
}
|
||||
if len(evidence) == 0 {
|
||||
return operationaltrust.EvidenceFreshnessUnknown, operationaltrust.EvidenceUnavailable
|
||||
}
|
||||
|
||||
freshness := operationaltrust.EvidenceFresh
|
||||
completeness := operationaltrust.EvidenceComplete
|
||||
for _, envelope := range evidence {
|
||||
switch envelope.FreshnessAt(now) {
|
||||
case operationaltrust.EvidenceStale:
|
||||
freshness = operationaltrust.EvidenceStale
|
||||
case operationaltrust.EvidenceFreshnessUnknown:
|
||||
if freshness != operationaltrust.EvidenceStale {
|
||||
freshness = operationaltrust.EvidenceFreshnessUnknown
|
||||
}
|
||||
}
|
||||
switch envelope.Completeness {
|
||||
case operationaltrust.EvidenceUnavailable:
|
||||
completeness = operationaltrust.EvidenceUnavailable
|
||||
case operationaltrust.EvidencePartial:
|
||||
if completeness != operationaltrust.EvidenceUnavailable {
|
||||
completeness = operationaltrust.EvidencePartial
|
||||
}
|
||||
}
|
||||
}
|
||||
return freshness, completeness
|
||||
}
|
||||
|
||||
func worstAttentionCompleteness(
|
||||
evidence []operationaltrust.EvidenceEnvelope,
|
||||
) operationaltrust.EvidenceCompleteness {
|
||||
if len(evidence) == 0 {
|
||||
return operationaltrust.EvidenceUnavailable
|
||||
}
|
||||
completeness := operationaltrust.EvidenceComplete
|
||||
for _, envelope := range evidence {
|
||||
if envelope.Completeness == operationaltrust.EvidenceUnavailable {
|
||||
return operationaltrust.EvidenceUnavailable
|
||||
}
|
||||
if envelope.Completeness == operationaltrust.EvidencePartial {
|
||||
completeness = operationaltrust.EvidencePartial
|
||||
}
|
||||
}
|
||||
return completeness
|
||||
}
|
||||
|
||||
func FilterAttentionDetails(
|
||||
details []AttentionItemDetail,
|
||||
filter AttentionFilter,
|
||||
) ([]AttentionItemDetail, error) {
|
||||
if filter == "" {
|
||||
filter = AttentionFilterActive
|
||||
}
|
||||
if !filter.Valid() {
|
||||
return nil, fmt.Errorf("invalid attention filter %q", filter)
|
||||
}
|
||||
filtered := make([]AttentionItemDetail, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if attentionStateMatches(detail.Item.State, filter) {
|
||||
filtered = append(filtered, detail)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func PaginateAttentionDetails(
|
||||
details []AttentionItemDetail,
|
||||
page int,
|
||||
limit int,
|
||||
) ([]AttentionItemDetail, error) {
|
||||
if page < 1 {
|
||||
return nil, errors.New("attention page must be at least one")
|
||||
}
|
||||
if limit < 1 || limit > MaxAttentionPageSize {
|
||||
return nil, fmt.Errorf("attention limit must be between 1 and %d", MaxAttentionPageSize)
|
||||
}
|
||||
start := (page - 1) * limit
|
||||
if start >= len(details) {
|
||||
return []AttentionItemDetail{}, nil
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(details) {
|
||||
end = len(details)
|
||||
}
|
||||
return append([]AttentionItemDetail(nil), details[start:end]...), nil
|
||||
}
|
||||
|
||||
func attentionStateMatches(state operationaltrust.OperationalState, filter AttentionFilter) bool {
|
||||
switch filter {
|
||||
case AttentionFilterActive:
|
||||
return attentionStateCountsAsActive(state)
|
||||
case AttentionFilterOpen:
|
||||
return state == operationaltrust.OperationalOpen ||
|
||||
state == operationaltrust.OperationalObserving ||
|
||||
state == operationaltrust.OperationalResolving
|
||||
case AttentionFilterAcknowledged:
|
||||
return state == operationaltrust.OperationalAcknowledged
|
||||
case AttentionFilterSuppressed:
|
||||
return state == operationaltrust.OperationalSuppressed
|
||||
case AttentionFilterUncertain:
|
||||
return state == operationaltrust.OperationalStale ||
|
||||
state == operationaltrust.OperationalUnknown
|
||||
case AttentionFilterResolved:
|
||||
return state == operationaltrust.OperationalResolved
|
||||
case AttentionFilterAll:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func attentionStateCountsAsActive(state operationaltrust.OperationalState) bool {
|
||||
switch state {
|
||||
case operationaltrust.OperationalObserving,
|
||||
operationaltrust.OperationalOpen,
|
||||
operationaltrust.OperationalResolving,
|
||||
operationaltrust.OperationalStale,
|
||||
operationaltrust.OperationalUnknown:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeAttention(details []AttentionItemDetail, now time.Time) AttentionSummary {
|
||||
summary := AttentionSummary{
|
||||
CoverageState: "current",
|
||||
EvaluatedAt: now,
|
||||
}
|
||||
for _, detail := range details {
|
||||
switch detail.Item.State {
|
||||
case operationaltrust.OperationalAcknowledged:
|
||||
summary.AcknowledgedCount++
|
||||
case operationaltrust.OperationalSuppressed:
|
||||
summary.SuppressedCount++
|
||||
case operationaltrust.OperationalStale, operationaltrust.OperationalUnknown:
|
||||
summary.UncertainCount++
|
||||
summary.ActiveCount++
|
||||
case operationaltrust.OperationalResolved:
|
||||
summary.ResolvedCount++
|
||||
default:
|
||||
if attentionStateCountsAsActive(detail.Item.State) {
|
||||
summary.ActiveCount++
|
||||
summary.OpenCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
summary.Calm = summary.ActiveCount == 0
|
||||
return summary
|
||||
}
|
||||
|
||||
func attentionItemLess(left, right AttentionItem) bool {
|
||||
if l, r := attentionSeverityRank(left.Severity), attentionSeverityRank(right.Severity); l != r {
|
||||
return l > r
|
||||
}
|
||||
if l, r := len(left.RelatedResources), len(right.RelatedResources); l != r {
|
||||
return l > r
|
||||
}
|
||||
if l, r := attentionProtectionRank(left.ProtectionPosture), attentionProtectionRank(right.ProtectionPosture); l != r {
|
||||
return l > r
|
||||
}
|
||||
if l, r := attentionFreshnessRank(left.EvidenceFreshness), attentionFreshnessRank(right.EvidenceFreshness); l != r {
|
||||
return l > r
|
||||
}
|
||||
if !left.FirstObservedAt.Equal(right.FirstObservedAt) {
|
||||
return left.FirstObservedAt.Before(right.FirstObservedAt)
|
||||
}
|
||||
return left.ID < right.ID
|
||||
}
|
||||
|
||||
func attentionSeverityRank(severity operationaltrust.OperationalSeverity) int {
|
||||
switch severity {
|
||||
case operationaltrust.SeverityCritical:
|
||||
return 3
|
||||
case operationaltrust.SeverityWarning:
|
||||
return 2
|
||||
case operationaltrust.SeverityInfo:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func attentionProtectionRank(posture *recoverymodel.ProtectionPosture) int {
|
||||
if posture == nil {
|
||||
return 2
|
||||
}
|
||||
switch posture.State {
|
||||
case recoverymodel.ProtectionStateAttention:
|
||||
return 4
|
||||
case recoverymodel.ProtectionStateUnprotected:
|
||||
return 3
|
||||
case recoverymodel.ProtectionStateUnknown:
|
||||
return 2
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func attentionFreshnessRank(freshness operationaltrust.EvidenceFreshness) int {
|
||||
switch freshness {
|
||||
case operationaltrust.EvidenceFresh:
|
||||
return 2
|
||||
case operationaltrust.EvidenceStale:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func attentionTitle(alert alerts.Alert, resourceName string) string {
|
||||
alertType := strings.TrimSpace(alert.Type)
|
||||
if alertType == "" {
|
||||
return "Issue on " + resourceName
|
||||
}
|
||||
words := strings.Fields(strings.NewReplacer("-", " ", "_", " ").Replace(alertType))
|
||||
for i := range words {
|
||||
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
|
||||
}
|
||||
return strings.Join(words, " ") + " on " + resourceName
|
||||
}
|
||||
|
||||
func attentionResourceType(alert alerts.Alert) string {
|
||||
if alert.Metadata != nil {
|
||||
if value, ok := alert.Metadata["resourceType"].(string); ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstAttentionText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if normalized := strings.TrimSpace(value); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func canonicalAttentionIDs(values []string) []string {
|
||||
unique := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if normalized := strings.TrimSpace(value); normalized != "" {
|
||||
unique[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(unique))
|
||||
for value := range unique {
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneAttentionEvidence(
|
||||
values []operationaltrust.EvidenceEnvelope,
|
||||
) []operationaltrust.EvidenceEnvelope {
|
||||
result := make([]operationaltrust.EvidenceEnvelope, len(values))
|
||||
for i := range values {
|
||||
result[i] = values[i].Clone()
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if !result[i].ObservedAt.Equal(result[j].ObservedAt) {
|
||||
return result[i].ObservedAt.Before(result[j].ObservedAt)
|
||||
}
|
||||
return result[i].ID < result[j].ID
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneAttentionTimeline(
|
||||
values []operationaltrust.LifecycleTransition,
|
||||
) []operationaltrust.LifecycleTransition {
|
||||
result := make([]operationaltrust.LifecycleTransition, len(values))
|
||||
for i := range values {
|
||||
result[i] = values[i].Clone()
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if !result[i].At.Equal(result[j].At) {
|
||||
return result[i].At.Before(result[j].At)
|
||||
}
|
||||
return result[i].ID < result[j].ID
|
||||
})
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
)
|
||||
|
||||
func TestAttentionProjectionLargeCardinalityKeepsResponseBounded(t *testing.T) {
|
||||
const cardinality = 10_000
|
||||
now := time.Date(2026, 7, 19, 9, 0, 0, 0, time.UTC)
|
||||
active := make([]alerts.Alert, cardinality)
|
||||
for index := range active {
|
||||
active[index] = attentionTestAlert(
|
||||
fmt.Sprintf("record-%05d", index),
|
||||
operationaltrust.OperationalOpen,
|
||||
operationaltrust.SeverityWarning,
|
||||
now.Add(-time.Duration(index)*time.Second),
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
projection := ProjectAttentionItems(active, nil, nil, now)
|
||||
elapsed := time.Since(startedAt)
|
||||
if projection.Summary.ActiveCount != cardinality {
|
||||
t.Fatalf(
|
||||
"ActiveCount = %d, want %d",
|
||||
projection.Summary.ActiveCount,
|
||||
cardinality,
|
||||
)
|
||||
}
|
||||
page, err := PaginateAttentionDetails(projection.Details, 1, MaxAttentionPageSize)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateAttentionDetails(): %v", err)
|
||||
}
|
||||
if len(page) != MaxAttentionPageSize {
|
||||
t.Fatalf("page size = %d, want %d", len(page), MaxAttentionPageSize)
|
||||
}
|
||||
// This is deliberately generous enough for shared CI while still catching
|
||||
// accidental quadratic projection or an external request per item.
|
||||
if elapsed > 5*time.Second {
|
||||
t.Fatalf("10k attention projection took %s, want <= 5s", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
recoverymodel "github.com/rcourtman/pulse-go-rewrite/internal/recovery/model"
|
||||
)
|
||||
|
||||
func TestProjectAttentionItemsUsesCanonicalLifecycleAndStableOrdering(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 1, 0, 0, 0, time.UTC)
|
||||
active := []alerts.Alert{
|
||||
attentionTestAlert("warning-old", operationaltrust.OperationalOpen, operationaltrust.SeverityWarning, now.Add(-2*time.Hour), now),
|
||||
attentionTestAlert("critical", operationaltrust.OperationalOpen, operationaltrust.SeverityCritical, now.Add(-time.Hour), now),
|
||||
attentionTestAlert("warning-protected", operationaltrust.OperationalOpen, operationaltrust.SeverityWarning, now.Add(-3*time.Hour), now),
|
||||
}
|
||||
postures := map[string]recoverymodel.ProtectionPosture{
|
||||
"resource-warning-old": attentionTestPosture("resource-warning-old", recoverymodel.ProtectionStateUnprotected, now),
|
||||
"resource-warning-protected": attentionTestPosture(
|
||||
"resource-warning-protected",
|
||||
recoverymodel.ProtectionStateProtected,
|
||||
now,
|
||||
),
|
||||
}
|
||||
|
||||
projected := ProjectAttentionItems(active, nil, postures, now)
|
||||
if projected.Summary.ActiveCount != 3 {
|
||||
t.Fatalf("ActiveCount = %d, want 3", projected.Summary.ActiveCount)
|
||||
}
|
||||
if projected.Summary.Calm {
|
||||
t.Fatal("active projection reported a calm state")
|
||||
}
|
||||
if len(projected.Details) != 3 {
|
||||
t.Fatalf("details = %d, want 3", len(projected.Details))
|
||||
}
|
||||
got := []string{
|
||||
projected.Details[0].Item.ID,
|
||||
projected.Details[1].Item.ID,
|
||||
projected.Details[2].Item.ID,
|
||||
}
|
||||
want := []string{"critical", "warning-old", "warning-protected"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
if projected.Details[1].Item.OperationalRecordID != "warning-old" {
|
||||
t.Fatal("attention item did not retain canonical operational record identity")
|
||||
}
|
||||
if projected.Details[1].Item.ProtectionPosture == nil ||
|
||||
projected.Details[1].Item.ProtectionPosture.State != recoverymodel.ProtectionStateUnprotected {
|
||||
t.Fatal("attention item did not receive canonical protection posture")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAttentionItemsDeduplicatesHistoryAndActiveByOperationalRecord(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 2, 0, 0, 0, time.UTC)
|
||||
historical := attentionTestAlert(
|
||||
"shared-record",
|
||||
operationaltrust.OperationalResolved,
|
||||
operationaltrust.SeverityWarning,
|
||||
now.Add(-3*time.Hour),
|
||||
now.Add(-time.Hour),
|
||||
)
|
||||
resolvedAt := now.Add(-time.Hour)
|
||||
historical.OperationalRecord.ResolvedAt = &resolvedAt
|
||||
active := attentionTestAlert(
|
||||
"shared-record",
|
||||
operationaltrust.OperationalOpen,
|
||||
operationaltrust.SeverityWarning,
|
||||
now.Add(-3*time.Hour),
|
||||
now,
|
||||
)
|
||||
|
||||
projected := ProjectAttentionItems([]alerts.Alert{active}, []alerts.Alert{historical}, nil, now)
|
||||
if len(projected.Details) != 1 {
|
||||
t.Fatalf("details = %d, want one canonical item", len(projected.Details))
|
||||
}
|
||||
if projected.Details[0].Item.State != operationaltrust.OperationalOpen {
|
||||
t.Fatalf("state = %q, want active lifecycle state", projected.Details[0].Item.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAttentionItemsDoesNotReviveNonTerminalHistorySnapshots(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 2, 30, 0, 0, time.UTC)
|
||||
orphanedOpen := attentionTestAlert(
|
||||
"historical-open",
|
||||
operationaltrust.OperationalOpen,
|
||||
operationaltrust.SeverityCritical,
|
||||
now.Add(-3*time.Hour),
|
||||
now.Add(-2*time.Hour),
|
||||
)
|
||||
resolved := attentionTestAlert(
|
||||
"historical-resolved",
|
||||
operationaltrust.OperationalResolved,
|
||||
operationaltrust.SeverityWarning,
|
||||
now.Add(-4*time.Hour),
|
||||
now.Add(-time.Hour),
|
||||
)
|
||||
resolvedAt := now.Add(-time.Hour)
|
||||
resolved.OperationalRecord.ResolvedAt = &resolvedAt
|
||||
|
||||
projected := ProjectAttentionItems(
|
||||
nil,
|
||||
[]alerts.Alert{orphanedOpen, resolved},
|
||||
nil,
|
||||
now,
|
||||
)
|
||||
if len(projected.Details) != 1 {
|
||||
t.Fatalf("details = %d, want only the resolved historical record", len(projected.Details))
|
||||
}
|
||||
if got := projected.Details[0].Item.ID; got != "historical-resolved" {
|
||||
t.Fatalf("historical item = %q, want resolved record only", got)
|
||||
}
|
||||
if projected.Summary.ActiveCount != 0 || !projected.Summary.Calm {
|
||||
t.Fatalf("summary = %+v, historical open snapshot revived active work", projected.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAttentionItemsRepresentsUncertainEvidenceHonestly(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 3, 0, 0, 0, time.UTC)
|
||||
stale := attentionTestAlert(
|
||||
"stale",
|
||||
operationaltrust.OperationalStale,
|
||||
operationaltrust.SeverityCritical,
|
||||
now.Add(-time.Hour),
|
||||
now,
|
||||
)
|
||||
stale.Evidence = nil
|
||||
unknown := attentionTestAlert(
|
||||
"unknown",
|
||||
operationaltrust.OperationalUnknown,
|
||||
operationaltrust.SeverityWarning,
|
||||
now.Add(-time.Hour),
|
||||
now,
|
||||
)
|
||||
unknown.Evidence = nil
|
||||
|
||||
projected := ProjectAttentionItems([]alerts.Alert{stale, unknown}, nil, nil, now)
|
||||
if projected.Summary.UncertainCount != 2 || projected.Summary.ActiveCount != 2 {
|
||||
t.Fatalf("summary = %+v, want two uncertain active items", projected.Summary)
|
||||
}
|
||||
if projected.Details[0].Item.EvidenceFreshness != operationaltrust.EvidenceStale {
|
||||
t.Fatalf(
|
||||
"stale freshness = %q",
|
||||
projected.Details[0].Item.EvidenceFreshness,
|
||||
)
|
||||
}
|
||||
if projected.Details[1].Item.EvidenceFreshness != operationaltrust.EvidenceFreshnessUnknown {
|
||||
t.Fatalf(
|
||||
"unknown freshness = %q",
|
||||
projected.Details[1].Item.EvidenceFreshness,
|
||||
)
|
||||
}
|
||||
for _, detail := range projected.Details {
|
||||
if detail.Item.EvidenceCompleteness != operationaltrust.EvidenceUnavailable {
|
||||
t.Fatalf("completeness = %q, want unavailable", detail.Item.EvidenceCompleteness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionFiltersAndPaginationCoverLifecycleStates(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 4, 0, 0, 0, time.UTC)
|
||||
states := []operationaltrust.OperationalState{
|
||||
operationaltrust.OperationalOpen,
|
||||
operationaltrust.OperationalAcknowledged,
|
||||
operationaltrust.OperationalSuppressed,
|
||||
operationaltrust.OperationalStale,
|
||||
operationaltrust.OperationalUnknown,
|
||||
operationaltrust.OperationalResolved,
|
||||
}
|
||||
active := make([]alerts.Alert, 0, len(states)-1)
|
||||
history := make([]alerts.Alert, 0, 1)
|
||||
for index, state := range states {
|
||||
alert := attentionTestAlert(
|
||||
string(state),
|
||||
state,
|
||||
operationaltrust.SeverityWarning,
|
||||
now.Add(-time.Duration(index+1)*time.Minute),
|
||||
now,
|
||||
)
|
||||
switch state {
|
||||
case operationaltrust.OperationalAcknowledged:
|
||||
alert.OperationalRecord.Acknowledgement = &operationaltrust.Acknowledgement{
|
||||
At: now,
|
||||
By: "operator",
|
||||
}
|
||||
case operationaltrust.OperationalSuppressed:
|
||||
alert.OperationalRecord.Suppression = &operationaltrust.Suppression{
|
||||
At: now,
|
||||
By: "operator",
|
||||
Reason: "maintenance",
|
||||
}
|
||||
case operationaltrust.OperationalResolved:
|
||||
resolvedAt := now
|
||||
alert.OperationalRecord.ResolvedAt = &resolvedAt
|
||||
history = append(history, alert)
|
||||
continue
|
||||
}
|
||||
active = append(active, alert)
|
||||
}
|
||||
|
||||
projected := ProjectAttentionItems(active, history, nil, now)
|
||||
checks := map[AttentionFilter]int{
|
||||
AttentionFilterActive: 3,
|
||||
AttentionFilterOpen: 1,
|
||||
AttentionFilterAcknowledged: 1,
|
||||
AttentionFilterSuppressed: 1,
|
||||
AttentionFilterUncertain: 2,
|
||||
AttentionFilterResolved: 1,
|
||||
AttentionFilterAll: 6,
|
||||
}
|
||||
for filter, want := range checks {
|
||||
got, err := FilterAttentionDetails(projected.Details, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("FilterAttentionDetails(%q): %v", filter, err)
|
||||
}
|
||||
if len(got) != want {
|
||||
t.Fatalf("FilterAttentionDetails(%q) = %d, want %d", filter, len(got), want)
|
||||
}
|
||||
}
|
||||
page, err := PaginateAttentionDetails(projected.Details, 2, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateAttentionDetails(): %v", err)
|
||||
}
|
||||
if len(page) != 2 {
|
||||
t.Fatalf("page length = %d, want 2", len(page))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAttentionItemsProducesCurrentCalmEvaluation(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 5, 0, 0, 0, time.UTC)
|
||||
projected := ProjectAttentionItems(nil, nil, nil, now)
|
||||
if !projected.Summary.Calm || projected.Summary.ActiveCount != 0 {
|
||||
t.Fatalf("summary = %+v, want current calm evaluation", projected.Summary)
|
||||
}
|
||||
if projected.Summary.CoverageState != "current" ||
|
||||
!projected.Summary.EvaluatedAt.Equal(now) {
|
||||
t.Fatalf("summary = %+v, want bounded current evaluation", projected.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func attentionTestAlert(
|
||||
id string,
|
||||
state operationaltrust.OperationalState,
|
||||
severity operationaltrust.OperationalSeverity,
|
||||
firstObservedAt time.Time,
|
||||
lastObservedAt time.Time,
|
||||
) alerts.Alert {
|
||||
resourceID := "resource-" + id
|
||||
evidence := attentionTestEvidence(resourceID, lastObservedAt)
|
||||
record := operationaltrust.OperationalRecord{
|
||||
ID: id,
|
||||
CanonicalSpecID: "spec-" + id,
|
||||
SubjectResourceID: resourceID,
|
||||
State: state,
|
||||
Severity: severity,
|
||||
FirstObservedAt: firstObservedAt,
|
||||
LastObservedAt: lastObservedAt,
|
||||
StateChangedAt: lastObservedAt,
|
||||
EvidenceIDs: []string{evidence.ID},
|
||||
CauseKey: "cause-" + id,
|
||||
RelatedResourceIDs: []string{"related-" + id},
|
||||
ImpactSummary: "Service may be interrupted.",
|
||||
RecommendedNextStep: "Review the source evidence.",
|
||||
}
|
||||
return alerts.Alert{
|
||||
ID: "alert-" + id,
|
||||
Type: "service-health",
|
||||
Level: alerts.AlertLevelWarning,
|
||||
ResourceID: resourceID,
|
||||
ResourceName: "Resource " + id,
|
||||
Message: "The service health check needs attention.",
|
||||
StartTime: firstObservedAt,
|
||||
LastSeen: lastObservedAt,
|
||||
OperationalRecord: &record,
|
||||
Evidence: []operationaltrust.EvidenceEnvelope{evidence},
|
||||
}
|
||||
}
|
||||
|
||||
func attentionTestEvidence(
|
||||
resourceID string,
|
||||
observedAt time.Time,
|
||||
) operationaltrust.EvidenceEnvelope {
|
||||
validUntil := observedAt.Add(time.Hour)
|
||||
return operationaltrust.EvidenceEnvelope{
|
||||
ID: "evidence-" + resourceID,
|
||||
Source: operationaltrust.EvidenceSource{
|
||||
Provider: "test",
|
||||
Collector: "test-collector",
|
||||
},
|
||||
Subject: operationaltrust.EvidenceSubject{ResourceID: resourceID},
|
||||
ObservedAt: observedAt,
|
||||
IngestedAt: observedAt,
|
||||
ValidUntil: &validUntil,
|
||||
Completeness: operationaltrust.EvidenceComplete,
|
||||
Confidence: operationaltrust.EvidenceConfirmed,
|
||||
Permissions: operationaltrust.EvidencePermissionsSufficient,
|
||||
}
|
||||
}
|
||||
|
||||
func attentionTestPosture(
|
||||
resourceID string,
|
||||
state recoverymodel.ProtectionState,
|
||||
evaluatedAt time.Time,
|
||||
) recoverymodel.ProtectionPosture {
|
||||
return recoverymodel.ProtectionPosture{
|
||||
SubjectResourceID: resourceID,
|
||||
State: state,
|
||||
Freshness: recoverymodel.ProtectionFreshnessCurrent,
|
||||
Verification: recoverymodel.ProtectionVerificationVerified,
|
||||
Coverage: recoverymodel.ProtectionCoverageComplete,
|
||||
ProviderStates: []recoverymodel.ProtectionProviderState{},
|
||||
EvidenceIDs: []string{},
|
||||
Explanation: "Test posture.",
|
||||
EvaluatedAt: evaluatedAt,
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package ai
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
)
|
||||
|
||||
// PatrolMetrics manages Prometheus instrumentation for patrol decisions.
|
||||
@@ -22,6 +24,10 @@ type PatrolMetrics struct {
|
||||
streamReplayEvents prometheus.Counter
|
||||
streamReplayBatch prometheus.Histogram
|
||||
streamSubscriberDrop *prometheus.CounterVec
|
||||
attentionItems *prometheus.GaugeVec
|
||||
attentionOldestAge prometheus.Gauge
|
||||
attentionAckLatest prometheus.Gauge
|
||||
calmEvaluationTime prometheus.Gauge
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -160,6 +166,39 @@ func newPatrolMetrics() *PatrolMetrics {
|
||||
},
|
||||
[]string{"reason"},
|
||||
),
|
||||
attentionItems: prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "pulse",
|
||||
Subsystem: "patrol",
|
||||
Name: "attention_items",
|
||||
Help: "Current Patrol attention item count by canonical lifecycle state",
|
||||
},
|
||||
[]string{"state"},
|
||||
),
|
||||
attentionOldestAge: prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "pulse",
|
||||
Subsystem: "patrol",
|
||||
Name: "attention_oldest_age_seconds",
|
||||
Help: "Age in seconds of the oldest active Patrol attention item",
|
||||
},
|
||||
),
|
||||
attentionAckLatest: prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "pulse",
|
||||
Subsystem: "patrol",
|
||||
Name: "attention_acknowledgement_latest_seconds",
|
||||
Help: "Elapsed seconds from first observation to the newest current acknowledgement",
|
||||
},
|
||||
),
|
||||
calmEvaluationTime: prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "pulse",
|
||||
Subsystem: "patrol",
|
||||
Name: "calm_evaluation_timestamp_seconds",
|
||||
Help: "Unix timestamp of the latest canonical Patrol calm-day evaluation",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
prometheus.MustRegister(
|
||||
@@ -177,6 +216,10 @@ func newPatrolMetrics() *PatrolMetrics {
|
||||
m.streamReplayEvents,
|
||||
m.streamReplayBatch,
|
||||
m.streamSubscriberDrop,
|
||||
m.attentionItems,
|
||||
m.attentionOldestAge,
|
||||
m.attentionAckLatest,
|
||||
m.calmEvaluationTime,
|
||||
)
|
||||
|
||||
return m
|
||||
@@ -257,3 +300,57 @@ func (m *PatrolMetrics) RecordStreamSubscriberDrop(reason string) {
|
||||
}
|
||||
m.streamSubscriberDrop.WithLabelValues(reason).Inc()
|
||||
}
|
||||
|
||||
// ObserveAttentionProjection records bounded low-cardinality queue health.
|
||||
// Resource and record identifiers are deliberately excluded from labels.
|
||||
func (m *PatrolMetrics) ObserveAttentionProjection(
|
||||
projection AttentionProjection,
|
||||
now time.Time,
|
||||
) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
states := []operationaltrust.OperationalState{
|
||||
operationaltrust.OperationalObserving,
|
||||
operationaltrust.OperationalOpen,
|
||||
operationaltrust.OperationalAcknowledged,
|
||||
operationaltrust.OperationalSuppressed,
|
||||
operationaltrust.OperationalResolving,
|
||||
operationaltrust.OperationalResolved,
|
||||
operationaltrust.OperationalStale,
|
||||
operationaltrust.OperationalUnknown,
|
||||
}
|
||||
counts := make(map[operationaltrust.OperationalState]int, len(states))
|
||||
oldestAge := time.Duration(0)
|
||||
latestAcknowledgementAt := time.Time{}
|
||||
latestAcknowledgementElapsed := time.Duration(0)
|
||||
for _, detail := range projection.Details {
|
||||
counts[detail.Item.State]++
|
||||
if attentionStateCountsAsActive(detail.Item.State) {
|
||||
age := now.Sub(detail.Item.FirstObservedAt)
|
||||
if age > oldestAge {
|
||||
oldestAge = age
|
||||
}
|
||||
}
|
||||
if detail.Item.State == operationaltrust.OperationalAcknowledged &&
|
||||
detail.OperationalRecord.Acknowledgement != nil {
|
||||
elapsed := detail.OperationalRecord.Acknowledgement.At.Sub(
|
||||
detail.Item.FirstObservedAt,
|
||||
)
|
||||
if elapsed >= 0 &&
|
||||
detail.OperationalRecord.Acknowledgement.At.After(latestAcknowledgementAt) {
|
||||
latestAcknowledgementAt = detail.OperationalRecord.Acknowledgement.At
|
||||
latestAcknowledgementElapsed = elapsed
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, state := range states {
|
||||
m.attentionItems.WithLabelValues(string(state)).Set(float64(counts[state]))
|
||||
}
|
||||
m.attentionOldestAge.Set(oldestAge.Seconds())
|
||||
m.attentionAckLatest.Set(latestAcknowledgementElapsed.Seconds())
|
||||
m.calmEvaluationTime.Set(float64(projection.Summary.EvaluatedAt.Unix()))
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ func ensureOperationalContract(alert *Alert, ingestedAt time.Time) {
|
||||
record.EvidenceIDs = evidenceIDs
|
||||
record.CauseKey = causeKey
|
||||
record.ImpactSummary = strings.TrimSpace(alert.Message)
|
||||
if strings.TrimSpace(record.RecommendedNextStep) == "" {
|
||||
record.RecommendedNextStep = operationalRecommendedNextStep(alert)
|
||||
}
|
||||
if state != operationaltrust.OperationalResolved {
|
||||
record.ResolvedAt = nil
|
||||
}
|
||||
@@ -137,6 +140,18 @@ func ensureOperationalContract(alert *Alert, ingestedAt time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
func operationalRecommendedNextStep(alert *Alert) string {
|
||||
if alert == nil {
|
||||
return ""
|
||||
}
|
||||
if action, ok := alert.Metadata["incidentAction"].(string); ok {
|
||||
if action = strings.TrimSpace(action); action != "" {
|
||||
return action
|
||||
}
|
||||
}
|
||||
return "Open the affected resource and verify its current state before making changes."
|
||||
}
|
||||
|
||||
func mergeOperationalRecurrence(
|
||||
target *Alert,
|
||||
previous *Alert,
|
||||
|
||||
@@ -70,6 +70,35 @@ func TestEnsureOperationalContractBackfillsLegacyAlertHonestly(t *testing.T) {
|
||||
alert.Transitions[0].ID != alert.LatestTransition.ID {
|
||||
t.Fatalf("transition timeline = %+v, want initial transition", alert.Transitions)
|
||||
}
|
||||
if got, want := alert.OperationalRecord.RecommendedNextStep, "Open the affected resource and verify its current state before making changes."; got != want {
|
||||
t.Fatalf("RecommendedNextStep = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOperationalContractUsesCanonicalIncidentAction(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 1, 0, 0, 0, time.UTC)
|
||||
alert := &Alert{
|
||||
ID: "storage:pool-1",
|
||||
Type: "storage-incident",
|
||||
Level: AlertLevelCritical,
|
||||
ResourceID: "pool-1",
|
||||
ResourceName: "Pool 1",
|
||||
Message: "Pool health is degraded",
|
||||
StartTime: now.Add(-time.Minute),
|
||||
LastSeen: now,
|
||||
Metadata: map[string]interface{}{
|
||||
"incidentAction": "Inspect the degraded devices and restore redundancy",
|
||||
},
|
||||
}
|
||||
|
||||
ensureOperationalContract(alert, now)
|
||||
|
||||
if alert.OperationalRecord == nil {
|
||||
t.Fatal("expected operational record")
|
||||
}
|
||||
if got, want := alert.OperationalRecord.RecommendedNextStep, "Inspect the degraded devices and restore redundancy"; got != want {
|
||||
t.Fatalf("RecommendedNextStep = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalAlertEvidenceBuildsConfirmedEnvelope(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
||||
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
|
||||
recoverymodel "github.com/rcourtman/pulse-go-rewrite/internal/recovery/model"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
attentionHistoryReadLimit = 500
|
||||
attentionPostureBatchSize = 200
|
||||
)
|
||||
|
||||
type attentionAlertSnapshot func(context.Context) ([]alerts.Alert, []alerts.Alert, error)
|
||||
|
||||
type AttentionHandlers struct {
|
||||
readAlerts attentionAlertSnapshot
|
||||
recoveryManager *recoverymanager.Manager
|
||||
}
|
||||
|
||||
func NewAttentionHandlers(
|
||||
getMonitor func(context.Context) *monitoring.Monitor,
|
||||
recoveryManager *recoverymanager.Manager,
|
||||
) *AttentionHandlers {
|
||||
return &AttentionHandlers{
|
||||
readAlerts: func(ctx context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
if getMonitor == nil {
|
||||
return nil, nil, fmt.Errorf("monitor is not configured")
|
||||
}
|
||||
monitor := getMonitor(ctx)
|
||||
if monitor == nil || monitor.GetAlertManager() == nil {
|
||||
return nil, nil, fmt.Errorf("alert lifecycle is not available")
|
||||
}
|
||||
manager := monitor.GetAlertManager()
|
||||
return manager.GetActiveAlerts(), manager.GetAlertHistory(attentionHistoryReadLimit), nil
|
||||
},
|
||||
recoveryManager: recoveryManager,
|
||||
}
|
||||
}
|
||||
|
||||
type attentionListResponse struct {
|
||||
Data []ai.AttentionItem `json:"data"`
|
||||
Summary ai.AttentionSummary `json:"summary"`
|
||||
Meta struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) HandleAttention(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/attention")
|
||||
switch {
|
||||
case path == "" || path == "/":
|
||||
h.handleAttentionList(w, r)
|
||||
case path == "/summary":
|
||||
h.handleAttentionSummary(w, r)
|
||||
case strings.HasPrefix(path, "/"):
|
||||
h.handleAttentionDetail(w, r, strings.TrimPrefix(path, "/"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.Request) {
|
||||
filter, page, limit, ok := parseAttentionListQuery(w, r.URL.Query())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projection, err := h.project(r.Context(), true)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
}
|
||||
filtered, err := ai.FilterAttentionDetails(projection.Details, filter)
|
||||
if err != nil {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_filter",
|
||||
err.Error(),
|
||||
map[string]string{"filter": string(filter)},
|
||||
)
|
||||
return
|
||||
}
|
||||
paged, err := ai.PaginateAttentionDetails(filtered, page, limit)
|
||||
if err != nil {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_page",
|
||||
err.Error(),
|
||||
nil,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
response := attentionListResponse{
|
||||
Data: make([]ai.AttentionItem, 0, len(paged)),
|
||||
Summary: projection.Summary,
|
||||
}
|
||||
for _, detail := range paged {
|
||||
response.Data = append(response.Data, detail.Item)
|
||||
}
|
||||
response.Meta.Page = page
|
||||
response.Meta.Limit = limit
|
||||
response.Meta.Total = len(filtered)
|
||||
if len(filtered) > 0 {
|
||||
response.Meta.TotalPages = (len(filtered) + limit - 1) / limit
|
||||
}
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize Patrol attention list")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionSummary(w http.ResponseWriter, r *http.Request) {
|
||||
projection, err := h.project(r.Context(), false)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
}
|
||||
if err := utils.WriteJSONResponse(w, projection.Summary); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize Patrol attention summary")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionDetail(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
rawID string,
|
||||
) {
|
||||
itemID, err := url.PathUnescape(rawID)
|
||||
if err != nil || strings.TrimSpace(itemID) == "" {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_id",
|
||||
"Attention item ID is invalid.",
|
||||
nil,
|
||||
)
|
||||
return
|
||||
}
|
||||
projection, err := h.project(r.Context(), true)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
}
|
||||
for _, detail := range projection.Details {
|
||||
if detail.Item.ID == itemID {
|
||||
if err := utils.WriteJSONResponse(w, detail); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize Patrol attention detail")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusNotFound,
|
||||
"attention_item_not_found",
|
||||
"Attention item was not found.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) project(
|
||||
ctx context.Context,
|
||||
includeProtectionPosture bool,
|
||||
) (ai.AttentionProjection, error) {
|
||||
if h == nil || h.readAlerts == nil {
|
||||
return ai.AttentionProjection{}, fmt.Errorf("attention lifecycle source is not configured")
|
||||
}
|
||||
active, history, err := h.readAlerts(ctx)
|
||||
if err != nil {
|
||||
return ai.AttentionProjection{}, err
|
||||
}
|
||||
postures := map[string]recoverymodel.ProtectionPosture{}
|
||||
postureComplete := true
|
||||
if includeProtectionPosture {
|
||||
resourceIDs := attentionResourceIDs(active, history)
|
||||
postures, postureComplete = h.loadProtectionPostures(
|
||||
ctx,
|
||||
GetOrgID(ctx),
|
||||
resourceIDs,
|
||||
)
|
||||
}
|
||||
projection := ai.ProjectAttentionItems(active, history, postures, time.Now().UTC())
|
||||
if !postureComplete {
|
||||
projection.Summary.CoverageState = "partial"
|
||||
}
|
||||
ai.GetPatrolMetrics().ObserveAttentionProjection(projection, projection.Summary.EvaluatedAt)
|
||||
return projection, nil
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) loadProtectionPostures(
|
||||
ctx context.Context,
|
||||
orgID string,
|
||||
resourceIDs []string,
|
||||
) (map[string]recoverymodel.ProtectionPosture, bool) {
|
||||
result := make(map[string]recoverymodel.ProtectionPosture, len(resourceIDs))
|
||||
if len(resourceIDs) == 0 {
|
||||
return result, true
|
||||
}
|
||||
if h == nil || h.recoveryManager == nil {
|
||||
return result, false
|
||||
}
|
||||
store, err := h.recoveryManager.StoreForOrg(orgID)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Patrol attention protection posture is unavailable")
|
||||
return result, false
|
||||
}
|
||||
|
||||
complete := true
|
||||
for start := 0; start < len(resourceIDs); start += attentionPostureBatchSize {
|
||||
end := start + attentionPostureBatchSize
|
||||
if end > len(resourceIDs) {
|
||||
end = len(resourceIDs)
|
||||
}
|
||||
batch := resourceIDs[start:end]
|
||||
postures, _, listErr := store.ListProtectionPostures(ctx, recovery.ProtectionPostureQuery{
|
||||
SubjectResourceIDs: batch,
|
||||
Page: 1,
|
||||
Limit: len(batch),
|
||||
})
|
||||
if listErr != nil {
|
||||
log.Debug().Err(listErr).Msg("Patrol attention posture batch is unavailable")
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
for _, posture := range postures {
|
||||
result[posture.SubjectResourceID] = posture
|
||||
}
|
||||
}
|
||||
return result, complete
|
||||
}
|
||||
|
||||
func parseAttentionListQuery(
|
||||
w http.ResponseWriter,
|
||||
query url.Values,
|
||||
) (ai.AttentionFilter, int, int, bool) {
|
||||
filter := ai.AttentionFilter(strings.TrimSpace(query.Get("filter")))
|
||||
if filter == "" {
|
||||
filter = ai.AttentionFilterActive
|
||||
}
|
||||
if !filter.Valid() {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_filter",
|
||||
"Filter must be active, open, acknowledged, suppressed, stale_unknown, resolved, or all.",
|
||||
map[string]string{"filter": string(filter)},
|
||||
)
|
||||
return "", 0, 0, false
|
||||
}
|
||||
page, valid := parsePositiveAttentionInt(w, query.Get("page"), 1, "page")
|
||||
if !valid {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
limit, valid := parsePositiveAttentionInt(
|
||||
w,
|
||||
query.Get("limit"),
|
||||
ai.DefaultAttentionPageSize,
|
||||
"limit",
|
||||
)
|
||||
if !valid {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
if limit > ai.MaxAttentionPageSize {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"attention_limit_exceeded",
|
||||
fmt.Sprintf("Attention limit must not exceed %d.", ai.MaxAttentionPageSize),
|
||||
map[string]string{"limit": strconv.Itoa(ai.MaxAttentionPageSize)},
|
||||
)
|
||||
return "", 0, 0, false
|
||||
}
|
||||
return filter, page, limit, true
|
||||
}
|
||||
|
||||
func parsePositiveAttentionInt(
|
||||
w http.ResponseWriter,
|
||||
raw string,
|
||||
fallback int,
|
||||
field string,
|
||||
) (int, bool) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return fallback, true
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_"+field,
|
||||
"Attention "+field+" must be a positive integer.",
|
||||
map[string]string{field: raw},
|
||||
)
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func attentionResourceIDs(active, history []alerts.Alert) []string {
|
||||
unique := make(map[string]struct{}, len(active)+len(history))
|
||||
add := func(values []alerts.Alert, history bool) {
|
||||
for _, alert := range values {
|
||||
if alert.OperationalRecord == nil {
|
||||
continue
|
||||
}
|
||||
if history && alert.OperationalRecord.State != operationaltrust.OperationalResolved {
|
||||
continue
|
||||
}
|
||||
if resourceID := strings.TrimSpace(alert.OperationalRecord.SubjectResourceID); resourceID != "" {
|
||||
unique[resourceID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
add(active, false)
|
||||
add(history, true)
|
||||
result := make([]string, 0, len(unique))
|
||||
for resourceID := range unique {
|
||||
result = append(result, resourceID)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func writeAttentionUnavailable(w http.ResponseWriter, err error) {
|
||||
log.Warn().Err(err).Msg("Patrol attention read model is unavailable")
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusServiceUnavailable,
|
||||
"attention_read_model_unavailable",
|
||||
"Patrol attention is temporarily unavailable. No calm or healthy state has been inferred.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
)
|
||||
|
||||
func TestAttentionHandlersListUsesCanonicalCountAndFilters(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 6, 0, 0, 0, time.UTC)
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return []alerts.Alert{
|
||||
attentionHandlerAlert("open", operationaltrust.OperationalOpen, now),
|
||||
attentionHandlerAlert("ack", operationaltrust.OperationalAcknowledged, now),
|
||||
attentionHandlerAlert("stale", operationaltrust.OperationalStale, now),
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention?filter=acknowledged&limit=10",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload attentionListResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload.Data) != 1 || payload.Data[0].State != operationaltrust.OperationalAcknowledged {
|
||||
t.Fatalf("data = %+v, want acknowledged item", payload.Data)
|
||||
}
|
||||
if payload.Summary.ActiveCount != 2 ||
|
||||
payload.Summary.AcknowledgedCount != 1 ||
|
||||
payload.Summary.UncertainCount != 1 {
|
||||
t.Fatalf("summary = %+v", payload.Summary)
|
||||
}
|
||||
if payload.Summary.CoverageState != "partial" {
|
||||
t.Fatalf("coverage = %q, want partial without posture source", payload.Summary.CoverageState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersSummaryAndDetailShareOneProjection(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 7, 0, 0, 0, time.UTC)
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return []alerts.Alert{
|
||||
attentionHandlerAlert("record-1", operationaltrust.OperationalOpen, now),
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
summaryRequest := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/summary",
|
||||
nil,
|
||||
)
|
||||
summaryResponse := httptest.NewRecorder()
|
||||
handler.HandleAttention(summaryResponse, summaryRequest)
|
||||
if summaryResponse.Code != http.StatusOK {
|
||||
t.Fatalf("summary status = %d body=%s", summaryResponse.Code, summaryResponse.Body.String())
|
||||
}
|
||||
var summary ai.AttentionSummary
|
||||
if err := json.Unmarshal(summaryResponse.Body.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("decode summary: %v", err)
|
||||
}
|
||||
if summary.ActiveCount != 1 {
|
||||
t.Fatalf("ActiveCount = %d, want 1", summary.ActiveCount)
|
||||
}
|
||||
|
||||
detailRequest := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/record-1",
|
||||
nil,
|
||||
)
|
||||
detailResponse := httptest.NewRecorder()
|
||||
handler.HandleAttention(detailResponse, detailRequest)
|
||||
if detailResponse.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d body=%s", detailResponse.Code, detailResponse.Body.String())
|
||||
}
|
||||
var detail ai.AttentionItemDetail
|
||||
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Item.ID != "record-1" ||
|
||||
detail.OperationalRecord.ID != detail.Item.OperationalRecordID ||
|
||||
len(detail.Evidence) != 1 {
|
||||
t.Fatalf("detail = %+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersDetailSupportsCanonicalIDsContainingSlashes(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 7, 30, 0, 0, time.UTC)
|
||||
const recordID = "agent:node-1/disk:mnt-disk2::metric-threshold:disk"
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return []alerts.Alert{
|
||||
attentionHandlerAlert(recordID, operationaltrust.OperationalOpen, now),
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/agent%3Anode-1%2Fdisk%3Amnt-disk2%3A%3Ametric-threshold%3Adisk",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var detail ai.AttentionItemDetail
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Item.ID != recordID {
|
||||
t.Fatalf("detail ID = %q, want %q", detail.Item.ID, recordID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersFailClosedWhenLifecycleUnavailable(t *testing.T) {
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return nil, nil, errors.New("collector state unavailable")
|
||||
},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/summary",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if body := response.Body.String(); body == "" ||
|
||||
!json.Valid(response.Body.Bytes()) {
|
||||
t.Fatalf("expected typed unavailable response, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersRejectInvalidOrUnboundedQueries(t *testing.T) {
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return nil, nil, nil
|
||||
},
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/api/ai/patrol/attention?filter=healthy",
|
||||
"/api/ai/patrol/attention?page=0",
|
||||
"/api/ai/patrol/attention?limit=201",
|
||||
"/api/ai/patrol/attention/%20",
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status = %d body=%s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attentionHandlerAlert(
|
||||
id string,
|
||||
state operationaltrust.OperationalState,
|
||||
now time.Time,
|
||||
) alerts.Alert {
|
||||
resourceID := "resource-" + id
|
||||
validUntil := now.Add(time.Hour)
|
||||
evidence := operationaltrust.EvidenceEnvelope{
|
||||
ID: "evidence-" + id,
|
||||
Source: operationaltrust.EvidenceSource{
|
||||
Provider: "test",
|
||||
Collector: "test",
|
||||
},
|
||||
Subject: operationaltrust.EvidenceSubject{ResourceID: resourceID},
|
||||
ObservedAt: now,
|
||||
IngestedAt: now,
|
||||
ValidUntil: &validUntil,
|
||||
Completeness: operationaltrust.EvidenceComplete,
|
||||
Confidence: operationaltrust.EvidenceConfirmed,
|
||||
Permissions: operationaltrust.EvidencePermissionsSufficient,
|
||||
}
|
||||
record := operationaltrust.OperationalRecord{
|
||||
ID: id,
|
||||
CanonicalSpecID: "spec-" + id,
|
||||
SubjectResourceID: resourceID,
|
||||
State: state,
|
||||
Severity: operationaltrust.SeverityWarning,
|
||||
FirstObservedAt: now.Add(-time.Hour),
|
||||
LastObservedAt: now,
|
||||
StateChangedAt: now,
|
||||
EvidenceIDs: []string{evidence.ID},
|
||||
CauseKey: "cause-" + id,
|
||||
ImpactSummary: "Service interruption is possible.",
|
||||
RelatedResourceIDs: []string{},
|
||||
}
|
||||
switch state {
|
||||
case operationaltrust.OperationalAcknowledged:
|
||||
record.Acknowledgement = &operationaltrust.Acknowledgement{At: now, By: "operator"}
|
||||
case operationaltrust.OperationalSuppressed:
|
||||
record.Suppression = &operationaltrust.Suppression{
|
||||
At: now,
|
||||
By: "operator",
|
||||
Reason: "maintenance",
|
||||
}
|
||||
case operationaltrust.OperationalResolved:
|
||||
record.ResolvedAt = &now
|
||||
}
|
||||
return alerts.Alert{
|
||||
ID: "alert-" + id,
|
||||
Type: "service-health",
|
||||
Level: alerts.AlertLevelWarning,
|
||||
ResourceID: resourceID,
|
||||
ResourceName: "Resource " + id,
|
||||
Message: "Service health needs attention.",
|
||||
StartTime: now.Add(-time.Hour),
|
||||
LastSeen: now,
|
||||
OperationalRecord: &record,
|
||||
Evidence: []operationaltrust.EvidenceEnvelope{evidence},
|
||||
}
|
||||
}
|
||||
@@ -621,6 +621,8 @@ var allRouteAllowlist = []string{
|
||||
"/api/ai/patrol/status",
|
||||
"/api/ai/patrol/stream",
|
||||
"/api/ai/patrol/findings",
|
||||
"/api/ai/patrol/attention",
|
||||
"/api/ai/patrol/attention/",
|
||||
"/api/ai/patrol/history",
|
||||
"/api/ai/patrol/run",
|
||||
"/api/ai/patrol/preflight",
|
||||
|
||||
@@ -106,6 +106,7 @@ type Router struct {
|
||||
unifiedAgentHandlers *UnifiedAgentHandlers
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
aiSettingsHandler *AISettingsHandler
|
||||
attentionHandlers *AttentionHandlers
|
||||
aiHandler *AIHandler // AI chat handler
|
||||
discoveryHandlers *DiscoveryHandlers
|
||||
resourceHandlers *ResourceHandlers
|
||||
@@ -464,6 +465,7 @@ func (r *Router) setupRoutes() {
|
||||
)
|
||||
recoveryManager := recoverymanager.New(r.multiTenant)
|
||||
r.recoveryHandlers = NewRecoveryHandlers(recoveryManager)
|
||||
r.attentionHandlers = NewAttentionHandlers(r.configHandlers.getMonitor, recoveryManager)
|
||||
if r.mtMonitor != nil {
|
||||
r.mtMonitor.SetRecoveryManager(recoveryManager)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ func (r *Router) registerAIRelayRoutesGroup() {
|
||||
r.mux.HandleFunc("/api/ai/patrol/status", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolStatus)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/stream", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandlePatrolStream)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/findings", RequireAuth(r.config, r.routeAIPatrolFindings))
|
||||
r.mux.HandleFunc("/api/ai/patrol/attention", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.attentionHandlers.HandleAttention)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/attention/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.attentionHandlers.HandleAttention)))
|
||||
// SECURITY: AI Patrol read endpoints - require ai:execute scope
|
||||
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetFindingsHistory)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleForcePatrol)))
|
||||
|
||||
+1
-1
@@ -384,7 +384,7 @@ else
|
||||
# mock run) must NOT win here, or real mode would keep reading the mock dir.
|
||||
HOT_DEV_MOCK_FLAG=""
|
||||
if [[ -f "${ROOT_DIR}/tmp/dev-config/.env" ]]; then
|
||||
HOT_DEV_MOCK_FLAG="$(grep -E '^[[:space:]]*PULSE_MOCK_MODE=' "${ROOT_DIR}/tmp/dev-config/.env" | tail -1 | cut -d= -f2 | tr -dc 'a-z')"
|
||||
HOT_DEV_MOCK_FLAG="$(grep -E '^[[:space:]]*PULSE_MOCK_MODE=' "${ROOT_DIR}/tmp/dev-config/.env" | tail -1 | cut -d= -f2 | tr -dc 'a-z' || true)"
|
||||
fi
|
||||
if [[ -z "${HOT_DEV_MOCK_FLAG}" ]]; then
|
||||
HOT_DEV_MOCK_FLAG="${PULSE_MOCK_MODE:-}"
|
||||
|
||||
@@ -59,6 +59,11 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase):
|
||||
self.assertIn("privacy-allowlisted community evidence export", normalized_content)
|
||||
self.assertIn("Every active finding shown or returned to a Patrol run", content)
|
||||
self.assertIn("Silence is not an all-clear signal", normalized_content)
|
||||
self.assertIn("### The Patrol attention queue", content)
|
||||
self.assertIn(
|
||||
"it cannot create lifecycle truth, invent action authority, or hide uncertainty",
|
||||
normalized_content,
|
||||
)
|
||||
self.assertIn(
|
||||
"The Assistant model matrix below proves Assistant orchestration only",
|
||||
normalized_content,
|
||||
|
||||
@@ -12,7 +12,9 @@ RECOVERY_PRODUCT_SURFACE_EXACT_FILES = [
|
||||
]
|
||||
|
||||
PATROL_PAGE_AND_STATE_EXACT_FILES = [
|
||||
"frontend-modern/src/api/__tests__/patrolAttention.test.ts",
|
||||
"frontend-modern/src/components/Brand/__tests__/PulsePatrolLogo.test.tsx",
|
||||
"frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx",
|
||||
"frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts",
|
||||
"frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts",
|
||||
"frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
|
||||
@@ -24,6 +26,7 @@ PATROL_PAGE_AND_STATE_EXACT_FILES = [
|
||||
"tests/integration/tests/18-patrol-runtime-state.spec.ts",
|
||||
"tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts",
|
||||
"tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts",
|
||||
"tests/integration/tests/91-operational-trust-attention-workbench.spec.ts",
|
||||
]
|
||||
|
||||
PLATFORM_CONNECTIONS_WORKSPACE_EXACT_FILES = [
|
||||
@@ -2970,8 +2973,8 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 1244,
|
||||
"heading_line": 155,
|
||||
"line": 1247,
|
||||
"heading_line": 157,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
@@ -129,6 +129,16 @@ test_hot_dev_keeps_backend_launch_errors_in_debug_log() {
|
||||
assert_contains "backend LOG_FILE follows debug log override" "${output}" "LOG_FILE=\"\${BACKEND_DEBUG_LOG}\""
|
||||
}
|
||||
|
||||
test_hot_dev_tolerates_missing_mock_mode_setting() {
|
||||
local output
|
||||
output="$(sed -n '360,420p' "${HOT_DEV}")"
|
||||
|
||||
assert_contains \
|
||||
"hot-dev falls back when the canonical dev env has no mock-mode setting" \
|
||||
"${output}" \
|
||||
"tr -dc 'a-z' || true"
|
||||
}
|
||||
|
||||
test_hot_dev_preserves_proxmox_guest_docker_env() {
|
||||
local output
|
||||
output="$(sed -n '1,760p' "${HOT_DEV}")"
|
||||
@@ -328,6 +338,7 @@ test_pulse_process_count_handles_zero_matches_under_pipefail
|
||||
test_pulse_process_count_counts_matching_processes
|
||||
test_hot_dev_uses_resilient_backend_process_count
|
||||
test_hot_dev_keeps_backend_launch_errors_in_debug_log
|
||||
test_hot_dev_tolerates_missing_mock_mode_setting
|
||||
test_hot_dev_preserves_proxmox_guest_docker_env
|
||||
test_hot_dev_avoids_self_killing_npm_wrapper
|
||||
test_hot_dev_network_defaults_are_local_first_with_explicit_lan_opt_in
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
type AttentionMode = "active" | "calm" | "failed";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
window.sessionStorage.setItem(
|
||||
"pulse_auth",
|
||||
JSON.stringify({
|
||||
type: "token",
|
||||
value: "attention-workbench-test-token",
|
||||
}),
|
||||
);
|
||||
window.sessionStorage.setItem("pulse_auth_user", "operator");
|
||||
});
|
||||
await page.route("**/*", async (route) => {
|
||||
if (!new URL(route.request().url()).pathname.startsWith("/api/")) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "{}",
|
||||
});
|
||||
});
|
||||
await page.route("**/api/state", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connectedInfrastructure: [],
|
||||
metrics: [],
|
||||
performance: {
|
||||
apiCallDuration: {},
|
||||
lastPollDuration: 0,
|
||||
pollingStartTime: "",
|
||||
totalApiCalls: 0,
|
||||
failedApiCalls: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
},
|
||||
connectionHealth: {},
|
||||
stats: {
|
||||
startTime: evaluatedAt,
|
||||
uptime: 3600,
|
||||
pollingCycles: 1,
|
||||
webSocketClients: 0,
|
||||
version: "6.1.0-test",
|
||||
},
|
||||
activeAlerts: [],
|
||||
recentlyResolved: [],
|
||||
pveTagColors: {},
|
||||
pveTagStyles: {},
|
||||
lastUpdate: Date.parse(evaluatedAt),
|
||||
resources: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/version", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
version: "6.1.0-test",
|
||||
buildTime: evaluatedAt,
|
||||
gitCommit: "test",
|
||||
isDevelopment: true,
|
||||
isSourceBuild: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/security/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
detailLevel: "authenticated",
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
authUsername: "operator",
|
||||
sessionCapabilities: {
|
||||
demoMode: false,
|
||||
assistantEnabled: true,
|
||||
},
|
||||
presentationPolicy: {
|
||||
demoMode: false,
|
||||
readOnly: false,
|
||||
hideCommercial: false,
|
||||
hideUpgrade: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(100);
|
||||
expect(pageErrors, "The Patrol shell raised a browser error").toEqual([]);
|
||||
expect(consoleErrors, "The Patrol shell logged a browser error").toEqual([]);
|
||||
const shellText = (await page.locator("body").innerText()).trim();
|
||||
expect(shellText, `The authenticated shell was blank at ${page.url()}`).not.toBe("");
|
||||
await expect(page.getByRole("tab", { name: /Patrol/ })).toBeVisible();
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const firstObservedAt = new Date(now.getTime() - 42 * 60_000).toISOString();
|
||||
const lastObservedAt = new Date(now.getTime() - 2 * 60_000).toISOString();
|
||||
const evaluatedAt = now.toISOString();
|
||||
const attentionID = "node-1::metric-threshold:cpu";
|
||||
|
||||
const protectionPosture = {
|
||||
subjectResourceId: "node-1",
|
||||
state: "attention",
|
||||
lastAttemptAt: lastObservedAt,
|
||||
lastSuccessfulPointAt: firstObservedAt,
|
||||
lastVerifiedAt: firstObservedAt,
|
||||
freshness: "current",
|
||||
verification: "stale",
|
||||
coverage: "complete",
|
||||
providerStates: [
|
||||
{
|
||||
provider: "pbs",
|
||||
source: "recovery-points",
|
||||
scope: "primary",
|
||||
jobState: "success",
|
||||
historyCompleteness: "complete",
|
||||
permissions: "sufficient",
|
||||
lastAttemptAt: lastObservedAt,
|
||||
lastSuccessAt: firstObservedAt,
|
||||
lastVerifiedAt: firstObservedAt,
|
||||
evidenceIds: ["backup-evidence-1"],
|
||||
verificationExpected: true,
|
||||
},
|
||||
],
|
||||
repositoryResourceIds: ["pbs:repository:primary"],
|
||||
evidenceIds: ["backup-evidence-1"],
|
||||
explanation:
|
||||
"A current backup exists, but its verification is outside the verification window.",
|
||||
evaluatedAt,
|
||||
};
|
||||
|
||||
const openAttentionItem = {
|
||||
id: attentionID,
|
||||
operationalRecordId: attentionID,
|
||||
subjectResourceId: "node-1",
|
||||
subjectResourceName: "pve-main",
|
||||
subjectResourceType: "agent",
|
||||
title: "CPU pressure on pve-main",
|
||||
plainLanguageSummary:
|
||||
"CPU has remained above the configured threshold for two collection cycles.",
|
||||
severity: "critical",
|
||||
state: "open",
|
||||
firstObservedAt,
|
||||
lastObservedAt,
|
||||
evidenceFreshness: "fresh",
|
||||
evidenceCompleteness: "complete",
|
||||
impact: "Workloads on this node may respond slowly.",
|
||||
protectionPosture,
|
||||
relatedResources: [{ resourceId: "vm-101" }],
|
||||
recommendedNextStep:
|
||||
"Open the node and verify which workload is consuming CPU before making changes.",
|
||||
availableActions: [],
|
||||
verificationState: "not_available",
|
||||
};
|
||||
|
||||
const uncertainAttentionItem = {
|
||||
...openAttentionItem,
|
||||
id: "node-2::connectivity",
|
||||
operationalRecordId: "node-2::connectivity",
|
||||
subjectResourceId: "node-2",
|
||||
subjectResourceName: "pve-edge",
|
||||
title: "Connection state unknown for pve-edge",
|
||||
plainLanguageSummary:
|
||||
"Pulse does not have recent enough evidence to report this node as healthy.",
|
||||
severity: "warning",
|
||||
state: "unknown",
|
||||
evidenceFreshness: "unknown",
|
||||
evidenceCompleteness: "partial",
|
||||
protectionPosture: undefined,
|
||||
relatedResources: [],
|
||||
recommendedNextStep:
|
||||
"Check the provider connection and collect current evidence before deciding whether the node is offline.",
|
||||
};
|
||||
|
||||
const acknowledgedAttentionItem = {
|
||||
...openAttentionItem,
|
||||
id: "node-3::memory",
|
||||
operationalRecordId: "node-3::memory",
|
||||
subjectResourceId: "node-3",
|
||||
subjectResourceName: "pve-lab",
|
||||
title: "Memory pressure acknowledged on pve-lab",
|
||||
state: "acknowledged",
|
||||
};
|
||||
|
||||
const suppressedAttentionItem = {
|
||||
...openAttentionItem,
|
||||
id: "node-4::maintenance",
|
||||
operationalRecordId: "node-4::maintenance",
|
||||
subjectResourceId: "node-4",
|
||||
subjectResourceName: "pve-maintenance",
|
||||
title: "Maintenance alert suppressed on pve-maintenance",
|
||||
state: "suppressed",
|
||||
};
|
||||
|
||||
const resolvedAttentionItem = {
|
||||
...openAttentionItem,
|
||||
id: "node-5::storage",
|
||||
operationalRecordId: "node-5::storage",
|
||||
subjectResourceId: "node-5",
|
||||
subjectResourceName: "pve-recovered",
|
||||
title: "Storage pressure resolved on pve-recovered",
|
||||
state: "resolved",
|
||||
};
|
||||
|
||||
const activeSummary = {
|
||||
activeCount: 2,
|
||||
openCount: 1,
|
||||
acknowledgedCount: 1,
|
||||
suppressedCount: 1,
|
||||
uncertainCount: 1,
|
||||
resolvedCount: 1,
|
||||
calm: false,
|
||||
coverageState: "current",
|
||||
evaluatedAt,
|
||||
};
|
||||
|
||||
const detail = {
|
||||
item: openAttentionItem,
|
||||
operationalRecord: {
|
||||
id: attentionID,
|
||||
canonicalSpecId: "metric-threshold:cpu",
|
||||
subjectResourceId: "node-1",
|
||||
state: "open",
|
||||
severity: "critical",
|
||||
firstObservedAt,
|
||||
lastObservedAt,
|
||||
stateChangedAt: firstObservedAt,
|
||||
evidenceIds: ["metric-evidence-1"],
|
||||
causeKey: attentionID,
|
||||
relatedResourceIds: ["vm-101"],
|
||||
impactSummary: openAttentionItem.impact,
|
||||
recommendedNextStep: openAttentionItem.recommendedNextStep,
|
||||
},
|
||||
timeline: [
|
||||
{
|
||||
id: "transition-1",
|
||||
operationalRecordId: attentionID,
|
||||
from: "observing",
|
||||
to: "open",
|
||||
at: firstObservedAt,
|
||||
cause: "detector_decision",
|
||||
causeKey: attentionID,
|
||||
evidenceIds: ["metric-evidence-1"],
|
||||
reason:
|
||||
"The threshold remained breached for the required confirmation window.",
|
||||
},
|
||||
],
|
||||
evidence: [
|
||||
{
|
||||
id: "metric-evidence-1",
|
||||
source: {
|
||||
provider: "proxmox",
|
||||
collector: "node-metrics",
|
||||
instance: "homelab",
|
||||
},
|
||||
subject: { resourceId: "node-1" },
|
||||
observedAt: lastObservedAt,
|
||||
ingestedAt: lastObservedAt,
|
||||
completeness: "complete",
|
||||
confidence: "confirmed",
|
||||
permissions: "sufficient",
|
||||
reason: {
|
||||
code: "threshold_breach",
|
||||
message: "Two consecutive samples were above 90%.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function mockAttention(
|
||||
page: Page,
|
||||
initialMode: AttentionMode,
|
||||
): Promise<{ setMode: (mode: AttentionMode) => void }> {
|
||||
let mode = initialMode;
|
||||
await page.route("**/api/resources**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname !== "/api/resources") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: [],
|
||||
meta: { page: 1, limit: 100, total: 0, totalPages: 0 },
|
||||
links: { next: null },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/replication/jobs", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "[]",
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/patrol/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
runtime_state: "active",
|
||||
running: false,
|
||||
enabled: true,
|
||||
last_patrol_at: lastObservedAt,
|
||||
next_patrol_at: new Date(now.getTime() + 6 * 60 * 60_000).toISOString(),
|
||||
last_duration_ms: 42_000,
|
||||
resources_checked: 2,
|
||||
findings_count: 0,
|
||||
error_count: 0,
|
||||
healthy: true,
|
||||
interval_ms: 21_600_000,
|
||||
fixed_count: 0,
|
||||
blocked_reason: "",
|
||||
blocked_at: "",
|
||||
license_required: false,
|
||||
license_status: "active",
|
||||
summary: { critical: 0, warning: 0, watch: 0, info: 0 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/patrol/runs*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "[]",
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/patrol/autonomy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
autonomy_level: "monitor",
|
||||
requested_autonomy_level: "monitor",
|
||||
effective_autonomy_level: "monitor",
|
||||
full_mode_unlocked: false,
|
||||
autopilot_acknowledgement: {
|
||||
code: "not_requested",
|
||||
active: false,
|
||||
currentVersion: 1,
|
||||
acceptedScope: [],
|
||||
acceptedLimits: {
|
||||
policyAllowlistRequired: true,
|
||||
emergencyStopHonored: true,
|
||||
approvalFloorsHonored: true,
|
||||
verificationReconciledWhenSupported: true,
|
||||
evidenceClassDisclosed: true,
|
||||
inconclusiveOutcomeAllowed: true,
|
||||
executionSuccessIsNotOutcomeTruth: true,
|
||||
},
|
||||
},
|
||||
investigation_budget: 15,
|
||||
investigation_timeout_sec: 300,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/patrol/findings*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "[]",
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/unified/findings*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ findings: [], count: 0, active_count: 0 }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/intelligence", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
timestamp: evaluatedAt,
|
||||
overall_health: {
|
||||
score: 100,
|
||||
grade: "A",
|
||||
trend: "stable",
|
||||
factors: [],
|
||||
prediction: "No legacy Patrol findings are waiting.",
|
||||
},
|
||||
findings_count: {
|
||||
critical: 0,
|
||||
warning: 0,
|
||||
watch: 0,
|
||||
info: 0,
|
||||
total: 0,
|
||||
},
|
||||
predictions_count: 0,
|
||||
recent_changes_count: 0,
|
||||
recent_changes: [],
|
||||
learning: {
|
||||
resources_with_knowledge: 0,
|
||||
total_notes: 0,
|
||||
resources_with_baselines: 0,
|
||||
patterns_detected: 0,
|
||||
correlations_learned: 0,
|
||||
incidents_tracked: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/intelligence/correlations*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ correlations: [], count: 0 }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/circuit/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
state: "closed",
|
||||
can_patrol: true,
|
||||
consecutive_failures: 0,
|
||||
total_successes: 1,
|
||||
total_failures: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/approvals", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ approvals: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/settings/ai", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
patrol_enabled: true,
|
||||
patrol_interval_minutes: 360,
|
||||
patrol_model: "",
|
||||
model: "",
|
||||
alert_triggered_analysis: false,
|
||||
patrol_alert_triggers_enabled: true,
|
||||
patrol_anomaly_triggers_enabled: false,
|
||||
patrol_event_triggers_enabled: true,
|
||||
patrol_auto_fix: false,
|
||||
auto_fix_model: "",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/models", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ models: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/patrol/attention**", async (route: Route) => {
|
||||
if (mode === "failed") {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
code: "operational_lifecycle_unavailable",
|
||||
message: "Current lifecycle evidence is unavailable.",
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith("/summary")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
mode === "calm"
|
||||
? {
|
||||
activeCount: 0,
|
||||
openCount: 0,
|
||||
acknowledgedCount: 0,
|
||||
suppressedCount: 0,
|
||||
uncertainCount: 0,
|
||||
resolvedCount: 1,
|
||||
calm: true,
|
||||
coverageState: "current",
|
||||
evaluatedAt,
|
||||
}
|
||||
: activeSummary,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.pathname.endsWith("/attention")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(detail),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = url.searchParams.get("filter") ?? "active";
|
||||
const data =
|
||||
mode === "calm"
|
||||
? []
|
||||
: filter === "stale_unknown"
|
||||
? [uncertainAttentionItem]
|
||||
: filter === "open"
|
||||
? [openAttentionItem]
|
||||
: filter === "acknowledged"
|
||||
? [acknowledgedAttentionItem]
|
||||
: filter === "suppressed"
|
||||
? [suppressedAttentionItem]
|
||||
: filter === "resolved"
|
||||
? [resolvedAttentionItem]
|
||||
: [openAttentionItem, uncertainAttentionItem];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
summary:
|
||||
mode === "calm"
|
||||
? {
|
||||
activeCount: 0,
|
||||
openCount: 0,
|
||||
acknowledgedCount: 0,
|
||||
suppressedCount: 0,
|
||||
uncertainCount: 0,
|
||||
resolvedCount: 1,
|
||||
calm: true,
|
||||
coverageState: "current",
|
||||
evaluatedAt,
|
||||
}
|
||||
: activeSummary,
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: data.length,
|
||||
totalPages: data.length > 0 ? 1 : 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
return {
|
||||
setMode: (nextMode) => {
|
||||
mode = nextMode;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("makes active operational work primary and preserves the evidence boundary", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAttention(page, "active");
|
||||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "Patrol: 2 active attention items" }),
|
||||
).toBeVisible();
|
||||
const queue = page.getByRole("region", { name: "Needs attention" });
|
||||
await expect(queue.getByLabel("2 active attention items")).toBeVisible();
|
||||
await expect(
|
||||
queue.getByText(
|
||||
"CPU has remained above the configured threshold for two collection cycles.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Explain with Assistant" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await queue
|
||||
.getByRole("button", { name: "Acknowledged 1", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
queue.getByText("Memory pressure acknowledged on pve-lab"),
|
||||
).toBeVisible();
|
||||
await queue
|
||||
.getByRole("button", { name: "Suppressed 1", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
queue.getByText("Maintenance alert suppressed on pve-maintenance"),
|
||||
).toBeVisible();
|
||||
await queue
|
||||
.getByRole("button", { name: "Stale or unknown 1", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
queue.getByText("Connection state unknown for pve-edge"),
|
||||
).toBeVisible();
|
||||
await queue
|
||||
.getByRole("button", { name: "Recent resolved 1", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
queue.getByText("Storage pressure resolved on pve-recovered"),
|
||||
).toBeVisible();
|
||||
await queue.getByRole("button", { name: "Active 2", exact: true }).click();
|
||||
|
||||
const itemButton = queue.getByRole("button", {
|
||||
name: "Open CPU pressure on pve-main",
|
||||
});
|
||||
await itemButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const detailPanel = page.getByRole("complementary", {
|
||||
name: "CPU pressure on pve-main",
|
||||
});
|
||||
await expect(detailPanel).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText("Workloads on this node may respond slowly."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText(
|
||||
"Open the node and verify which workload is consuming CPU before making changes.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText("Two consecutive samples were above 90%."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText(
|
||||
"A current backup exists, but its verification is outside the verification window.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(detailPanel.getByText("Observing to Open")).toBeVisible();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`attention=${encodeURIComponent(attentionID)}`),
|
||||
);
|
||||
|
||||
await detailPanel
|
||||
.getByRole("button", { name: "Close attention detail" })
|
||||
.click();
|
||||
await expect(itemButton).toBeFocused();
|
||||
await expect(page).not.toHaveURL(/attention=/);
|
||||
});
|
||||
|
||||
test("puts the selected detail in view on a phone without page overflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockAttention(page, "active");
|
||||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const itemButton = page.getByRole("button", {
|
||||
name: "Open CPU pressure on pve-main",
|
||||
});
|
||||
await itemButton.click();
|
||||
|
||||
const detailPanel = page.getByRole("complementary", {
|
||||
name: "CPU pressure on pve-main",
|
||||
});
|
||||
await expect(detailPanel).toBeInViewport();
|
||||
await expect(
|
||||
detailPanel.getByText(
|
||||
"Open the node and verify which workload is consuming CPU before making changes.",
|
||||
),
|
||||
).toBeVisible();
|
||||
const overflows = await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth >
|
||||
document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflows).toBeFalsy();
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.getByRole("complementary", { name: "CPU pressure on pve-main" }),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("shows calm only with current coverage and never converts failure into health", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await mockAttention(page, "calm");
|
||||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByText("Nothing needs your attention")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The current operational lifecycle evaluation has no active items.",
|
||||
{
|
||||
exact: false,
|
||||
},
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
fixture.setMode("failed");
|
||||
await page.getByRole("button", { name: "Refresh Patrol attention" }).click();
|
||||
|
||||
await expect(page.getByText("Patrol attention is unavailable")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Pulse has not inferred a calm or healthy state from this failure.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Nothing needs your attention")).toHaveCount(0);
|
||||
});
|
||||
Reference in New Issue
Block a user