mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-20 22:43:31 +00:00
feat(patrol): simplify the operator workspace
This commit is contained in:
parent
e1427e66f8
commit
ed5d983e8e
15 changed files with 440 additions and 385 deletions
|
|
@ -7467,7 +7467,7 @@
|
|||
"status": "target-met",
|
||||
"completion": {
|
||||
"state": "complete",
|
||||
"summary": "Monitor-first Patrol operations now has a decision-first, responsive governed floor: authenticated launch stays on infrastructure monitoring; a compact control toolbar gives way to a daily briefing with current decision, critical, and separately canonical approval posture plus one recommended start; Patrol owns a persistent desktop inbox-and-context workspace and a true mobile master/detail flow with identity-stable review progress, previous/next navigation, automatic advancement after reviewed or temporarily suppressed occurrences, and explicit remaining-work feedback 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.",
|
||||
"summary": "Monitor-first Patrol operations now has a one-task-at-a-time, decision-first governed floor: authenticated launch stays on infrastructure monitoring; a compact secondary status row gives way to mutually exclusive Inbox, Protection, and Activity modes; Inbox defaults to one full-width ranked decision queue without duplicate counters, generic row state, or an empty preview; selection alone creates the persistent desktop inbox-and-context workspace or true mobile master/detail flow with identity-stable review progress, previous/next navigation, automatic advancement after reviewed or temporarily suppressed occurrences, and explicit remaining-work feedback over canonical lifecycle, evidence, protection, availability, approval, action, and verification truth; objectives, verified receipts, approvals, and records remain deliberately accessible without competing on the same canvas; calm states stay honest; and Assistant remains contextual explanation for the selected thing rather than the operations front door.",
|
||||
"tracking": []
|
||||
},
|
||||
"blockers": [],
|
||||
|
|
|
|||
|
|
@ -2519,6 +2519,12 @@ a new API state machine, queue contract, or verification-accounting field.
|
|||
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.
|
||||
The first-party `Inbox`, `Protection`, and `Activity` modes are a mutually
|
||||
exclusive presentation boundary, not API query modes: Inbox consumes the
|
||||
canonical attention projection, Protection consumes retained objectives,
|
||||
and Activity consumes verified receipts plus existing action/finding/run
|
||||
records. Switching panels must not rewrite membership, merge counts, invent
|
||||
lifecycle state, or refetch one dataset as though it represented another.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -772,16 +772,17 @@ assessment semantics, but they must keep those semantics in their governed
|
|||
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, 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
|
||||
`MetadataBadge`, native disclosure, dialog, tab, and neutral bordered-surface
|
||||
patterns. Patrol owns the meaning of background posture and the mutually
|
||||
exclusive `Inbox`, `Protection`, and `Activity` modes; the shared primitive
|
||||
boundary owns visible selection, Arrow/Home/End keyboard movement, roving tab
|
||||
focus, touch targets, responsive stacking, and visual consistency. Only the
|
||||
selected mode's panel is rendered. Plan-locked paid-mode discovery is not a
|
||||
daily-page primitive and must not be added beside the effective mode.
|
||||
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
|
||||
Current API-owned decisions must follow the compact posture row in the default
|
||||
Inbox at every viewport. Objective configuration and verified/history surfaces
|
||||
belong to their explicit modes rather than following the queue on the same
|
||||
canvas. 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
|
||||
|
|
|
|||
|
|
@ -75,16 +75,16 @@ sources, and retains the note as operator context.
|
|||
### Quiet operator home
|
||||
|
||||
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,
|
||||
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.
|
||||
the internal lifecycle. After the effective background posture it exposes three
|
||||
mutually exclusive operator modes: `Inbox`, `Protection`, and `Activity`.
|
||||
`Inbox` is the default and mounts only genuine operator decisions;
|
||||
`Protection` owns retained objectives; `Activity` owns verified receipts plus
|
||||
the subordinate action-audit and finding/run-record handoffs. Active decisions,
|
||||
objective configuration, receipts, and operational records must not all render
|
||||
at once. Investigation internals, lifecycle filters, raw findings, checks, and
|
||||
run history remain deliberately available without competing with the daily
|
||||
decision queue. The mode selector is keyboard navigable and retains the stable
|
||||
Patrol route rather than inventing API filters or lifecycle states.
|
||||
|
||||
`patrolHomePresentation.ts` owns the deterministic projection from effective
|
||||
autonomy and typed attention evidence into `needs user` versus `can continue`
|
||||
|
|
@ -1204,15 +1204,16 @@ 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 Patrol 2.0 frontend now uses a one-task-at-a-time, decision-first
|
||||
information hierarchy. The compact, borderless command row owns enabled
|
||||
posture, effective mode, last/next check context, manual check, settings, and
|
||||
the secondary mode disclosure without presenting a second primary CTA card.
|
||||
The typed attention workbench is the only substantial default page surface on
|
||||
desktop and mobile. `Protection` deliberately swaps in retained objectives;
|
||||
`Activity` swaps in proof-constrained verified outcomes and the subordinate
|
||||
`Review and history` handoff for universal governed actions and Patrol-specific
|
||||
finding/run records. Empty continuity surfaces therefore never share the prime
|
||||
viewport with current decisions.
|
||||
|
||||
The page introduction states the operator job—find the decision, choose the
|
||||
next step, and retain a verified record—rather than repeating the command
|
||||
|
|
@ -1224,17 +1225,18 @@ does not merge governed action records into the attention list or invent an
|
|||
attention count; the queue and approval inbox retain their separate canonical
|
||||
projections.
|
||||
|
||||
The default loaded state is a daily briefing rather than an unframed list. It
|
||||
states how many current decisions need the operator, shows the current loaded
|
||||
critical-decision and separately canonical approval counts, explains the queue
|
||||
ordering, and offers one `Start review` action for the highest-priority item.
|
||||
On wide layouts the inbox and a recommended first-decision preview occupy a
|
||||
persistent two-pane workspace before selection; selection replaces the preview
|
||||
with the full governed context without hiding the inbox. On narrow layouts the
|
||||
selected context intentionally replaces the list until `Back to list`, making
|
||||
Patrol a true master/detail workflow instead of stacking the detail above a
|
||||
second full queue. The command header remains compact so the briefing and first
|
||||
decision enter the initial viewport.
|
||||
The default loaded state is a full-width decision inbox with one headline that
|
||||
states how many current decisions need the operator, one short explanation of
|
||||
the ordering, one `Start review` action for the highest-priority item, the
|
||||
canonical pending-approval handoff when needed, and refresh. It must not repeat
|
||||
the same facts as a metric strip, generic row state, generic `Review` label, or
|
||||
empty recommended-item preview. A two-pane workspace appears only after the
|
||||
operator selects or deep-links a decision; wide layouts retain the inbox beside
|
||||
the full governed context. On narrow layouts the selected context intentionally
|
||||
replaces the list until `Back to list`, making Patrol a true master/detail
|
||||
workflow instead of stacking the detail above a second full queue. The command
|
||||
row remains compact and visually secondary so the inbox and first decisions
|
||||
enter the initial viewport.
|
||||
|
||||
Selecting the first item starts a bounded review session over the loaded
|
||||
decision order. The selected context shows its current position and exposes
|
||||
|
|
@ -1249,10 +1251,12 @@ that the occurrence will return at the chosen time. Clearing the final item
|
|||
returns to the calm inbox with an honest completion announcement.
|
||||
|
||||
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
|
||||
approval/review state, and latest observation time. Collapsed rows expose the
|
||||
severity word, bounded consequence, affected resource, and freshness; only
|
||||
exceptional approval-needed or verification-result work adds another label.
|
||||
Lifecycle state remains selected-detail truth instead of a repeated `Open`
|
||||
badge, and ordinary queue membership is not restated as `Review` on every row.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,26 +1,22 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "0da7092991898ca4b125f75cd7dacf9d492b7126",
|
||||
"verified_at": "2026-08-18T10:05:00Z",
|
||||
"base_sha": "e1427e66f8614d0fea90d62d7f785f93595fdae1",
|
||||
"verified_at": "2026-08-18T09:24:31Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/useAppRuntimeState.ts"
|
||||
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/useAppRuntimeState.ts": "311d1b7c6a97b94830311650b4efe5ee5cf57215c903e9835be70441a62836dd"
|
||||
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "770c7eaecf23026916fa48198fe5220be6db076fbd30a7351e250d2cbeec9fb4",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "f147df3ec75a27fb5d06b6cf9e8f487b02d93af1630c8d7fb0fd3688b264a3dc",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "4a3d400d59c42a48b0488ef0a4b0026f39326e1cbfc44d19528198bfd84030e1"
|
||||
},
|
||||
"routes": [
|
||||
"/login",
|
||||
"/",
|
||||
"/proxmox/overview",
|
||||
"/docker/overview",
|
||||
"/kubernetes/overview",
|
||||
"/truenas/overview",
|
||||
"/vsphere/overview"
|
||||
],
|
||||
"routes": ["/patrol"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
|
|
@ -29,25 +25,18 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"signed-out login form",
|
||||
"authenticated shell against a backend that reports the platformAdmission facet",
|
||||
"navigation resolved with all six primary platform tabs",
|
||||
"backend stopped mid-session, shell showing a disconnected socket",
|
||||
"backend restored and the socket reconnected",
|
||||
"Docker, Kubernetes, TrueNAS and vSphere platform pages populated",
|
||||
"shell at 390x844 with the Proxmox table rendered"
|
||||
"Default live Inbox with 12 ranked decisions, one critical item, and one governed approval waiting; Protection, verified outcomes, and history absent from the canvas",
|
||||
"Protection mode with retained-objective setup only and no decision queue or activity surface",
|
||||
"Activity mode with verified outcomes and the subordinate actions, findings, and run-record handoffs",
|
||||
"Desktop selected-decision split with persistent queue, current position, previous/next navigation, and governed lifecycle, evidence, protection, resource, and Assistant context",
|
||||
"390x844 selected-decision master/detail state with the queue hidden, Back to list, queue position, and touch-sized navigation",
|
||||
"Current-coverage calm state, transport failure state, and attached availability failure without synthetic health"
|
||||
],
|
||||
"interactions": [
|
||||
"signed in and confirmed admission is read once from a one-resource request",
|
||||
"idled 20 seconds with live runtime state and confirmed no further admission requests, so the steady state polls nothing",
|
||||
"stopped the backend mid-session, watched the shell report a disconnected socket, restarted it, and confirmed admission was refetched once on reconnect",
|
||||
"confirmed navigation still listed every platform tab after the reconnect cycle",
|
||||
"switched to the Docker tab and confirmed 80 rows",
|
||||
"switched to the Kubernetes tab and confirmed 180 rows",
|
||||
"switched to the TrueNAS tab and confirmed 3 rows",
|
||||
"switched to the vSphere tab and confirmed 32 rows",
|
||||
"returned to the Proxmox tab and confirmed 79 rows",
|
||||
"resized to 390x844 and confirmed the Proxmox table still renders 79 rows",
|
||||
"confirmed zero console or page errors across the pass"
|
||||
"Used the live in-app Browser to inspect the final Inbox, Protection, Activity, and selected-decision split against current backend data",
|
||||
"Exercised the workspace tabs and confirmed only the selected panel is present while stable Patrol route and API truth are retained",
|
||||
"Verified automatic advancement after Mark reviewed, bounded suppression feedback, inbox focus restoration, deep-link reload, and no horizontal overflow",
|
||||
"Passed the complete 18-scenario attention-workbench matrix across Chromium, mobile Chrome, and mobile Safari; the two corrected exact-name checks were rerun across all three engines",
|
||||
"Passed all eight affected Assistant, monitor-first, and attached-availability journeys in Chromium"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,13 +127,14 @@ export function PatrolAttentionWorkbench(
|
|||
props.autonomyLocked ? 'monitor' : (props.autonomyLevel ?? 'monitor'),
|
||||
);
|
||||
const autonomyExperience = createMemo(() => PATROL_AUTONOMY_EXPERIENCE[effectiveAutonomyLevel()]);
|
||||
const attention = createMemo(() =>
|
||||
partitionPatrolAttention(
|
||||
patrolAttentionStore.items(),
|
||||
const attention = createMemo(() => {
|
||||
const items = patrolAttentionStore.items();
|
||||
return partitionPatrolAttention(
|
||||
Array.isArray(items) ? items : [],
|
||||
props.autonomyLevel ?? 'monitor',
|
||||
props.autonomyLocked ?? false,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
const sortedDecisions = createMemo(() => sortPatrolAttentionDecisions(attention().needsUser));
|
||||
const orderedDecisions = createMemo(() => {
|
||||
const current = sortedDecisions();
|
||||
|
|
@ -165,9 +166,6 @@ export function PatrolAttentionWorkbench(
|
|||
const index = selectedDecisionIndex();
|
||||
return index >= 0 ? orderedDecisions()[index + 1] : undefined;
|
||||
});
|
||||
const criticalDecisionCount = createMemo(
|
||||
() => attention().needsUser.filter((decision) => decision.item.severity === 'critical').length,
|
||||
);
|
||||
const briefingHeadline = createMemo(() => {
|
||||
if (patrolAttentionStore.loading() && !summary()) return 'Building your current briefing';
|
||||
const count = attention().needsUser.length;
|
||||
|
|
@ -310,105 +308,58 @@ export function PatrolAttentionWorkbench(
|
|||
}
|
||||
});
|
||||
|
||||
const decisionCountLabel = createMemo(() => {
|
||||
const count = attention().needsUser.length;
|
||||
return `${count} ${count === 1 ? 'item requires' : 'items require'} review`;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="patrol-attention-heading"
|
||||
aria-label="Patrol decision inbox"
|
||||
class="overflow-hidden rounded-xl border border-border bg-surface shadow-sm"
|
||||
>
|
||||
<div class="border-b border-border bg-blue-50/50 px-4 py-5 dark:bg-blue-950/20 sm:px-6 sm:py-6">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="border-b border-border px-4 py-4 sm:px-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.16em] text-blue-700 dark:text-blue-300">
|
||||
Today's Patrol briefing
|
||||
</p>
|
||||
<h2
|
||||
id="patrol-attention-heading"
|
||||
class="mt-2 text-base font-semibold text-base-content"
|
||||
>
|
||||
Needs your attention
|
||||
</h2>
|
||||
<p class="mt-1 text-2xl font-semibold tracking-tight text-base-content sm:text-3xl">
|
||||
<h2 class="text-xl font-semibold tracking-tight text-base-content sm:text-2xl">
|
||||
{briefingHeadline()}
|
||||
</p>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted">
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-5 text-muted">
|
||||
{attention().needsUser.length > 0
|
||||
? 'Patrol has already ordered the queue by severity, actionability, and the freshest evidence.'
|
||||
? 'Highest priority first, based on severity, actionability, and current evidence.'
|
||||
: autonomyExperience().needsYouDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full lg:w-[32rem]">
|
||||
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<Show when={!selectedItemId() && highestPriorityDecision()}>
|
||||
{(decision) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
onClick={() => selectItem(decision().item.id)}
|
||||
>
|
||||
Start review
|
||||
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={(props.pendingActionCount ?? 0) > 0}>
|
||||
<ButtonLink href="/actions" variant="secondary" size="sm" class="gap-1.5">
|
||||
<ClipboardCheckIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Review {props.pendingActionCount}{' '}
|
||||
{props.pendingActionCount === 1 ? 'approval' : 'approvals'}
|
||||
</ButtonLink>
|
||||
</Show>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
onClick={() => void loadCurrentFilter()}
|
||||
disabled={patrolAttentionStore.loading()}
|
||||
aria-label="Refresh Patrol attention"
|
||||
>
|
||||
<RefreshIcon
|
||||
class={`h-4 w-4 ${patrolAttentionStore.loading() ? 'motion-safe:animate-spin' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={summary()}>
|
||||
<div
|
||||
class="mt-4 grid grid-cols-3 overflow-hidden rounded-lg border border-border bg-surface/80 shadow-sm"
|
||||
aria-label={decisionCountLabel()}
|
||||
>
|
||||
<div class="px-3 py-3 sm:px-4">
|
||||
<span class="block text-xl font-semibold text-red-700 dark:text-red-300">
|
||||
{criticalDecisionCount()}
|
||||
</span>
|
||||
<span class="mt-0.5 block text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Critical
|
||||
</span>
|
||||
</div>
|
||||
<div class="border-l border-border px-3 py-3 sm:px-4">
|
||||
<span class="block text-xl font-semibold text-base-content">
|
||||
{attention().needsUser.length}
|
||||
</span>
|
||||
<span class="mt-0.5 block text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Decisions
|
||||
</span>
|
||||
</div>
|
||||
<div class="border-l border-border px-3 py-3 sm:px-4">
|
||||
<span class="block text-xl font-semibold text-amber-700 dark:text-amber-300">
|
||||
{props.pendingActionCount ?? 0}
|
||||
</span>
|
||||
<span class="mt-0.5 block text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Approvals
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<Show when={!selectedItemId() && highestPriorityDecision()}>
|
||||
{(decision) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
onClick={() => selectItem(decision().item.id)}
|
||||
>
|
||||
Start review
|
||||
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={(props.pendingActionCount ?? 0) > 0}>
|
||||
<ButtonLink href="/actions" variant="secondary" size="sm" class="gap-1.5">
|
||||
<ClipboardCheckIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Review {props.pendingActionCount}{' '}
|
||||
{props.pendingActionCount === 1 ? 'approval' : 'approvals'}
|
||||
</ButtonLink>
|
||||
</Show>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
onClick={() => void loadCurrentFilter()}
|
||||
disabled={patrolAttentionStore.loading()}
|
||||
aria-label="Refresh Patrol attention"
|
||||
>
|
||||
<RefreshIcon
|
||||
class={`h-4 w-4 ${patrolAttentionStore.loading() ? 'motion-safe:animate-spin' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={attention().quiet.length > 0}>
|
||||
|
|
@ -433,22 +384,11 @@ export function PatrolAttentionWorkbench(
|
|||
</Show>
|
||||
|
||||
<div
|
||||
class={`grid min-w-0 ${attention().needsUser.length > 0 ? 'lg:grid-cols-[minmax(20rem,0.78fr)_minmax(0,1.22fr)]' : ''}`}
|
||||
class={`grid min-w-0 ${selectedItemId() ? 'lg:grid-cols-[minmax(20rem,0.78fr)_minmax(0,1.22fr)]' : ''}`}
|
||||
>
|
||||
<div
|
||||
class={`min-w-0 ${selectedItemId() ? 'hidden lg:block' : ''} ${attention().needsUser.length > 0 ? 'lg:max-h-[52rem] lg:overflow-y-auto lg:border-r lg:border-border' : ''}`}
|
||||
class={`min-w-0 ${selectedItemId() ? 'hidden lg:block lg:max-h-[52rem] lg:overflow-y-auto lg:border-r lg:border-border' : ''}`}
|
||||
>
|
||||
<Show when={attention().needsUser.length > 0}>
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-surface/95 px-4 py-3 backdrop-blur sm:px-5">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-base-content">Decision inbox</h3>
|
||||
<p class="mt-0.5 text-[11px] text-muted">Highest priority first</p>
|
||||
</div>
|
||||
<MetadataBadge tone="neutral" size="xs" shape="rounded">
|
||||
{attention().needsUser.length} open
|
||||
</MetadataBadge>
|
||||
</div>
|
||||
</Show>
|
||||
<AttentionList
|
||||
decisions={visibleDecisions()}
|
||||
selectedItemId={selectedItemId()}
|
||||
|
|
@ -469,42 +409,7 @@ export function PatrolAttentionWorkbench(
|
|||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={selectedItemId()}
|
||||
fallback={
|
||||
<Show when={highestPriorityDecision()}>
|
||||
{(decision) => (
|
||||
<div class="hidden min-h-[30rem] flex-col items-start justify-start bg-surface-alt/20 px-8 py-8 text-left lg:flex">
|
||||
<span class="inline-flex h-12 w-12 items-center justify-center rounded-xl border border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300">
|
||||
<SparklesIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<p class="mt-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted">
|
||||
Start here
|
||||
</p>
|
||||
<h3 class="mt-2 max-w-lg text-lg font-semibold text-base-content">
|
||||
{decision().item.title}
|
||||
</h3>
|
||||
<p class="mt-2 max-w-lg text-sm leading-6 text-muted">
|
||||
{decision().item.plainLanguageSummary}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
class="mt-5 gap-2"
|
||||
onClick={() => selectItem(decision().item.id)}
|
||||
>
|
||||
Review this decision
|
||||
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<p class="mt-4 max-w-md text-xs leading-5 text-muted">
|
||||
Patrol chose this from severity, available action, and evidence recency. It will
|
||||
not make a change from this screen without the governed action flow.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={selectedItemId()}>
|
||||
<div
|
||||
ref={detailPanel}
|
||||
tabindex="-1"
|
||||
|
|
@ -576,6 +481,11 @@ function AttentionList(props: {
|
|||
itemButtons: Map<string, HTMLButtonElement>;
|
||||
onSelect: (itemId: string) => void;
|
||||
}) {
|
||||
const hasQuietWork = () => {
|
||||
const items = patrolAttentionStore.items();
|
||||
return Array.isArray(items) && items.length > 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div aria-live="polite">
|
||||
<Show when={patrolAttentionStore.error()}>
|
||||
|
|
@ -604,7 +514,7 @@ function AttentionList(props: {
|
|||
>
|
||||
<Show
|
||||
when={props.decisions.length > 0}
|
||||
fallback={<AttentionEmptyState hasQuietWork={patrolAttentionStore.items().length > 0} />}
|
||||
fallback={<AttentionEmptyState hasQuietWork={hasQuietWork()} />}
|
||||
>
|
||||
<ul class="divide-y divide-border" aria-label="Patrol attention items">
|
||||
<For each={props.decisions}>
|
||||
|
|
@ -621,7 +531,7 @@ function AttentionList(props: {
|
|||
if (item.verificationState === 'failed' || item.verificationState === 'unknown') {
|
||||
return 'Verify result';
|
||||
}
|
||||
return 'Review';
|
||||
return undefined;
|
||||
};
|
||||
return (
|
||||
<li>
|
||||
|
|
@ -646,18 +556,21 @@ function AttentionList(props: {
|
|||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-base-content">
|
||||
{item.title}
|
||||
</span>
|
||||
<StateBadge item={item} />
|
||||
</div>
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">
|
||||
{item.plainLanguageSummary}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||
<span
|
||||
class="font-semibold text-amber-700 dark:text-amber-300"
|
||||
title={decision.reason}
|
||||
>
|
||||
{decisionLabel()}
|
||||
</span>
|
||||
<Show when={decisionLabel()}>
|
||||
{(label) => (
|
||||
<span
|
||||
class="font-semibold text-amber-700 dark:text-amber-300"
|
||||
title={decision.reason}
|
||||
>
|
||||
{label()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate font-medium text-base-content">
|
||||
{item.subjectResourceName}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -301,8 +301,8 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
|
|||
class="relative z-[200] mb-3"
|
||||
/>
|
||||
|
||||
<section class="overflow-hidden rounded-lg border border-border bg-surface shadow-sm">
|
||||
<div class="flex flex-col gap-3 px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<section class="border-y border-border">
|
||||
<div class="flex flex-col gap-3 px-1 py-2 sm:px-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="pt-0.5">
|
||||
<TogglePrimitive
|
||||
|
|
@ -359,14 +359,14 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
|
|||
<Show when={!state.shouldShowPatrolSetupOnly()}>
|
||||
<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 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',
|
||||
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover 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 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"
|
||||
class="flex min-h-11 items-center justify-center gap-2 rounded-md border border-transparent px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content sm:min-h-0"
|
||||
>
|
||||
<SettingsIcon class="h-4 w-4" />
|
||||
Settings
|
||||
|
|
@ -376,8 +376,8 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
|
|||
</Show>
|
||||
</div>
|
||||
|
||||
<details id={PATROL_CONTROL_ANCHOR} class="border-t border-border-subtle px-4 py-2 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">
|
||||
<details id={PATROL_CONTROL_ANCHOR} class="border-t border-border-subtle px-1 py-1 sm:px-2">
|
||||
<summary class="min-h-11 cursor-pointer text-xs font-medium text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:min-h-0">
|
||||
Mode and automation
|
||||
</summary>
|
||||
<div class="pt-3">
|
||||
|
|
|
|||
|
|
@ -14,12 +14,38 @@ import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench';
|
|||
import { PatrolObjectivesPanel } from './PatrolObjectivesPanel';
|
||||
import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel';
|
||||
|
||||
type PatrolWorkspaceView = 'inbox' | 'protection' | 'activity';
|
||||
const PATROL_WORKSPACE_VIEWS: readonly PatrolWorkspaceView[] = ['inbox', 'protection', 'activity'];
|
||||
|
||||
export function PatrolIntelligenceSurface() {
|
||||
const state = usePatrolIntelligenceState();
|
||||
const [activeView, setActiveView] = createSignal<PatrolWorkspaceView>('inbox');
|
||||
const [findingsOpen, setFindingsOpen] = createSignal(false);
|
||||
const workspaceTabs: Partial<Record<PatrolWorkspaceView, HTMLButtonElement>> = {};
|
||||
let findingsPanel: HTMLDetailsElement | undefined;
|
||||
const openWorkCount = () => aiIntelligenceStore.patrolOpenWorkCount;
|
||||
const activateView = (view: PatrolWorkspaceView, focus = false) => {
|
||||
setActiveView(view);
|
||||
if (focus) queueMicrotask(() => workspaceTabs[view]?.focus());
|
||||
};
|
||||
const handleWorkspaceKeyDown = (event: KeyboardEvent, currentView: PatrolWorkspaceView) => {
|
||||
const currentIndex = PATROL_WORKSPACE_VIEWS.indexOf(currentView);
|
||||
const requestedIndex =
|
||||
event.key === 'ArrowRight'
|
||||
? (currentIndex + 1) % PATROL_WORKSPACE_VIEWS.length
|
||||
: event.key === 'ArrowLeft'
|
||||
? (currentIndex - 1 + PATROL_WORKSPACE_VIEWS.length) % PATROL_WORKSPACE_VIEWS.length
|
||||
: event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? PATROL_WORKSPACE_VIEWS.length - 1
|
||||
: -1;
|
||||
if (requestedIndex < 0) return;
|
||||
event.preventDefault();
|
||||
activateView(PATROL_WORKSPACE_VIEWS[requestedIndex], true);
|
||||
};
|
||||
const openFindings = () => {
|
||||
activateView('activity');
|
||||
setFindingsOpen(true);
|
||||
queueMicrotask(() => {
|
||||
findingsPanel?.scrollIntoView?.({ block: 'start' });
|
||||
|
|
@ -31,120 +57,171 @@ export function PatrolIntelligenceSurface() {
|
|||
<div class="space-y-4 lg:space-y-5">
|
||||
<PatrolIntelligenceHeader state={state} />
|
||||
<PatrolIntelligenceBanners state={state} />
|
||||
<PatrolAttentionWorkbench
|
||||
autonomyLevel={state.autonomyLevel()}
|
||||
autonomyLocked={state.autoFixLocked()}
|
||||
pendingActionCount={actionInboxStore.pendingActionCount}
|
||||
onOpenFindings={openFindings}
|
||||
/>
|
||||
|
||||
<div class="grid min-w-0 gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(20rem,0.85fr)]">
|
||||
<PatrolObjectivesPanel />
|
||||
<PatrolRecentWorkPanel />
|
||||
<div role="tablist" aria-label="Patrol workspace" class="flex gap-1 border-b border-border">
|
||||
{PATROL_WORKSPACE_VIEWS.map((view) => (
|
||||
<button
|
||||
ref={(element) => {
|
||||
workspaceTabs[view] = element;
|
||||
}}
|
||||
type="button"
|
||||
id={`patrol-${view}-tab`}
|
||||
role="tab"
|
||||
aria-selected={activeView() === view}
|
||||
aria-controls={`patrol-${view}-panel`}
|
||||
tabindex={activeView() === view ? 0 : -1}
|
||||
class={`relative min-h-11 rounded-t-md px-4 py-2 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 ${
|
||||
activeView() === view
|
||||
? 'bg-surface text-base-content after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:bg-blue-600'
|
||||
: 'text-muted hover:bg-surface-hover hover:text-base-content'
|
||||
}`}
|
||||
onClick={() => activateView(view)}
|
||||
onKeyDown={(event) => handleWorkspaceKeyDown(event, view)}
|
||||
>
|
||||
{view === 'inbox' ? 'Inbox' : view === 'protection' ? 'Protection' : 'Activity'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section
|
||||
aria-labelledby="patrol-continuity-title"
|
||||
class="rounded-lg border border-border bg-surface"
|
||||
>
|
||||
<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"
|
||||
<Show when={activeView() === 'inbox'}>
|
||||
<div id="patrol-inbox-panel" role="tabpanel" aria-labelledby="patrol-inbox-tab">
|
||||
<Show
|
||||
when={!state.shouldShowPatrolSetupOnly()}
|
||||
fallback={
|
||||
<div
|
||||
class={`rounded-lg border border-border bg-surface p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'pointer-events-none opacity-50' : ''}`}
|
||||
>
|
||||
<PatrolIntelligenceWorkspace state={state} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
<PatrolAttentionWorkbench
|
||||
autonomyLevel={state.autonomyLevel()}
|
||||
autonomyLocked={state.autoFixLocked()}
|
||||
pendingActionCount={actionInboxStore.pendingActionCount}
|
||||
onOpenFindings={openFindings}
|
||||
/>
|
||||
</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>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<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 class="sr-only">Findings and run records</summary>
|
||||
<Show when={activeView() === 'protection'}>
|
||||
<div id="patrol-protection-panel" role="tabpanel" aria-labelledby="patrol-protection-tab">
|
||||
<PatrolObjectivesPanel />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === 'activity'}>
|
||||
<div
|
||||
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
id="patrol-activity-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="patrol-activity-tab"
|
||||
class="space-y-4 lg:space-y-5"
|
||||
>
|
||||
<PatrolIntelligenceWorkspace state={state} />
|
||||
<PatrolRecentWorkPanel />
|
||||
|
||||
<section
|
||||
aria-labelledby="patrol-continuity-title"
|
||||
class="rounded-lg border border-border bg-surface"
|
||||
>
|
||||
<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">
|
||||
Audit governed 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>
|
||||
</section>
|
||||
|
||||
<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 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' : ''}`}
|
||||
>
|
||||
<PatrolIntelligenceWorkspace state={state} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,16 +230,17 @@ describe('PatrolAttentionWorkbench', () => {
|
|||
renderWorkbench();
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Needs your attention' }),
|
||||
await screen.findByRole('heading', { name: '1 decision needs you' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('1 decision needs you')).toBeInTheDocument();
|
||||
expect(screen.getByText("Today's Patrol briefing")).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Decision inbox' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Start review' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Review this decision' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Open Disk pressure on Database VM' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Today's Patrol briefing")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Decision inbox' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Review this decision' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Open', { exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Review', { exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('group', { name: 'Attention state' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('combobox', { name: 'Attention state' })).not.toBeInTheDocument();
|
||||
expect(apiMocks.getList).toHaveBeenCalledWith('active');
|
||||
|
|
|
|||
|
|
@ -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 justify-center gap-2 rounded-md border border-blue-600',
|
||||
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-border',
|
||||
);
|
||||
expect(headerSource).toContain(
|
||||
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-border',
|
||||
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-transparent',
|
||||
);
|
||||
expect(headerSource).toContain('sm:min-h-0');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -535,6 +535,13 @@ const defaultOperationsLoopStatus = (overrides: Record<string, unknown> = {}) =>
|
|||
...overrides,
|
||||
});
|
||||
|
||||
async function openPatrolActivityMode() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: 'Activity' })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Activity' }));
|
||||
}
|
||||
|
||||
describe('AIIntelligence entitlement gating', () => {
|
||||
it('keeps Patrol page data sync bounded without making it a primary action', () => {
|
||||
expect(patrolIntelligenceStateSource).toContain('PATROL_REFRESH_TIMEOUT_MS');
|
||||
|
|
@ -729,7 +736,9 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
|
||||
expect(patrolControlAnchor).toContainElement(operationsLoopAnchor);
|
||||
expect(screen.queryByTestId('patrol-current-work')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Current Patrol issues appear here.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Current Patrol issues appear here.')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Inbox' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('region', { name: 'Patrol decision inbox' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Issues Patrol found. Infrastructure stays unchanged.'),
|
||||
).not.toBeInTheDocument();
|
||||
|
|
@ -887,7 +896,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
render(() => <AIIntelligence />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /Check now/i }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
getCorrelationsMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
|
@ -914,7 +923,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
render(() => <AIIntelligence />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /Check now/i }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Check now/i })[0]);
|
||||
|
|
@ -1063,6 +1072,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
expect(screen.queryByText('VM 200')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Full → Restart')).not.toBeInTheDocument();
|
||||
|
||||
await openPatrolActivityMode();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'History' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Patrol history' })).toBeInTheDocument();
|
||||
|
|
@ -1494,7 +1504,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Open work')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Patrol decision inbox' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Loading view...')).not.toBeInTheDocument();
|
||||
|
|
@ -1514,7 +1524,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol could not refresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open work')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Patrol decision inbox' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Loading view...')).not.toBeInTheDocument();
|
||||
|
|
@ -1785,6 +1795,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
};
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
await openPatrolActivityMode();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open work')).toBeInTheDocument();
|
||||
|
|
@ -2012,7 +2023,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 your attention' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: 'Patrol decision inbox' })).not.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();
|
||||
|
|
@ -2061,7 +2072,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
await waitFor(() => {
|
||||
expect(screen.getByText('Patrol model issue')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Patrol decision inbox' })).toBeInTheDocument();
|
||||
const providerActions = screen.getAllByRole('link', { name: /Check Patrol model/i });
|
||||
expect(providerActions.length).toBeGreaterThan(0);
|
||||
expect(providerActions[0]).toHaveAttribute('href', '/settings/pulse-intelligence/patrol');
|
||||
|
|
@ -2378,6 +2389,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
};
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
await openPatrolActivityMode();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPatrolStatusMock).toHaveBeenCalled();
|
||||
|
|
@ -2444,6 +2456,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
};
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
await openPatrolActivityMode();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPatrolStatusMock).toHaveBeenCalled();
|
||||
|
|
@ -2513,6 +2526,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
};
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
await openPatrolActivityMode();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPatrolStatusMock).toHaveBeenCalled();
|
||||
|
|
@ -2574,6 +2588,7 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
};
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
await openPatrolActivityMode();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPatrolStatusMock).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -604,7 +604,10 @@ test.describe("Patrol Assistant operator briefing", () => {
|
|||
});
|
||||
|
||||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /Findings and run records/ }).click();
|
||||
await page.getByRole("tab", { name: "Activity", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /Findings and run records/ })
|
||||
.click();
|
||||
|
||||
await page.getByText("High CPU usage").click();
|
||||
const findingReview = page.locator(
|
||||
|
|
@ -740,7 +743,10 @@ test.describe("Patrol Assistant operator briefing", () => {
|
|||
|
||||
includePendingApproval = false;
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /Findings and run records/ }).click();
|
||||
await page.getByRole("tab", { name: "Activity", exact: true }).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();
|
||||
|
|
@ -770,14 +776,19 @@ test.describe("Patrol Assistant operator briefing", () => {
|
|||
includeUnifiedInvestigationRecord = false;
|
||||
includeInvestigationProposedFix = true;
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /Findings and run records/ }).click();
|
||||
await page.getByRole("tab", { name: "Activity", exact: true }).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();
|
||||
const expiredFinding = page.locator(
|
||||
"#finding-finding-operator-briefing-details",
|
||||
);
|
||||
await expect(expiredFinding.getByText("Action details unavailable")).toBeVisible();
|
||||
await expect(
|
||||
expiredFinding.getByText("Action details unavailable"),
|
||||
).toBeVisible();
|
||||
await expiredFinding.getByText("Manage", { exact: true }).click();
|
||||
await expiredFinding
|
||||
.getByRole("button", { name: "Open in Assistant" })
|
||||
|
|
|
|||
|
|
@ -293,8 +293,10 @@ const buildAttentionItem = (
|
|||
eligibility: "eligible",
|
||||
reasons: [],
|
||||
evidenceIds: ["evidence-high-cpu"],
|
||||
expectedPostcondition: "The agent reconnects and reports current CPU evidence.",
|
||||
verificationPolicy: "Confirm a fresh agent heartbeat after restart.",
|
||||
expectedPostcondition:
|
||||
"The agent reconnects and reports current CPU evidence.",
|
||||
verificationPolicy:
|
||||
"Confirm a fresh agent heartbeat after restart.",
|
||||
requiresApproval: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -714,7 +716,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 your attention" }),
|
||||
page.getByRole("heading", { level: 2, name: "No decisions are waiting" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Nothing needs you right now")).toBeVisible();
|
||||
await expect(
|
||||
|
|
@ -760,7 +762,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 your attention" }),
|
||||
page.getByRole("heading", { level: 2, name: "1 decision needs you" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("list", { name: "Patrol attention items" }),
|
||||
|
|
@ -882,7 +884,10 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
|
|||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "Patrol" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /Findings and run records/ }).click();
|
||||
await page.getByRole("tab", { name: "Activity", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /Findings and run records/ })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText("Operating system updates need review").first(),
|
||||
).toBeVisible();
|
||||
|
|
@ -926,7 +931,10 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
|
|||
}),
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /Findings and run records/ }).click();
|
||||
await page.getByRole("tab", { name: "Activity", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /Findings and run records/ })
|
||||
.click();
|
||||
const cleanupTitle = page
|
||||
.getByText("Downloaded package data is using needed space")
|
||||
.first();
|
||||
|
|
@ -981,7 +989,10 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
|
|||
}),
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /Findings and run records/ }).click();
|
||||
await page.getByRole("tab", { name: "Activity", exact: true }).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")
|
||||
|
|
|
|||
|
|
@ -686,32 +686,60 @@ async function mockAttention(
|
|||
|
||||
test("starts from the normal monitor shell and reaches the canonical attention queue", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
await mockAttention(page, "active");
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const patrolNavigation = testInfo.project.name.startsWith("mobile-")
|
||||
? page
|
||||
.getByRole("navigation", { name: "Mobile navigation" })
|
||||
.getByRole("button", { name: /Patrol/ })
|
||||
: page
|
||||
.getByRole("tab", { name: /Patrol/ })
|
||||
.or(page.getByRole("link", { name: /Patrol/ }));
|
||||
const patrolNavigation =
|
||||
(page.viewportSize()?.width ?? 1280) < 1024
|
||||
? page
|
||||
.getByRole("navigation", { name: "Mobile navigation" })
|
||||
.getByRole("button", { name: /Patrol/ })
|
||||
: page
|
||||
.getByRole("tab", { name: /Patrol/ })
|
||||
.or(page.getByRole("link", { name: /Patrol/ }));
|
||||
await patrolNavigation.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/patrol/);
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Needs your attention" }),
|
||||
page.getByRole("region", { name: "Patrol decision inbox" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open CPU pressure on pve-main" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("2 decisions need you")).toBeVisible();
|
||||
if (!testInfo.project.name.startsWith("mobile-")) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Review this decision" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "Inbox", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Protected outcomes", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Verified outcomes", exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "Protection", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Protected outcomes", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Patrol decision inbox" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "Activity", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Verified outcomes", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Review and history", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Protected outcomes", exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "Inbox", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Patrol decision inbox" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("makes active operational work primary and preserves the evidence boundary", async ({
|
||||
|
|
@ -727,8 +755,7 @@ test("makes active operational work primary and preserves the evidence boundary"
|
|||
}),
|
||||
),
|
||||
).toBeVisible();
|
||||
const queue = page.getByRole("region", { name: "Needs your attention" });
|
||||
await expect(queue.getByLabel("2 items require review")).toBeVisible();
|
||||
const queue = page.getByRole("region", { name: "Patrol decision inbox" });
|
||||
const attentionList = queue.getByRole("list", {
|
||||
name: "Patrol attention items",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
|
||||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const queue = page.getByRole("region", { name: "Needs your attention" });
|
||||
const queue = page.getByRole("region", { name: "Patrol decision inbox" });
|
||||
await expect(queue).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.getByRole("tab", {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue