mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-28 06:27:26 +00:00
feat(patrol): redesign the operator workbench
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
+4
-4
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user