feat(patrol): redesign the operator workbench

This commit is contained in:
rcourtman
2026-08-17 23:41:03 +01:00
parent 3f32148b50
commit 3088f52dc0
25 changed files with 551 additions and 363 deletions
+36 -1
View File
@@ -2670,6 +2670,11 @@
"path": "docs/release-control/v6/internal/subsystems/api-contracts.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/patrol-intelligence.md",
@@ -7462,7 +7467,7 @@
"status": "target-met",
"completion": {
"state": "complete",
"summary": "Monitor-first Patrol operations now has a first-class governed floor: authenticated launch stays on infrastructure monitoring; Patrol owns one contradiction-resistant attention and explain-act-verify workbench over canonical lifecycle, evidence, protection, availability, approval, action, and verification truth; calm states stay honest; and Assistant remains contextual explanation for the selected thing rather than the operations front door.",
"summary": "Monitor-first Patrol operations now has a decision-first, responsive governed floor: authenticated launch stays on infrastructure monitoring; current work outranks configuration; Patrol owns one severity-ordered attention and explain-act-verify workbench over canonical lifecycle, evidence, protection, availability, approval, action, and verification truth; objective setup and verified receipts remain compact supporting context; calm states stay honest; and Assistant remains contextual explanation for the selected thing rather than the operations front door.",
"tracking": []
},
"blockers": [],
@@ -7559,11 +7564,41 @@
"path": "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/patrolPagePresentation.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/attention.go",
@@ -2512,6 +2512,16 @@ a new API state machine, queue contract, or verification-accounting field.
instance-scoped style.
8. Route unified-agent installer and binary download headers through `internal/api/unified_agent.go` and `internal/api/contract_test.go` together. Unified-agent BINARY downloads must keep the canonical `X-Checksum-Sha256` plus `X-Signature-Ed25519` contract for updater clients whether the binary is served locally or proxied from the matching GitHub release, instead of leaving callers to infer trust from source location alone. The served install-script endpoints (GET /install.sh and /install.ps1) are governed differently and have NO GitHub fallback at all: they serve the locally bundled AGENT installer or fail closed with 503. The agent installer is a per-build artifact bundled into every release tarball and Docker image, not a release asset, so the endpoint must never fetch the top-level GitHub install.sh release asset (the SERVER installer, which rejects the agent wizard's --url / --token-file, issue #1470). It attaches the base64-encoded `X-Signature-SSHSIG` header when the local detached signatures are present and omits it when they are not; a present-but-unsigned local agent installer is still served, because the agent install path (curl piped into bash) does not verify these headers, so correctness of the served script outranks signature presence.
9. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts`, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, the Patrol-owned section files under `frontend-modern/src/features/patrol/`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the store normalization owner, runtime hook, feature shell, Current work workspace, section owners, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice
and the decision-first Patrol projection boundary, where API-owned active
attention and pending decisions render before retained-objective setup,
verified receipts, and operational history without introducing a new API
status, severity, or workflow dialect. The browser may order the canonical
attention projection by severity, then actionable review posture, then
observation recency, but it must preserve every API-owned lifecycle,
evidence, protection, action, and verification fact in selected detail.
Secondary receipt responses that are absent or incomplete may degrade to an
unavailable or empty supporting panel; they must not unmount the current
attention workbench or convert missing receipts into successful work
while keeping the universal governed-operation ledger as one subordinate
handoff rather than a second Patrol implementation: the surface may show
the durable action inbox's content-free pending count and link to
@@ -773,12 +773,15 @@ presentation helpers and render them inside the shared neutral Pulse surface
language rather than introducing page-local verdict bands or nested cards.
The Patrol operator home composes the shared `PageHeader`, `Button`, `Toggle`,
`MetadataBadge`, native disclosure, dialog, and neutral bordered-surface
patterns. Patrol owns the meaning and order of background posture, objectives,
genuine decisions, verified receipts, and secondary operational records; the
patterns. Patrol owns the meaning and order of background posture, genuine
decisions, objectives, verified receipts, and secondary operational records; the
shared primitive boundary owns keyboard focus, touch targets, responsive
stacking, and visual consistency. Plan-locked paid-mode discovery is not a
daily-page primitive and must not be added beside the effective mode.
The compact `Recently handled` list consumes server-authored Patrol work
Current API-owned decisions must follow the compact posture header and precede
objective configuration at every viewport; an empty objective state must not
push current work below the first useful screen. The compact `Verified outcomes`
list consumes server-authored Patrol work
receipts rather than filtering generic history in the browser. It may format
the canonical capability name and verification time with shared presentation
helpers, and it reuses the action resource presenter for durable resources that
@@ -2600,14 +2603,16 @@ 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. Scan rows omit unavailable protection metadata and collapse evidence
freshness/completeness into one operator phrase; selected detail keeps the typed
facts with named fields and plain unavailable-state labels rather than raw token
pairs.
The default queue shows severity, lifecycle state, plain-language consequence,
resource, the required review posture, and observation age. It sorts severity
first, then items with a governed approval or existing action, then newest
observation, while retaining the server-authored membership. Provider evidence,
protection posture, lifecycle controls, and history belong in selected detail;
legacy Patrol analytics belong in collapsed supporting context. Unavailable and
partial states must use explicit copy rather than success styling. Selected
detail keeps the typed facts with named fields and plain unavailable-state
labels rather than raw token pairs. On narrow viewports, selecting a row moves
and focuses that decision context without horizontal page overflow.
The selected attention detail may compose the shared Actions review for an
eligible backend-authored offer. The detail owns only the expected
@@ -76,10 +76,15 @@ sources, and retains the note as operator context.
The first-party Patrol page is an outcome and exception surface, not a mirror of
the internal lifecycle. Its primary order is the effective background posture,
the outcomes Patrol is looking after, work that genuinely needs the operator,
and concise recent receipts. Investigation internals, lifecycle filters, raw
findings, checks, and run history remain available as secondary operational
records without competing with those four questions.
work that genuinely needs the operator, the outcomes Patrol is looking after,
and concise recent receipts. Active work must outrank objective configuration
on the daily-use route, especially on a narrow viewport; an empty objectives
surface cannot push current decisions below the first useful screen.
Investigation internals, lifecycle filters, raw findings, checks, and run
history remain available as secondary operational records without competing
with those four questions. Action review/audit and finding/run records use one
subordinate `Review and history` handoff area so the page does not present
several competing history concepts.
`patrolHomePresentation.ts` owns the deterministic projection from effective
autonomy and typed attention evidence into `needs user` versus `can continue`
@@ -1199,6 +1204,28 @@ The durable decision and proof record is
## Current State
The Patrol 2.0 frontend now uses a decision-first information hierarchy. The
compact command header owns enabled posture, effective mode, last/next check
context, manual check, settings, and the secondary mode disclosure. The typed
attention workbench is the first substantial page surface on desktop and
mobile. Protected objectives and proof-constrained verified outcomes follow as
supporting continuity surfaces; empty objectives and empty receipts stay
compact instead of consuming the prime viewport. Universal governed actions
and Patrol-specific finding/run records are exposed together through the
subordinate `Review and history` handoff area.
The attention queue orders genuine operator decisions by severity, actionable
approval/review state, and latest observation time. Rows expose the severity
word, lifecycle state, bounded consequence, affected resource, decision label,
and freshness without repeating full mode-policy prose on every item. Selecting
an item opens one responsive decision context: the current summary, affected
resource, impact and recommended next step precede governed action review;
occurrence acknowledgement/suppression, evidence, protection posture, timeline,
resource navigation, and contextual Assistant explanation remain available
without displacing the primary decision. Narrow selection scrolls and focuses
that context before the queue so keyboard and screen-reader users do not have
to traverse the list again.
The active Patrol queue now uses compact severity-accented rows for
prioritisation and a single focused review panel for the selected issue. The
collapsed row carries severity, actionable state, title, resource and recency,
+34 -35
View File
@@ -1,29 +1,34 @@
{
"version": 1,
"base_sha": "8e32afa1856cce6defecf8e3596d76cdf1113147",
"verified_at": "2026-08-17T22:05:00Z",
"base_sha": "3f32148b50c460320349fbadff5652ba5a8f4391",
"verified_at": "2026-08-17T22:38:49Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/hooks/useUnifiedResources.ts"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx",
"frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx",
"frontend-modern/src/features/patrol/patrolHomePresentation.ts",
"frontend-modern/src/utils/patrolPagePresentation.ts"
],
"content_sha256": {
"frontend-modern/src/hooks/useUnifiedResources.ts": "a2c8ba30cd723792c3c9c7a8b7485d11ec496c50ad2888069325e9fc08f765a8"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "e9b9bf575863cfabb7b9f25818792882093313441c375c20174f8ccb695d749b",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "9adbd9fd4c9b5ae1e22c768a8034c570d5bc3b5f382bae002afffd82798022f0",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "b974431efa5fc4e4c7b8837d8678f99708ed63b22d268c76d9cf1409df56c64e",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "c9dc90ac77b57dc69718beed2ada4b47127a92900b229a5ae3e8dbf389c2461d",
"frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx": "b9fea954dc881a07d0bc97ec1401fefd5777aaef623bf7c1ce07f96d5f5b033b",
"frontend-modern/src/features/patrol/patrolHomePresentation.ts": "724ca2540acad8ab5b154008ce4c066bd84f1e4354c5da3198c5b580f2f9c38b",
"frontend-modern/src/utils/patrolPagePresentation.ts": "142505e839feed425ae0163adda1bf829d5b71075abf684898012f990f0f6326"
},
"routes": [
"/login",
"/",
"/?platform=proxmox&tab=storage",
"/?platform=proxmox&tab=backups",
"/?platform=proxmox&tab=ceph",
"/?platform=docker",
"/?platform=kubernetes",
"/?platform=truenas",
"/?platform=vsphere"
"/patrol",
"/patrol?attention=<attention-id>"
],
"viewports": [
{
"width": 1440,
"height": 900
"width": 1280,
"height": 720
},
{
"width": 390,
@@ -31,27 +36,21 @@
}
],
"states": [
"signed-out login form",
"post-sign-in loading state before the first snapshot lands",
"Proxmox overview populated with 79 rows against a 209-resource estate",
"node row expanded to its guest children",
"guest table filtered by a search term",
"Proxmox Storage, Backups and Ceph sub-tabs populated",
"Docker (80 rows), Kubernetes (180 rows), TrueNAS (3 rows) and vSphere (32 rows) platform pages populated",
"realtime transport connected, websocket snapshot racing the canonical fetch",
"realtime transport unavailable, canonical fetch as the only path to first paint",
"Proxmox overview at 390x844 with a row expanded and filtered"
"Watch only with 12 current attention items and one governed action awaiting approval",
"decision queue before selection with critical items sorted ahead of warnings",
"selected attention item with impact, recommended next step, lifecycle controls and evidence",
"empty protected-outcomes setup state",
"empty verified-outcomes receipt state",
"collapsed mode-and-automation disclosure",
"collapsed findings-and-run-records disclosure",
"mobile queue and selected decision context"
],
"interactions": [
"signed in and confirmed the canonical fetch is no longer held behind the websocket hydration wait",
"measured first paint on a production bundle with the realtime transport unavailable: 5083-6384ms before, 3349-4860ms after",
"confirmed the bootstrap-to-canonical-fetch gap fell from 1720-1882ms to 519-613ms",
"clicked a node row to expand its guests (79 rows to 80)",
"typed 'backup' into the guest search field and confirmed the table narrowed to 9 rows",
"cleared the search field and confirmed the full table returned",
"opened the Proxmox Storage, Backups and Ceph sub-tabs and returned to Overview",
"switched through the Docker, Kubernetes, TrueNAS and vSphere platform tabs and back to Proxmox, confirming each renders its own rows",
"repeated the whole pass with the websocket connected and again with it unavailable, with identical row counts and no console errors on the connected run",
"resized to 390x844, expanded a row (79 rows to 80) and filtered to 9 rows"
"opened Patrol from the authenticated runtime and confirmed current work precedes outcome configuration and history",
"selected the highest-severity attention item and confirmed the decision-context route state",
"confirmed the selected mobile decision scrolls into view and receives keyboard focus",
"confirmed the 390px layout has no horizontal page overflow",
"confirmed the primary Check now and secondary Settings controls remain visible at both viewports",
"confirmed Actions and approvals and Findings and run records remain available as subordinate review paths"
]
}
@@ -64,6 +64,35 @@ import {
const PRIMARY_EVIDENCE_LIMIT = 3;
const PRIMARY_DECISION_LIMIT = 5;
const SEVERITY_PRIORITY: Record<AttentionItem['severity'], number> = {
critical: 0,
warning: 1,
info: 2,
unknown: 3,
};
export function sortPatrolAttentionDecisions(
decisions: PatrolAttentionDecision[],
): PatrolAttentionDecision[] {
return [...decisions].sort((left, right) => {
const severityDelta =
SEVERITY_PRIORITY[left.item.severity] - SEVERITY_PRIORITY[right.item.severity];
if (severityDelta !== 0) return severityDelta;
const leftActionable = left.item.availableActions.some(
(action) => action.approval === 'required' || action.requiresApproval || action.actionId,
);
const rightActionable = right.item.availableActions.some(
(action) => action.approval === 'required' || action.requiresApproval || action.actionId,
);
if (leftActionable !== rightActionable) return leftActionable ? -1 : 1;
return (
new Date(right.item.lastObservedAt).getTime() - new Date(left.item.lastObservedAt).getTime()
);
});
}
export function PatrolAttentionWorkbench(
props: {
autonomyLevel?: PatrolAutonomyLevel;
@@ -98,8 +127,8 @@ export function PatrolAttentionWorkbench(
);
const visibleDecisions = createMemo(() =>
showAllDecisions()
? attention().needsUser
: attention().needsUser.slice(0, PRIMARY_DECISION_LIMIT),
? sortPatrolAttentionDecisions(attention().needsUser)
: sortPatrolAttentionDecisions(attention().needsUser).slice(0, PRIMARY_DECISION_LIMIT),
);
const loadCurrentFilter = () => patrolAttentionStore.load(patrolAttentionStore.filter());
@@ -107,6 +136,7 @@ export function PatrolAttentionWorkbench(
queueMicrotask(() => {
if (window.matchMedia?.('(max-width: 1023px)').matches) {
detailPanel?.scrollIntoView?.({ block: 'start' });
detailPanel?.focus?.({ preventScroll: true });
}
});
};
@@ -203,7 +233,7 @@ export function PatrolAttentionWorkbench(
const decisionCountLabel = createMemo(() => {
const count = attention().needsUser.length;
return `${count} ${count === 1 ? 'decision needs' : 'decisions need'} you`;
return `${count} ${count === 1 ? 'item requires' : 'items require'} review`;
});
return (
@@ -216,7 +246,7 @@ export function PatrolAttentionWorkbench(
<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 you
Needs your attention
</h2>
<Show when={summary()}>
<MetadataBadge
@@ -225,7 +255,7 @@ export function PatrolAttentionWorkbench(
shape="rounded"
aria-label={decisionCountLabel()}
>
{attention().needsUser.length}
{attention().needsUser.length} to review
</MetadataBadge>
</Show>
</div>
@@ -258,10 +288,10 @@ export function PatrolAttentionWorkbench(
</div>
<div
class={`grid min-w-0 ${selectedItemId() ? 'lg:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.2fr)]' : ''}`}
class={`grid min-w-0 ${selectedItemId() ? 'lg:grid-cols-[minmax(20rem,0.82fr)_minmax(0,1.18fr)]' : ''}`}
>
<div
class={`min-w-0 ${selectedItemId() ? 'border-b border-border lg:border-b-0 lg:border-r' : ''}`}
class={`min-w-0 ${selectedItemId() ? 'border-b border-border lg:max-h-[48rem] lg:overflow-y-auto lg:border-b-0 lg:border-r' : ''}`}
>
<AttentionList
decisions={visibleDecisions()}
@@ -271,7 +301,11 @@ export function PatrolAttentionWorkbench(
/>
</div>
<Show when={selectedItemId()}>
<div ref={detailPanel} class="order-first min-w-0 scroll-mt-4 lg:order-none">
<div
ref={detailPanel}
tabindex="-1"
class="order-first min-w-0 scroll-mt-4 lg:order-none lg:max-h-[48rem] lg:overflow-y-auto"
>
<AttentionDetail
detail={selectedDetail()}
loading={patrolAttentionStore.detailLoading()}
@@ -363,6 +397,19 @@ function AttentionList(props: {
<For each={props.decisions}>
{(decision) => {
const item = decision.item;
const decisionLabel = () => {
if (
item.availableActions.some(
(action) => action.approval === 'required' || action.requiresApproval,
)
) {
return 'Approval needed';
}
if (item.verificationState === 'failed' || item.verificationState === 'unknown') {
return 'Verify result';
}
return 'Review';
};
return (
<li>
<button
@@ -378,10 +425,12 @@ function AttentionList(props: {
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">
<MetadataBadge tone={severityTone(item)} size="xs" shape="rounded">
{formatLabel(item.severity)}
</MetadataBadge>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-base-content">
{item.title}
</span>
<StateBadge item={item} />
@@ -389,17 +438,18 @@ function AttentionList(props: {
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">
{item.plainLanguageSummary}
</p>
<p class="mt-1 text-xs font-medium leading-5 text-amber-700 dark:text-amber-300">
{decision.reason}
</p>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<span
class="font-semibold text-amber-700 dark:text-amber-300"
title={decision.reason}
>
{decisionLabel()}
</span>
<span class="truncate font-medium text-base-content">
{item.subjectResourceName}
</span>
<EvidenceLabel item={item} />
<ProtectionLabel item={item} />
<span>
{formatRelativeTime(item.firstObservedAt, { compact: true })}
Last seen {formatRelativeTime(item.lastObservedAt, { compact: true })}
</span>
</div>
</div>
@@ -563,7 +613,7 @@ function AttentionDetail(props: {
<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
Decision context
</p>
<h3 id="attention-detail-title" class="mt-1 text-sm font-semibold text-base-content">
{item()?.title ?? 'Loading attention item'}
@@ -601,17 +651,6 @@ function AttentionDetail(props: {
</p>
</section>
<AttentionLifecycleControls
detail={loaded()}
busy={props.lifecycleBusy}
error={props.lifecycleError}
onAcknowledge={props.onAcknowledge}
onUnacknowledge={props.onUnacknowledge}
onSuppress={props.onSuppress}
onUnsuppress={props.onUnsuppress}
onOpenFindings={props.onOpenFindings}
/>
<DetailSection title="Affected resource">
<p class="text-sm font-medium text-base-content">
{loaded().item.subjectResourceName}
@@ -682,6 +721,17 @@ function AttentionDetail(props: {
)}
</Show>
<AttentionLifecycleControls
detail={loaded()}
busy={props.lifecycleBusy}
error={props.lifecycleError}
onAcknowledge={props.onAcknowledge}
onUnacknowledge={props.onUnacknowledge}
onSuppress={props.onSuppress}
onUnsuppress={props.onUnsuppress}
onOpenFindings={props.onOpenFindings}
/>
<DetailSection title="Evidence">
<Show
when={orderedEvidence().length > 0}
@@ -1098,26 +1148,17 @@ function EvidenceObservation(props: { evidence: EvidenceEnvelope }) {
);
}
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 severityTone(item: AttentionItem): MetadataBadgeTone {
switch (item.severity) {
case 'critical':
return 'danger';
case 'warning':
return 'warning';
case 'info':
return 'info';
default:
return 'muted';
}
}
function StateBadge(props: { item: AttentionItem }) {
@@ -301,8 +301,8 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
class="relative z-[200] mb-3"
/>
<section class="rounded-lg border border-border bg-surface px-4 py-4 shadow-sm sm:px-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<section class="overflow-hidden rounded-lg border border-border bg-surface shadow-sm">
<div class="flex flex-col gap-4 px-4 py-4 sm:px-5 lg:flex-row lg:items-start lg:justify-between">
<div class="flex min-w-0 items-start gap-3">
<div class="pt-0.5">
<TogglePrimitive
@@ -316,11 +316,9 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-semibold text-base-content">
{state.patrolEnabledLocal()
? 'Patrol is looking after your infrastructure'
: 'Patrol is off'}
{state.patrolEnabledLocal() ? 'Watching your infrastructure' : 'Patrol is off'}
</h2>
<span class="rounded-full border border-border-subtle bg-surface-alt px-2 py-0.5 text-xs font-medium text-base-content">
<span class="rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-800 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-200">
{selectedAutonomyExperience().label}
</span>
</div>
@@ -333,41 +331,42 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
<p class="mt-1 text-xs leading-5 text-muted">{triggerStatusSummary()}</p>
</Show>
<Show when={!state.shouldShowPatrolSetupOnly() && recency().timestamp}>
<p class="mt-1 text-xs leading-5 text-muted">
{recency().label}:{' '}
{formatRelativeTime(recency().timestamp, {
compact: true,
emptyText: 'Never',
})}
<div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-5 text-muted">
<span>
{recency().label}:{' '}
{formatRelativeTime(recency().timestamp, {
compact: true,
emptyText: 'Never',
})}
</span>
<Show when={recency().resourcesCheckedLabel}>
{' '}
— {recency().resourcesCheckedLabel}
<span aria-hidden="true">·</span>
<span>{recency().resourcesCheckedLabel}</span>
</Show>
<Show when={state.patrolStatus()?.next_patrol_at}>
{' '}
·{' '}
<span aria-hidden="true">·</span>
<CountdownTimer
targetDate={state.patrolStatus()!.next_patrol_at!}
prefix="Next check: "
prefix="Next check "
class="font-variant-numeric tabular-nums"
/>
</Show>
</p>
</div>
</Show>
</div>
</div>
<Show when={!state.shouldShowPatrolSetupOnly()}>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<div class="grid w-full shrink-0 grid-cols-2 gap-2 lg:flex lg:w-auto lg:flex-wrap lg:items-center">
{renderRunControl(
'flex min-h-11 items-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover:bg-surface-alt disabled:text-muted sm:min-h-0',
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-blue-600 bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:border-border disabled:bg-surface-alt disabled:text-muted sm:min-h-0',
)}
<Show when={!runBlockedByProviderSetup()}>
<A
href={settingsTabPath('system-ai-patrol')}
aria-label="Open Patrol settings"
title="Open Patrol settings"
class="flex min-h-11 items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover:bg-surface-alt sm:min-h-0"
class="flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover:bg-surface-alt sm:min-h-0"
>
<SettingsIcon class="h-4 w-4" />
Settings
@@ -377,9 +376,9 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
</Show>
</div>
<details id={PATROL_CONTROL_ANCHOR} class="mt-4 border-t border-border-subtle pt-3">
<details id={PATROL_CONTROL_ANCHOR} class="border-t border-border-subtle px-4 py-3 sm:px-5">
<summary class="min-h-11 cursor-pointer text-sm font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:min-h-0">
How Patrol operates
Mode and automation
</summary>
<div class="pt-3">
<span id={PATROL_OPERATIONS_LOOP_ANCHOR} class="sr-only" aria-hidden="true" />
@@ -1,5 +1,7 @@
import { createSignal, Show } from 'solid-js';
import ListChecksIcon from 'lucide-solid/icons/list-checks';
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import ClipboardCheckIcon from 'lucide-solid/icons/clipboard-check';
import HistoryIcon from 'lucide-solid/icons/history';
import { ButtonLink } from '@/components/shared/Button';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
@@ -15,94 +17,127 @@ import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel';
export function PatrolIntelligenceSurface() {
const state = usePatrolIntelligenceState();
const [findingsOpen, setFindingsOpen] = createSignal(false);
let findingsSummary: HTMLElement | undefined;
let findingsPanel: HTMLDetailsElement | undefined;
const openWorkCount = () => aiIntelligenceStore.patrolOpenWorkCount;
const openFindings = () => {
setFindingsOpen(true);
queueMicrotask(() => {
findingsSummary?.scrollIntoView?.({ block: 'start' });
findingsSummary?.focus?.({ preventScroll: true });
findingsPanel?.scrollIntoView?.({ block: 'start' });
findingsPanel?.focus?.({ preventScroll: true });
});
};
return (
<div class="space-y-6">
<div class="space-y-5 lg:space-y-6">
<PatrolIntelligenceHeader state={state} />
<PatrolIntelligenceBanners state={state} />
<PatrolObjectivesPanel />
<PatrolAttentionWorkbench
autonomyLevel={state.autonomyLevel()}
autonomyLocked={state.autoFixLocked()}
onOpenFindings={openFindings}
/>
<div class="grid min-w-0 gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(20rem,0.85fr)]">
<PatrolObjectivesPanel />
<PatrolRecentWorkPanel />
</div>
<section
aria-labelledby="patrol-activity-history-title"
class="flex flex-col gap-4 rounded-lg border border-border bg-surface px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5"
aria-labelledby="patrol-continuity-title"
class="rounded-lg border border-border bg-surface"
>
<div class="flex min-w-0 items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
<ListChecksIcon class="h-5 w-5" aria-hidden="true" />
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2
id="patrol-activity-history-title"
class="text-base font-semibold text-base-content"
>
Activity history
</h2>
<Show when={actionInboxStore.pendingActionCount > 0}>
<MetadataBadge
tone="warning"
size="sm"
shape="rounded"
aria-label={`${actionInboxStore.pendingActionCount} ${actionInboxStore.pendingActionCount === 1 ? 'operation needs' : 'operations need'} review`}
>
{actionInboxStore.pendingActionCount} to review
</MetadataBadge>
</Show>
</div>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Audit every governed operation, including work started elsewhere in Pulse.
</p>
</div>
<div class="border-b border-border px-4 py-4 sm:px-5">
<h2 id="patrol-continuity-title" class="text-base font-semibold text-base-content">
Review and history
</h2>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Follow pending decisions, audit completed operations, or inspect Patrol's underlying
records.
</p>
</div>
<div class="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0">
<ButtonLink
href="/actions"
variant="ghost"
size="md"
class="group h-auto min-h-20 w-full justify-between rounded-none px-4 py-3 text-left sm:px-5"
>
<span class="flex min-w-0 items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
<ClipboardCheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-base-content">
Actions and approvals
<Show when={actionInboxStore.pendingActionCount > 0}>
<MetadataBadge
tone="warning"
size="xs"
shape="rounded"
aria-label={`${actionInboxStore.pendingActionCount} ${actionInboxStore.pendingActionCount === 1 ? 'operation needs' : 'operations need'} review`}
>
{actionInboxStore.pendingActionCount} waiting
</MetadataBadge>
</Show>
</span>
<span class="mt-1 block text-xs font-normal leading-5 text-muted">
Review governed actions and their complete audit trail.
</span>
</span>
</span>
<ArrowRightIcon
class="h-4 w-4 shrink-0 text-muted transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</ButtonLink>
<button
type="button"
class="group flex min-h-20 w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5"
aria-expanded={findingsOpen()}
aria-controls="patrol-operational-records"
onClick={() => setFindingsOpen((open) => !open)}
>
<span class="flex min-w-0 items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
<HistoryIcon class="h-5 w-5" aria-hidden="true" />
</span>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-base-content">
Findings and run records
<Show when={openWorkCount() > 0}>
<MetadataBadge
tone="info"
size="xs"
shape="rounded"
aria-label={`${openWorkCount()} open patrol ${openWorkCount() === 1 ? 'finding' : 'findings'}`}
>
{openWorkCount()} open
</MetadataBadge>
</Show>
</span>
<span class="mt-1 block text-xs font-normal leading-5 text-muted">
Inspect finding detail, check results, and historical runs.
</span>
</span>
</span>
<ArrowRightIcon
class={`h-4 w-4 shrink-0 text-muted transition-transform ${findingsOpen() ? 'rotate-90' : ''}`}
aria-hidden="true"
/>
</button>
</div>
<ButtonLink
href="/actions"
variant="secondary"
size="sm"
class="w-full shrink-0 justify-center sm:w-auto"
>
Open activity history
</ButtonLink>
</section>
<PatrolRecentWorkPanel />
<details
ref={findingsPanel}
id="patrol-operational-records"
tabindex="-1"
class="rounded-lg border border-border bg-surface"
open={findingsOpen()}
onToggle={(event) => setFindingsOpen(event.currentTarget.open)}
>
<summary
ref={findingsSummary}
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"
>
<span class="inline-flex flex-wrap items-center gap-2">
Operational records and run history
<Show when={openWorkCount() > 0}>
<MetadataBadge
tone="info"
size="xs"
shape="rounded"
aria-label={`${openWorkCount()} open patrol ${openWorkCount() === 1 ? 'finding' : 'findings'}`}
>
{openWorkCount()} open
</MetadataBadge>
</Show>
</span>
</summary>
<summary class="sr-only">Findings and run records</summary>
<div
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
>
@@ -200,11 +200,11 @@ export const PatrolObjectivesPanel: Component = () => {
<div class="flex flex-col gap-3 border-b border-border px-4 py-4 sm:flex-row sm:items-start sm:justify-between sm:px-5">
<div>
<h2 id="patrol-objectives-title" class="text-base font-semibold text-base-content">
What Patrol is looking after
Protected outcomes
</h2>
<p class="mt-1 max-w-3xl text-sm text-muted">
Describe the outcome. Patrol chooses a cheap local signal, wakes the model only when
evidence changes, and handles any fix according to your Patrol mode.
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Tell Patrol what must stay true. It builds the smallest useful background signal and
reports when the outcome changes.
</p>
</div>
<Button variant="primary" size="sm" onClick={(event) => openCreate(event.currentTarget)}>
@@ -216,7 +216,7 @@ export const PatrolObjectivesPanel: Component = () => {
<div class="p-4 sm:p-5">
<Show
when={!loading()}
fallback={<p class="py-6 text-center text-sm text-muted">Loading objectives…</p>}
fallback={<p class="py-3 text-sm text-muted">Loading protected outcomes…</p>}
>
<Show
when={!loadError()}
@@ -229,109 +229,111 @@ export const PatrolObjectivesPanel: Component = () => {
</div>
}
>
<div
class={`mb-4 rounded-lg border px-4 py-3 ${
protectionSummary().tone === 'success'
? 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-900 dark:bg-emerald-950/20'
: protectionSummary().tone === 'warning'
? 'border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/20'
: 'border-border-subtle bg-surface-alt/40'
}`}
aria-live="polite"
>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<p class="text-sm font-semibold text-base-content">
{protectionSummary().headline}
</p>
<Show when={protectionSummary().paused > 0}>
<MetadataBadge tone="neutral" size="xs" shape="rounded">
{protectionSummary().paused} paused
</MetadataBadge>
</Show>
</div>
<p class="mt-1 text-xs leading-5 text-muted">{protectionSummary().detail}</p>
</div>
<Show
when={objectives().length > 0}
fallback={
<div class="rounded-lg border border-dashed border-border px-4 py-8 text-center">
<p class="text-sm font-medium text-base-content">No retained outcomes yet</p>
<p class="mx-auto mt-1 max-w-xl text-sm text-muted">
Try “Keep my cameras available” or “Keep disk use below 85%”. You set the
outcome; Patrol works out how to observe it safely.
<div class="flex min-h-24 flex-col justify-center rounded-lg border border-dashed border-border px-4 py-4">
<p class="text-sm font-semibold text-base-content">Choose what matters most</p>
<p class="mt-1 text-xs leading-5 text-muted">
For example, “Keep my cameras available” or “Keep disk use below 85%”. Patrol
works out how to observe it safely.
</p>
</div>
}
>
<div class="divide-y divide-border rounded-lg border border-border">
<For each={objectives()}>
{(objective) => {
const presentation = () => coveragePresentation(objective.coverage);
const scopeLabel = () =>
objective.scope.resource_ids.length === 0
? 'Entire estate'
: `${objective.scope.resource_ids.length} selected ${objective.scope.resource_ids.length === 1 ? 'resource' : 'resources'}`;
return (
<article class="flex flex-col gap-3 p-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="break-words text-sm font-semibold text-base-content">
{objective.brief}
</h3>
<Show when={objective.status === 'paused'}>
<MetadataBadge tone="neutral" size="xs" shape="rounded">
Paused
<>
<div
class={`mb-4 rounded-lg border px-4 py-3 ${
protectionSummary().tone === 'success'
? 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-900 dark:bg-emerald-950/20'
: protectionSummary().tone === 'warning'
? 'border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/20'
: 'border-border-subtle bg-surface-alt/40'
}`}
aria-live="polite"
>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<p class="text-sm font-semibold text-base-content">
{protectionSummary().headline}
</p>
<Show when={protectionSummary().paused > 0}>
<MetadataBadge tone="neutral" size="xs" shape="rounded">
{protectionSummary().paused} paused
</MetadataBadge>
</Show>
</div>
<p class="mt-1 text-xs leading-5 text-muted">{protectionSummary().detail}</p>
</div>
<div class="divide-y divide-border rounded-lg border border-border">
<For each={objectives()}>
{(objective) => {
const presentation = () => coveragePresentation(objective.coverage);
const scopeLabel = () =>
objective.scope.resource_ids.length === 0
? 'Entire estate'
: `${objective.scope.resource_ids.length} selected ${objective.scope.resource_ids.length === 1 ? 'resource' : 'resources'}`;
return (
<article class="flex flex-col gap-3 p-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="break-words text-sm font-semibold text-base-content">
{objective.brief}
</h3>
<Show when={objective.status === 'paused'}>
<MetadataBadge tone="neutral" size="xs" shape="rounded">
Paused
</MetadataBadge>
</Show>
<MetadataBadge tone={presentation().tone} size="xs" shape="rounded">
{presentation().label}
</MetadataBadge>
</Show>
<MetadataBadge tone={presentation().tone} size="xs" shape="rounded">
{presentation().label}
</MetadataBadge>
</div>
<p class="mt-1 text-sm text-muted">{objective.coverage.summary}</p>
<p class="mt-2 text-xs text-muted">{scopeLabel()}</p>
</div>
<p class="mt-1 text-sm text-muted">{objective.coverage.summary}</p>
<p class="mt-2 text-xs text-muted">{scopeLabel()}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
disabled={mutatingId() === objective.id}
onClick={() =>
void setObjectiveStatus(
objective,
objective.status === 'paused' ? 'active' : 'paused',
)
}
>
{objective.status === 'paused' ? (
<PlayIcon class="mr-2 h-4 w-4" />
) : (
<PauseIcon class="mr-2 h-4 w-4" />
)}
{objective.status === 'paused' ? 'Resume' : 'Pause'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(event) => openEdit(objective, event.currentTarget)}
>
<PencilIcon class="mr-2 h-4 w-4" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
disabled={mutatingId() === objective.id}
onClick={() => void removeObjective(objective)}
>
<TrashIcon class="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</article>
);
}}
</For>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
disabled={mutatingId() === objective.id}
onClick={() =>
void setObjectiveStatus(
objective,
objective.status === 'paused' ? 'active' : 'paused',
)
}
>
{objective.status === 'paused' ? (
<PlayIcon class="mr-2 h-4 w-4" />
) : (
<PauseIcon class="mr-2 h-4 w-4" />
)}
{objective.status === 'paused' ? 'Resume' : 'Pause'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(event) => openEdit(objective, event.currentTarget)}
>
<PencilIcon class="mr-2 h-4 w-4" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
disabled={mutatingId() === objective.id}
onClick={() => void removeObjective(objective)}
>
<TrashIcon class="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</article>
);
}}
</For>
</div>
</>
</Show>
</Show>
</Show>
@@ -25,7 +25,9 @@ export function PatrolRecentWorkPanel() {
if (!quiet) setLoading(true);
try {
const response = await getPatrolWorkReceipts(RECEIPT_LIMIT);
setItems(response.data);
// Keep this secondary panel from taking down the primary workbench if an
// older server or a partially available endpoint omits its receipts.
setItems(Array.isArray(response.data) ? response.data : []);
setError('');
} catch (cause) {
setError(formatRecentWorkError(cause));
@@ -49,16 +51,16 @@ export function PatrolRecentWorkPanel() {
return (
<section
class="overflow-hidden rounded-lg border border-border bg-surface"
class="h-full overflow-hidden rounded-lg border border-border bg-surface"
aria-labelledby="patrol-recent-work-title"
>
<div class="flex items-start justify-between gap-3 border-b border-border px-4 py-4 sm:px-5">
<div>
<h2 id="patrol-recent-work-title" class="text-base font-semibold text-base-content">
Recently handled
Verified outcomes
</h2>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Concise receipts appear only after Patrol has verified the outcome.
Work appears here only after Patrol confirms the expected result.
</p>
</div>
<Button
@@ -94,13 +96,15 @@ export function PatrolRecentWorkPanel() {
<Show
when={receipts().length > 0}
fallback={
<div class="px-6 py-8 text-center">
<CheckCircleIcon class="mx-auto h-8 w-8 text-muted" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">No verified work yet</h3>
<p class="mx-auto mt-1 max-w-lg text-xs leading-5 text-muted">
Patrol will record a receipt here after an issue is resolved and its expected
outcome is confirmed.
</p>
<div class="flex min-h-24 items-start gap-3 px-4 py-4 sm:px-5">
<CheckCircleIcon class="mt-0.5 h-5 w-5 shrink-0 text-muted" aria-hidden="true" />
<div>
<h3 class="text-sm font-semibold text-base-content">No verified outcomes yet</h3>
<p class="mt-1 text-xs leading-5 text-muted">
Patrol records a receipt after an issue is resolved and the expected result is
confirmed.
</p>
</div>
</div>
}
>
@@ -66,7 +66,10 @@ vi.mock('@/features/actions/ActionReviewDialog', async () => {
};
});
import { PatrolAttentionWorkbench } from '../PatrolAttentionWorkbench';
import {
PatrolAttentionWorkbench,
sortPatrolAttentionDecisions,
} from '../PatrolAttentionWorkbench';
import { patrolAttentionStore } from '@/stores/patrolAttention';
const evaluatedAt = '2026-07-19T08:00:00Z';
@@ -226,7 +229,9 @@ describe('PatrolAttentionWorkbench', () => {
apiMocks.getList.mockResolvedValue(listResponse([item()], attentionSummary));
renderWorkbench();
expect(await screen.findByRole('heading', { name: 'Needs you' })).toBeInTheDocument();
expect(
await screen.findByRole('heading', { name: 'Needs your attention' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Open Disk pressure on Database VM' }),
).toBeInTheDocument();
@@ -297,6 +302,41 @@ describe('PatrolAttentionWorkbench', () => {
expect(screen.getByRole('button', { name: 'Show fewer decisions' })).toBeInTheDocument();
});
it('orders the operator queue by severity, actionable decisions, and freshest evidence', () => {
const action = {
targetResourceId: 'pve:vm:101',
capability: 'expand_disk',
kind: 'storage',
label: 'Expand disk',
mode: 'execute' as const,
risk: 'medium',
approval: 'required' as const,
eligibility: 'eligible' as const,
reasons: [],
evidenceIds: ['evidence-1'],
expectedPostcondition: 'Disk pressure clears.',
verificationPolicy: 'disk-pressure',
requiresApproval: true,
};
const decisions = [
item({ id: 'warning', severity: 'warning', lastObservedAt: '2026-07-19T09:00:00Z' }),
item({ id: 'critical-old', lastObservedAt: '2026-07-19T07:30:00Z' }),
item({
id: 'critical-action',
lastObservedAt: '2026-07-19T07:00:00Z',
availableActions: [action],
}),
item({ id: 'critical-new', lastObservedAt: '2026-07-19T08:30:00Z' }),
].map((attentionItem) => ({ item: attentionItem, reason: 'Review this issue.' }));
expect(sortPatrolAttentionDecisions(decisions).map((decision) => decision.item.id)).toEqual([
'critical-action',
'critical-new',
'critical-old',
'warning',
]);
});
it('opens deepest typed evidence, protection, and timeline detail from one queue item', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
@@ -103,10 +103,10 @@ describe('PatrolIntelligenceHeader', () => {
it('keeps primary Patrol actions touch-sized on phones without inflating desktop chrome', () => {
expect(headerSource).toContain(
'flex min-h-11 items-center gap-2 rounded-md border border-border bg-surface',
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-blue-600',
);
expect(headerSource).toContain(
'flex min-h-11 items-center gap-2 rounded-md border border-border',
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-border',
);
expect(headerSource).toContain('sm:min-h-0');
});
@@ -53,7 +53,7 @@ describe('PatrolObjectivesPanel', () => {
it('creates an abstract estate-wide objective from one simple statement', async () => {
render(() => <PatrolObjectivesPanel />);
expect(await screen.findByText('No retained outcomes yet')).toBeInTheDocument();
expect(await screen.findByText('Choose what matters most')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Add objective' }).at(-1)!);
const outcome = screen.getByLabelText('What should Patrol keep true?');
@@ -73,7 +73,7 @@ describe('PatrolRecentWorkPanel', () => {
render(() => <PatrolRecentWorkPanel />);
expect(
await screen.findByRole('heading', { name: 'No verified work yet' }),
await screen.findByRole('heading', { name: 'No verified outcomes yet' }),
).toBeInTheDocument();
expect(screen.queryByText('Verified')).not.toBeInTheDocument();
});
@@ -1525,9 +1525,9 @@ describe('Patrol page header IA framing', () => {
const { getPatrolPageHeaderMeta, PATROL_PAGE_DESCRIPTION, PATROL_PAGE_TITLE_TOOLTIP } =
await import('@/utils/patrolPagePresentation');
expect(PATROL_PAGE_DESCRIPTION).toBe(
'Patrol checks your infrastructure, explains what it found, follows your mode before acting, and records the result.',
'Patrol detects operational issues, brings forward the next decision, and records only verified outcomes.',
);
expect(PATROL_PAGE_DESCRIPTION).toContain('follows your mode before acting');
expect(PATROL_PAGE_DESCRIPTION).toContain('verified outcomes');
expect(PATROL_PAGE_TITLE_TOOLTIP).toBe(PATROL_PAGE_DESCRIPTION);
expect(getPatrolPageHeaderMeta()).toMatchObject({
title: 'Patrol',
@@ -1536,8 +1536,8 @@ describe('Patrol page header IA framing', () => {
});
expect(getPatrolPageHeaderMeta({ autonomyLocked: true })).toMatchObject({
title: 'Patrol',
description: 'Patrol checks your infrastructure and shows current issues.',
titleTooltip: 'Patrol checks your infrastructure and shows current issues.',
description: 'Continuous infrastructure checks, with every decision kept in your hands.',
titleTooltip: 'Continuous infrastructure checks, with every decision kept in your hands.',
});
});
});
@@ -11,23 +11,22 @@ export interface PatrolAutonomyExperience {
export const PATROL_AUTONOMY_EXPERIENCE: Record<PatrolAutonomyLevel, PatrolAutonomyExperience> = {
monitor: {
label: 'Watch only',
summary: 'Patrol watches your infrastructure. You decide what happens next.',
needsYouDescription:
'Patrol reports current issues here because Watch only never makes infrastructure changes.',
summary: 'Patrol checks continuously and keeps every infrastructure decision with you.',
needsYouDescription: 'Every current issue stays in your queue for review in Watch only mode.',
quietWorkDescription: 'Patrol continues watching these issues without making changes.',
},
approval: {
label: 'Ask first',
summary: 'Patrol investigates and prepares fixes, then waits for your approval.',
needsYouDescription:
'These issues need your decision before Patrol can make a change or close the loop.',
'These issues are waiting for a decision or approval before work can continue.',
quietWorkDescription: 'Patrol continues investigating while it waits for the decisions above.',
},
assisted: {
label: 'Safe auto-fix',
summary: 'Patrol handles policy-allowed safe work and asks when risk or evidence needs you.',
needsYouDescription:
'Patrol only interrupts when policy, risk, missing evidence, or verification requires your decision.',
'This queue contains only work blocked by policy, risk, missing evidence, or verification.',
quietWorkDescription:
'Other current issues can proceed within the safe auto-fix policy without a decision from you.',
},
@@ -35,8 +34,7 @@ export const PATROL_AUTONOMY_EXPERIENCE: Record<PatrolAutonomyLevel, PatrolAuton
label: 'Autopilot',
summary:
'Patrol handles allowed work in the background and interrupts only when it cannot proceed safely.',
needsYouDescription:
'Patrol only interrupts when it is blocked, lacks trustworthy evidence, or still requires approval.',
needsYouDescription: 'This queue contains only work Patrol cannot complete safely without you.',
quietWorkDescription:
'Other current issues do not require a decision under Autopilot and remain visible in Alerts.',
},
@@ -548,9 +548,21 @@ describe('AIIntelligence entitlement gating', () => {
expect(patrolIntelligenceHeaderSource).not.toContain('Sync page data');
expect(patrolIntelligenceHeaderSource).not.toContain('animate-spin');
expect(patrolIntelligenceHeaderSource).not.toContain('Refresh Patrol');
expect(patrolIntelligenceSurfaceSource).toContain('Activity history');
expect(patrolIntelligenceSurfaceSource).toContain('Review and history');
expect(patrolIntelligenceSurfaceSource).toContain('href="/actions"');
expect(patrolIntelligenceSurfaceSource).toContain('Open activity history');
expect(patrolIntelligenceSurfaceSource).toContain('Actions and approvals');
});
it('keeps active decisions ahead of objective configuration and historical review', () => {
const attentionIndex = patrolIntelligenceSurfaceSource.indexOf('<PatrolAttentionWorkbench');
const objectivesIndex = patrolIntelligenceSurfaceSource.indexOf('<PatrolObjectivesPanel');
const verifiedOutcomesIndex = patrolIntelligenceSurfaceSource.indexOf('<PatrolRecentWorkPanel');
const historyIndex = patrolIntelligenceSurfaceSource.indexOf('Review and history');
expect(attentionIndex).toBeGreaterThan(-1);
expect(objectivesIndex).toBeGreaterThan(attentionIndex);
expect(verifiedOutcomesIndex).toBeGreaterThan(attentionIndex);
expect(historyIndex).toBeGreaterThan(verifiedOutcomesIndex);
});
it('keeps the advanced Patrol settings drawer out of the old save-spinner path', () => {
@@ -680,11 +692,11 @@ describe('AIIntelligence entitlement gating', () => {
});
expect(
screen.getByText('Patrol checks your infrastructure and shows current issues.'),
screen.getByText('Continuous infrastructure checks, with every decision kept in your hands.'),
).toBeInTheDocument();
expect(
screen.queryByText(
'Patrol checks your infrastructure, explains what it found, follows your mode before acting, and records the result.',
'Patrol detects operational issues, brings forward the next decision, and records only verified outcomes.',
),
).not.toBeInTheDocument();
const patrolControlAnchor = document.getElementById(PATROL_CONTROL_ANCHOR);
@@ -1606,7 +1618,7 @@ describe('AIIntelligence entitlement gating', () => {
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();
expect(
screen.getByRole('heading', { name: 'Patrol is looking after your infrastructure' }),
screen.getByRole('heading', { name: 'Watching your infrastructure' }),
).toBeInTheDocument();
});
@@ -1682,7 +1694,7 @@ describe('AIIntelligence entitlement gating', () => {
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();
expect(
screen.getByRole('heading', { name: 'Patrol is looking after your infrastructure' }),
screen.getByRole('heading', { name: 'Watching your infrastructure' }),
).toBeInTheDocument();
});
@@ -1998,7 +2010,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByTestId('patrol-status-bar')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Open work' })).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Needs you' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Needs your attention' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Active' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Resolved' })).not.toBeInTheDocument();
@@ -15,24 +15,24 @@ describe('patrolPagePresentation', () => {
// users learn the full operations-loop model.
expect(PATROL_PAGE_TITLE).toBe('Patrol');
expect(PATROL_PAGE_DESCRIPTION).toBe(
'Patrol checks your infrastructure, explains what it found, follows your mode before acting, and records the result.',
'Patrol detects operational issues, brings forward the next decision, and records only verified outcomes.',
);
expect(PATROL_PAGE_TITLE_TOOLTIP).toBe(PATROL_PAGE_DESCRIPTION);
expect(getPatrolPageHeaderMeta()).toEqual({
title: 'Patrol',
description:
'Patrol checks your infrastructure, explains what it found, follows your mode before acting, and records the result.',
'Patrol detects operational issues, brings forward the next decision, and records only verified outcomes.',
titleTooltip:
'Patrol checks your infrastructure, explains what it found, follows your mode before acting, and records the result.',
'Patrol detects operational issues, brings forward the next decision, and records only verified outcomes.',
});
});
it('separates locked watch-only capability from Pro Watch only mode', () => {
expect(PATROL_PAGE_WATCH_ONLY_DESCRIPTION).toBe(
'Patrol checks your infrastructure and shows current issues.',
'Continuous infrastructure checks, with every decision kept in your hands.',
);
expect(PATROL_PAGE_MONITOR_DESCRIPTION).toBe(
'Watch only: Patrol checks infrastructure and reports issues only.',
'Watch only: Patrol checks your estate and brings every current issue to you.',
);
expect(getPatrolPageHeaderMeta({ autonomyLocked: true })).toEqual({
title: 'Patrol',
@@ -3,13 +3,13 @@ import type { PatrolAutonomyLevel } from '@/api/patrol';
export const PATROL_PAGE_TITLE = 'Patrol';
export const PATROL_PAGE_WATCH_ONLY_DESCRIPTION =
'Patrol checks your infrastructure and shows current issues.';
'Continuous infrastructure checks, with every decision kept in your hands.';
export const PATROL_PAGE_MONITOR_DESCRIPTION =
'Watch only: Patrol checks infrastructure and reports issues only.';
'Watch only: Patrol checks your estate and brings every current issue to you.';
export const PATROL_PAGE_DESCRIPTION =
'Patrol checks your infrastructure, explains what it found, follows your mode before acting, and records the result.';
'Patrol detects operational issues, brings forward the next decision, and records only verified outcomes.';
export const PATROL_PAGE_TITLE_TOOLTIP = PATROL_PAGE_DESCRIPTION;
export interface PatrolPageHeaderMetaInput {
@@ -801,7 +801,7 @@ test.describe("Patrol runtime-state browser contract", () => {
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await page.getByText("How Patrol operates", { exact: true }).click();
await page.getByText("Mode and automation", { exact: true }).click();
const modeGroup = page.getByRole("group", { name: "Patrol mode" });
await expect(modeGroup).toBeVisible();
await expect(
@@ -604,10 +604,7 @@ test.describe("Patrol Assistant operator briefing", () => {
});
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await page
.locator("summary")
.filter({ hasText: "Operational records and run history" })
.click();
await page.getByRole("button", { name: /Findings and run records/ }).click();
await page.getByText("High CPU usage").click();
const findingReview = page.locator(
@@ -743,10 +740,7 @@ test.describe("Patrol Assistant operator briefing", () => {
includePendingApproval = false;
await page.reload({ waitUntil: "domcontentloaded" });
await page
.locator("summary")
.filter({ hasText: "Operational records and run history" })
.click();
await page.getByRole("button", { name: /Findings and run records/ }).click();
const queuedFindingTitle = page.getByText("High CPU usage").first();
await expect(queuedFindingTitle).toBeVisible();
await queuedFindingTitle.click();
@@ -776,10 +770,7 @@ test.describe("Patrol Assistant operator briefing", () => {
includeUnifiedInvestigationRecord = false;
includeInvestigationProposedFix = true;
await page.reload({ waitUntil: "domcontentloaded" });
await page
.locator("summary")
.filter({ hasText: "Operational records and run history" })
.click();
await page.getByRole("button", { name: /Findings and run records/ }).click();
const expiredFindingTitle = page.getByText("High CPU usage").first();
await expect(expiredFindingTitle).toBeVisible();
await expiredFindingTitle.click();
@@ -714,7 +714,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
page.getByRole("heading", { level: 1, name: "Patrol" }),
).toBeVisible();
await expect(
page.getByRole("heading", { level: 2, name: "Needs you" }),
page.getByRole("heading", { level: 2, name: "Needs your attention" }),
).toBeVisible();
await expect(page.getByText("Nothing needs you right now")).toBeVisible();
await expect(
@@ -760,7 +760,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
await page.getByRole("tab", { name: /Patrol/ }).click();
await expect(page).toHaveURL(/\/patrol$/);
await expect(
page.getByRole("heading", { level: 2, name: "Needs you" }),
page.getByRole("heading", { level: 2, name: "Needs your attention" }),
).toBeVisible();
await expect(
page.getByRole("list", { name: "Patrol attention items" }),
@@ -882,10 +882,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
await expect(
page.getByRole("heading", { level: 1, name: "Patrol" }),
).toBeVisible();
await page
.locator("summary")
.filter({ hasText: "Operational records and run history" })
.click();
await page.getByRole("button", { name: /Findings and run records/ }).click();
await expect(
page.getByText("Operating system updates need review").first(),
).toBeVisible();
@@ -929,10 +926,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
}),
);
await page.reload({ waitUntil: "domcontentloaded" });
await page
.locator("summary")
.filter({ hasText: "Operational records and run history" })
.click();
await page.getByRole("button", { name: /Findings and run records/ }).click();
const cleanupTitle = page
.getByText("Downloaded package data is using needed space")
.first();
@@ -987,10 +981,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
}),
);
await page.reload({ waitUntil: "domcontentloaded" });
await page
.locator("summary")
.filter({ hasText: "Operational records and run history" })
.click();
await page.getByRole("button", { name: /Findings and run records/ }).click();
await page.getByRole("button", { name: "Resolved", exact: true }).click();
const resolvedTitle = page
.getByText("Operating system updates confirmed complete")
@@ -36,7 +36,7 @@ test("Autopilot records a versioned server acknowledgement before effective full
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, settings: { autonomy_level: active ? "full" : "monitor", requested_autonomy_level: active ? "full" : "monitor", effective_autonomy_level: active ? "full" : "monitor", full_mode_unlocked: active, autopilot_acknowledgement: status, investigation_budget: 15, investigation_timeout_sec: 300 }, autonomy_level: active ? "full" : "monitor", requested_autonomy_level: active ? "full" : "monitor", effective_autonomy_level: active ? "full" : "monitor", full_mode_unlocked: active, autopilot_acknowledgement: status, investigation_budget: 15, investigation_timeout_sec: 300 }) });
});
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await page.getByText("How Patrol operates", { exact: true }).click();
await page.getByText("Mode and automation", { exact: true }).click();
await page.getByRole("button", { name: "Autopilot" }).click();
await expect(page.getByRole("dialog", { name: "Activate Autopilot" })).toBeVisible();
const activate = page.getByRole("button", { name: "Record acknowledgement and activate" });
@@ -683,7 +683,7 @@ test("starts from the normal monitor shell and reaches the canonical attention q
await expect(page).toHaveURL(/\/patrol/);
await expect(
page.getByRole("region", { name: "Needs you" }),
page.getByRole("region", { name: "Needs your attention" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Open CPU pressure on pve-main" }),
@@ -703,17 +703,16 @@ test("makes active operational work primary and preserves the evidence boundary"
}),
),
).toBeVisible();
const queue = page.getByRole("region", { name: "Needs you" });
await expect(queue.getByLabel("2 decisions need you")).toBeVisible();
const queue = page.getByRole("region", { name: "Needs your attention" });
await expect(queue.getByLabel("2 items require review")).toBeVisible();
await expect(
queue.getByText(
"CPU has remained above the configured threshold for two collection cycles.",
),
).toBeVisible();
await expect(queue.getByText("Evidence current").first()).toBeVisible();
await expect(
queue.getByText("Protection needs attention").first(),
).toBeVisible();
const attentionList = queue.getByRole("list", { name: "Patrol attention items" });
await expect(attentionList.getByText("Evidence current")).toHaveCount(0);
await expect(attentionList.getByText("Protection needs attention")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Explain with Assistant" }),
).toHaveCount(0);
@@ -587,7 +587,7 @@ test.describe("Operational trust availability resource facet", () => {
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
const queue = page.getByRole("region", { name: "Needs you" });
const queue = page.getByRole("region", { name: "Needs your attention" });
await expect(queue).toBeVisible({ timeout: 30_000 });
await expect(
page.getByRole("tab", {