Redesign Patrol open work queue

This commit is contained in:
rcourtman 2026-07-13 16:12:37 +01:00
parent 2e9520da81
commit f9f90a1a27
14 changed files with 953 additions and 961 deletions

View file

@ -133,6 +133,13 @@ loop, and routes the operator from real findings/evidence to safe action.
evidence, next step, and verification state from existing finding, approval,
and workflow fields. The scaffold stays attached to the issue row and does
not create a separate proof, trust, or status strip.
- 2026-07-13: Replaced the always-visible seven-field scaffold with a compact
prioritisation queue and one focused selected-issue review. Collapsed rows
now keep severity, workflow state, title, resource, recency, and one
consequence visible; evidence, recommendation, approvals, verification,
history, and manual controls move into review. Active findings may group only
through API-owned same-resource, shared-node, or explicit-correlation facts,
and the duplicate count badge beside Open work was removed.
- 2026-06-30: Added Proxmox overview monitor-context Patrol coverage posture.
It appears only when Patrol has no current work, failed/latest check,
running check, setup failure, overdue schedule, or pending approval; uses

View file

@ -85,11 +85,17 @@ or historical proof/counting for resolved-only work.
problem above the list. When active Patrol findings are the next operator
step, the finding row owns the canonical primary action, contextual
Assistant handoffs, approvals, verification, and manual controls. A
compact row-owned issue scaffold may translate the same finding, approval,
and workflow fields into problem, affected resource, consequence, checked
evidence, safe workflow step, next step, and verification state, but it must
stay attached to the active issue row and must not become a separate proof,
trust, or status strip. Because a
compact row must expose only what the operator needs to prioritise the
queue: severity, current workflow state, title, affected resource, recency,
and one bounded consequence summary. Checked evidence, recommendation,
approval controls, verification state, history, and manual controls belong
to the single selected issue review beside the queue; the collapsed list
must not render those fields as an always-open checklist. Findings may be
grouped only from API-owned identity facts: the same canonical resource, a
shared parent node, or explicit correlated finding IDs. Grouping must not
infer causality from title or description prose, and the queue's affected
resource count must continue to count canonical resources rather than
visual groups. Because a
contextual Assistant handoff from that workflow is still a first-party Patrol
starter for the same governed journey, it must record content-free workflow
prompt activity through the shared marker route with the `pulse_patrol`
@ -920,6 +926,19 @@ fix`, or `Explain` based on current finding state), while secondary
## Current State
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,
plus one consequence line; evidence, recommendation, approval, verification,
history, and manual controls render only in the selected review. The previous
seven-field always-visible row scaffold and duplicate Open work count badge
were retired. Active findings group through API-owned same-resource, parent
node, or explicit correlation relationships, while the workspace continues to
count the underlying affected resources. Component, presentation, and browser
journey proof live in `FindingsPanel.links.test.tsx`,
`FindingsPanel.test.ts`, `aiFindingWorkType.test.ts`, and
`tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts`.
Patrol provider-repair actions now use the canonical Pulse Intelligence >
Provider & Models route `/settings/pulse-intelligence/provider`. The legacy
`/settings/system-ai` route remains a compatibility alias for old deep links,

View file

@ -37,7 +37,10 @@ import { createSuppressionRuleFromFinding, reinvestigateFinding } from '@/api/pa
import type { PatrolRunRecord, PatrolRuntimeState, PatrolAutonomyLevel } from '@/api/patrol';
import { formatRelativeTime } from '@/utils/format';
import { getFindingAlertIdentifier, hasTriggeringAlert } from '@/utils/findingAlertIdentity';
import { FilterSegmentedControl, type FilterSegmentOption } from '@/components/shared/FilterToolbar';
import {
FilterSegmentedControl,
type FilterSegmentOption,
} from '@/components/shared/FilterToolbar';
import { getSemanticTonePresentation } from '@/utils/semanticTonePresentation';
import { formatIdentifierLabel } from '@/utils/textPresentation';
import type { IntelligenceHealthScore } from '@/types/aiIntelligence';
@ -49,6 +52,7 @@ import AlertCircleIcon from 'lucide-solid/icons/alert-circle';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import InfoIcon from 'lucide-solid/icons/info';
import ChevronDownIcon from 'lucide-solid/icons/chevron-down';
import XIcon from 'lucide-solid/icons/x';
import {
buildFindingFilterOptions,
formatFindingForClipboard,
@ -83,7 +87,7 @@ import {
getInvestigationOutcomeSortOrder,
getInvestigationStatusBadgeTone,
getPatrolFindingActionableState,
getPatrolFindingRowScaffold,
getPatrolFindingQueueSummary,
} from '@/utils/aiFindingPresentation';
import { copyToClipboard } from '@/utils/clipboard';
@ -94,16 +98,9 @@ const FINDING_ROW_BADGE_PROPS = {
} as const;
export type FindingsPanelFilter =
| 'all'
| 'active'
| 'resolved'
| 'approvals'
| 'attention'
| 'overdue';
'all' | 'active' | 'resolved' | 'approvals' | 'attention' | 'overdue';
function capacityForecastToneClass(
tone: 'critical' | 'warning' | 'info',
): string {
function capacityForecastToneClass(tone: 'critical' | 'warning' | 'info'): string {
switch (tone) {
case 'critical':
return 'text-red-700 dark:text-red-300';
@ -114,6 +111,19 @@ function capacityForecastToneClass(
}
}
function patrolSeverityAccentClass(severity: UnifiedFinding['severity']): string {
switch (severity) {
case 'critical':
return 'border-l-red-500';
case 'warning':
return 'border-l-amber-500';
case 'watch':
return 'border-l-sky-500';
default:
return 'border-l-slate-400';
}
}
interface FindingsPanelProps {
resourceId?: string;
showResolved?: boolean;
@ -449,7 +459,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
(f) => f.source !== 'threshold' && !f.isThreshold && !hasTriggeringAlert(f),
);
});
const shouldGroupPatrolFindingsByResource = createMemo(
const shouldGroupActivePatrolFindings = createMemo(
() =>
isPatrolFindingsSource() &&
filter() === 'active' &&
@ -458,11 +468,12 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
);
const patrolFindingDisplayGroups = createMemo(() => {
const findings = patrolFindings();
if (!shouldGroupPatrolFindingsByResource()) {
if (!shouldGroupActivePatrolFindings()) {
return findings.map((finding) => ({
id: `finding:${finding.id}`,
resourceKey: `finding:${finding.id}`,
resourceLabel: getFindingSubjectPresentation(finding).label,
affectedResourceCount: 1,
kind: 'finding' as const,
label: getFindingSubjectPresentation(finding).label,
primaryFinding: finding,
relatedFindings: [],
findings: [finding],
@ -471,6 +482,22 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
return buildPatrolFindingDisplayGroups(findings);
});
const selectedPatrolGroup = createMemo(() => {
if (!isPatrolFindingsSource() || !expandedId()) return undefined;
return patrolFindingDisplayGroups().find((group) =>
group.findings.some((finding) => finding.id === expandedId()),
);
});
const selectedPatrolFinding = createMemo(() => {
const selectedId = expandedId();
if (!selectedId) return undefined;
return selectedPatrolGroup()?.findings.find((finding) => finding.id === selectedId);
});
createEffect(() => {
if (isPatrolFindingsSource() && expandedId() && !selectedPatrolFinding()) {
setExpandedId(null);
}
});
const filterOptions = createMemo(() => buildFindingFilterOptions(filterCounts()));
const filterSegments = createMemo<FilterSegmentOption[]>(() => {
const segments: FilterSegmentOption[] = filterOptions().map((option) => ({
@ -677,9 +704,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
}
} catch (err) {
setActionLoading(null);
notificationStore.error(
err instanceof Error ? err.message : 'Failed to start investigation',
);
notificationStore.error(err instanceof Error ? err.message : 'Failed to start investigation');
}
};
@ -905,10 +930,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
const patrolWorkflow = () =>
getFindingPatrolWorkflowPresentation(finding, aiIntelligenceStore.patrolPendingApprovals);
const patrolActionableState = () => getPatrolFindingActionableState(finding);
const patrolRowScaffold = () =>
isPatrolFindingsSource()
? getPatrolFindingRowScaffold(finding, aiIntelligenceStore.patrolPendingApprovals)
: undefined;
const patrolQueueSummary = () => getPatrolFindingQueueSummary(finding);
const collapsedApprovalAction = () => {
const workflow = patrolWorkflow();
return workflow?.stage === 'approval' ? workflow : undefined;
@ -950,10 +972,18 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
<div
id={`finding-${finding.id}`}
class={`p-3 transition-colors ${
isPatrolFindingsSource()
? `border-l-2 ${patrolSeverityAccentClass(finding.severity)} ${
expandedId() === finding.id ? 'bg-surface-alt/80' : 'hover:bg-surface-hover'
}`
: ''
} ${
finding.status === 'active'
? finding.acknowledgedAt
? 'opacity-60 hover:opacity-80 bg-surface-alt'
: 'hover:bg-surface-hover'
: isPatrolFindingsSource()
? ''
: 'hover:bg-surface-hover'
: 'opacity-60 bg-surface-alt hover:opacity-80'
}`}
>
@ -1007,15 +1037,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
verifying fix, fix failed). Raw investigation-status and
investigation-outcome badges stay hidden for Patrol
findings; this badge surfaces the operator-facing meaning. */}
<Show when={isPatrolFindingsSource() && patrolActionableState()}>
{(state) => (
<MetadataBadge {...FINDING_ROW_BADGE_PROPS} tone={state().tone}>
{state().label}
</MetadataBadge>
)}
<Show when={isPatrolFindingsSource()}>
<MetadataBadge
{...FINDING_ROW_BADGE_PROPS}
tone={patrolActionableState()?.tone ?? 'muted'}
>
{patrolActionableState()?.label ?? 'Needs review'}
</MetadataBadge>
</Show>
{/* Alert-triggered badge */}
<Show when={hasTriggeringAlert(finding)}>
<Show when={!isPatrolFindingsSource() && hasTriggeringAlert(finding)}>
<MetadataBadge
{...FINDING_ROW_BADGE_PROPS}
tone="warning"
@ -1101,7 +1132,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
needs to be triaged differently from a fresh detection.
Sits next to the confidence badge so trust + recurrence
can be scanned together without expanding the card. */}
<Show when={(finding.regressionCount || 0) > 0}>
<Show when={!isPatrolFindingsSource() && (finding.regressionCount || 0) > 0}>
<MetadataBadge
{...FINDING_ROW_BADGE_PROPS}
tone="warning"
@ -1120,12 +1151,12 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</span>
</div>
{/* Resource info */}
<div class="text-xs text-muted mt-1">
<div class="mt-1 text-xs text-muted">
<Show
when={options.hideSubject}
fallback={
<>
{subject.label} - {recency.label} {formatTime(recency.timestamp)}
{subject.label} {' · '} {recency.label} {formatTime(recency.timestamp)}
</>
}
>
@ -1187,27 +1218,19 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</span>
</Show>
</div>
<Show when={patrolRowScaffold()}>
{(scaffold) => (
<dl
aria-label="Patrol issue summary"
class="mt-2 grid gap-x-3 gap-y-1 text-xs sm:grid-cols-2 xl:grid-cols-3"
>
<For each={scaffold().items}>
{(item) => (
<div class="min-w-0">
<dt class="font-medium text-base-content">{item.label}</dt>
<dd class="mt-0.5 break-words text-muted">{item.value}</dd>
</div>
)}
</For>
</dl>
)}
<Show when={isPatrolFindingsSource()}>
<p class="mt-1.5 truncate text-xs leading-5 text-muted">{patrolQueueSummary()}</p>
</Show>
</div>
{/* Actions */}
<div class="flex items-center gap-1 shrink-0">
<Show when={expandedId() !== finding.id ? collapsedApprovalAction() : undefined}>
<Show
when={
!isPatrolFindingsSource() && expandedId() !== finding.id
? collapsedApprovalAction()
: undefined
}
>
{(action) => (
<button
type="button"
@ -1288,23 +1311,51 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</Show>
<button
type="button"
aria-label={`${expandedId() === finding.id ? 'Hide' : 'View'} details for ${title.label}`}
aria-label={
isPatrolFindingsSource()
? `${expandedId() === finding.id ? 'Close review' : 'Review issue'} for ${title.label}`
: `${expandedId() === finding.id ? 'Hide' : 'View'} details for ${title.label}`
}
aria-expanded={expandedId() === finding.id}
aria-controls={`finding-${finding.id}-details`}
onClick={toggleExpanded}
class="inline-flex items-center gap-1 rounded border border-border bg-surface px-2 py-1 text-xs font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={expandedId() === finding.id ? 'Hide details' : 'View details'}
title={
isPatrolFindingsSource()
? expandedId() === finding.id
? 'Close review'
: 'Review issue'
: expandedId() === finding.id
? 'Hide details'
: 'View details'
}
>
<span>{expandedId() === finding.id ? 'Hide' : 'Details'}</span>
<span>
{isPatrolFindingsSource()
? expandedId() === finding.id
? 'Close'
: 'Review'
: expandedId() === finding.id
? 'Hide'
: 'Details'}
</span>
<ChevronDownIcon
class={`h-3.5 w-3.5 transition-transform ${expandedId() === finding.id ? 'rotate-180' : ''}`}
class={`h-3.5 w-3.5 transition-transform ${
isPatrolFindingsSource()
? '-rotate-90'
: expandedId() === finding.id
? 'rotate-180'
: ''
}`}
/>
</button>
</div>
</div>
{/* Expanded content */}
<Show when={expandedId() === finding.id}>{renderExpandedContent(finding, options)}</Show>
<Show when={!isPatrolFindingsSource() && expandedId() === finding.id}>
{renderExpandedContent(finding, options)}
</Show>
</div>
);
};
@ -1362,8 +1413,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</Show>
<Show when={finding.recommendation}>
<p class="text-sm text-base-content mt-2">
<span class="font-medium">Recommended next step:</span>{' '}
{finding.recommendation}
<span class="font-medium">Recommended next step:</span> {finding.recommendation}
</p>
</Show>
<Show when={finding.aiConfidence && finding.aiConfidence > 0}>
@ -1599,11 +1649,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
<span class="text-muted">{proHandoff!.detail}</span>
<Show when={proHandoff!.destination} keyed>
{(destination) => (
<UpgradeButtonLink
destination={destination}
size="sm"
mobileFullWidth={false}
>
<UpgradeButtonLink destination={destination} size="sm" mobileFullWidth={false}>
{proHandoff!.actionLabel}
</UpgradeButtonLink>
)}
@ -1980,87 +2026,150 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</Show>
<Show when={!shouldShowLoadingState()}>
<Card padding="none" class="overflow-hidden">
{/* Content */}
<div class="divide-y divide-border-subtle">
<Show when={patrolFindingDisplayGroups().length === 0}>
<div class="p-6 text-sm text-muted text-center">
<Show when={shouldShowRichEmptyState()}>
<div class="flex flex-col items-center gap-3">
<Show
when={emptyStateCopy().tone === 'success'}
fallback={
<Show
when={emptyStateCopy().tone === 'error'}
fallback={
<Show
when={emptyStateCopy().tone === 'warning'}
fallback={
<InfoIcon class={`w-10 h-10 ${emptyStateTone().iconClass}`} />
}
>
<AlertTriangleIcon
class={`w-10 h-10 ${emptyStateTone().iconClass}`}
/>
</Show>
}
>
<AlertTriangleIcon class={`w-10 h-10 ${emptyStateTone().iconClass}`} />
</Show>
}
>
<CheckCircleIcon class={`w-10 h-10 ${emptyStateTone().iconClass}`} />
</Show>
<div>
<p class="font-medium text-base-content">{emptyStateCopy().title}</p>
<Show when={emptyStateCopy().body}>
<p class="text-xs mt-1">{emptyStateCopy().body}</p>
<div
class={
selectedPatrolFinding()
? 'grid items-start gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(24rem,1.1fr)]'
: ''
}
>
<Card padding="none" class="overflow-hidden">
{/* Content */}
<div class="divide-y divide-border-subtle">
<Show when={patrolFindingDisplayGroups().length === 0}>
<div class="p-6 text-sm text-muted text-center">
<Show when={shouldShowRichEmptyState()}>
<div class="flex flex-col items-center gap-3">
<Show
when={emptyStateCopy().tone === 'success'}
fallback={
<Show
when={emptyStateCopy().tone === 'error'}
fallback={
<Show
when={emptyStateCopy().tone === 'warning'}
fallback={
<InfoIcon class={`w-10 h-10 ${emptyStateTone().iconClass}`} />
}
>
<AlertTriangleIcon
class={`w-10 h-10 ${emptyStateTone().iconClass}`}
/>
</Show>
}
>
<AlertTriangleIcon class={`w-10 h-10 ${emptyStateTone().iconClass}`} />
</Show>
}
>
<CheckCircleIcon class={`w-10 h-10 ${emptyStateTone().iconClass}`} />
</Show>
<div>
<p class="font-medium text-base-content">{emptyStateCopy().title}</p>
<Show when={emptyStateCopy().body}>
<p class="text-xs mt-1">{emptyStateCopy().body}</p>
</Show>
</div>
</div>
</div>
</Show>
<Show when={filter() === 'attention'}>{emptyStateCopy().title}</Show>
<Show when={filter() === 'approvals'}>{emptyStateCopy().title}</Show>
<Show when={filter() === 'overdue'}>No overdue will-fix-later commitments.</Show>
<Show
when={
filter() !== 'active' &&
filter() !== 'attention' &&
filter() !== 'approvals' &&
filter() !== 'overdue' &&
props.runSnapshot === undefined
}
>
{emptyStateCopy().title}
</Show>
</div>
</Show>
<For each={patrolFindingDisplayGroups()}>
{(group) => (
<Show
when={group.findings.length > 1}
fallback={renderFindingItem(group.primaryFinding, false)}
>
<section
class="divide-y divide-border-subtle"
aria-label={`${group.resourceLabel}: ${getPatrolFindingIssueCountLabel(group.findings.length)}`}
</Show>
<Show when={filter() === 'attention'}>{emptyStateCopy().title}</Show>
<Show when={filter() === 'approvals'}>{emptyStateCopy().title}</Show>
<Show when={filter() === 'overdue'}>No overdue will-fix-later commitments.</Show>
<Show
when={
filter() !== 'active' &&
filter() !== 'attention' &&
filter() !== 'approvals' &&
filter() !== 'overdue' &&
props.runSnapshot === undefined
}
>
<div class="flex flex-wrap items-center justify-between gap-2 bg-surface-alt/60 px-3 py-2 text-xs">
<span class="font-semibold text-base-content">{group.resourceLabel}</span>
<span class="text-muted">
{getPatrolFindingIssueCountLabel(group.findings.length)}
</span>
{emptyStateCopy().title}
</Show>
</div>
</Show>
<For each={patrolFindingDisplayGroups()}>
{(group) => (
<Show
when={group.findings.length > 1}
fallback={renderFindingItem(group.primaryFinding, false)}
>
<section
class="divide-y divide-border-subtle"
aria-label={`${group.label}: ${getPatrolFindingIssueCountLabel(group.findings.length)}`}
>
<div class="flex flex-wrap items-center justify-between gap-2 bg-surface-alt/60 px-3 py-2 text-xs">
<span class="font-semibold text-base-content">{group.label}</span>
<span class="text-muted">
{getPatrolFindingIssueCountLabel(group.findings.length)}
<Show when={group.affectedResourceCount > 1}>
{` · ${group.affectedResourceCount} resources`}
</Show>
</span>
</div>
{renderFindingItem(group.primaryFinding, false, {
hideSubject: true,
relatedFindings: group.relatedFindings,
})}
</section>
</Show>
)}
</For>
</div>
</Card>
<Show when={selectedPatrolFinding()}>
{(finding) => {
const group = () => selectedPatrolGroup();
const title = () => getFindingTitlePresentation(finding());
const subject = () => getFindingSubjectPresentation(finding());
const recency = () => getFindingRecencyPresentation(finding());
const severity = () => getFindingSeverityPresentation(finding());
return (
<aside
id={`finding-${finding().id}-details`}
aria-label={`Review ${title().label}`}
class="overflow-hidden rounded-md border border-border bg-surface shadow-sm xl:sticky xl:top-4"
>
<div class="flex items-start justify-between gap-3 border-b border-border-subtle bg-surface-alt/60 px-4 py-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<MetadataBadge
{...FINDING_ROW_BADGE_PROPS}
tone={severity().badgeTone}
uppercase={severity().uppercase}
>
{severity().label}
</MetadataBadge>
<span class="text-xs font-medium text-muted">Review issue</span>
</div>
<h3 class="mt-2 text-sm font-semibold leading-5 text-base-content">
{title().label}
</h3>
<p class="mt-1 text-xs text-muted">
{subject().label} {' · '} {recency().label}{' '}
{formatTime(recency().timestamp)}
</p>
</div>
{renderFindingItem(group.primaryFinding, false, {
hideSubject: true,
relatedFindings: group.relatedFindings,
<button
type="button"
aria-label={`Close review panel for ${title().label}`}
onClick={() => setExpandedId(null)}
class="rounded p-1.5 text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
<XIcon class="h-4 w-4" />
</button>
</div>
<div class="max-h-[calc(100vh-12rem)] overflow-y-auto px-4 pb-4">
{renderExpandedContent(finding(), {
relatedFindings: group()?.relatedFindings ?? [],
})}
</section>
</Show>
)}
</For>
</div>
</Card>
</div>
</aside>
);
}}
</Show>
</div>
</Show>
</div>
);

View file

@ -190,7 +190,7 @@ vi.mock('@/stores/aiIntelligence', () => ({
describe('FindingsPanel resource links', () => {
const expectCollapsedFinding = (title: string) => {
expect(screen.getByRole('button', { name: `View details for ${title}` })).toBeInTheDocument();
expect(screen.getByRole('button', { name: `Review issue for ${title}` })).toBeInTheDocument();
};
beforeEach(() => {
@ -269,7 +269,7 @@ describe('FindingsPanel resource links', () => {
fireEvent.click(
screen.getByRole('button', {
name: 'View details for Provider billing or quota issue',
name: 'Review issue for Provider billing or quota issue',
}),
);
@ -306,15 +306,15 @@ describe('FindingsPanel resource links', () => {
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
const disclosure = screen.getByRole('button', {
name: 'View details for Provider connection issue',
name: 'Review issue for Provider connection issue',
});
expect(
screen.getAllByRole('button', {
name: 'View details for Provider connection issue',
name: 'Review issue for Provider connection issue',
}),
).toHaveLength(1);
expect(disclosure).toHaveTextContent('Details');
expect(disclosure).toHaveTextContent('Review');
expect(screen.queryByRole('button', { name: 'Acknowledge' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Snooze 24h' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Dismiss' })).not.toBeInTheDocument();
@ -323,9 +323,12 @@ describe('FindingsPanel resource links', () => {
expect(
screen.getByRole('button', {
name: 'Hide details for Provider connection issue',
name: 'Close review for Provider connection issue',
}),
).toHaveTextContent('Hide');
).toHaveTextContent('Close');
expect(
screen.getByRole('complementary', { name: 'Review Provider connection issue' }),
).toBeInTheDocument();
expect(screen.getByText('Manage')).toBeInTheDocument();
const assistantHandoff = screen.getByRole('button', { name: 'Open in Assistant' });
expect(assistantHandoff.closest('details')).not.toBeNull();
@ -442,17 +445,19 @@ describe('FindingsPanel resource links', () => {
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
const approvalAction = screen.getByRole('button', { name: 'Approve or reject' });
expect(approvalAction).toBeInTheDocument();
expect(screen.getByText('Approval required')).toBeInTheDocument();
const approvalAction = screen.getByRole('button', {
name: 'Review issue for Worker disk is full',
});
expect(screen.queryByText('Review finding')).not.toBeInTheDocument();
fireEvent.click(approvalAction);
expect(
screen.getByRole('button', {
name: 'Hide details for Worker disk is full',
name: 'Close review for Worker disk is full',
}),
).toHaveTextContent('Hide');
).toHaveTextContent('Close');
});
it('keeps selected Patrol run issue rows simple and expandable', async () => {
@ -521,7 +526,7 @@ describe('FindingsPanel resource links', () => {
expect(screen.queryByText('Review finding')).not.toBeInTheDocument();
const disclosure = screen.getByRole('button', {
name: 'View details for Unraid array running without parity protection while at 86% capacity',
name: 'Review issue for Unraid array running without parity protection while at 86% capacity',
});
expect(disclosure).toHaveAttribute('aria-expanded', 'false');
@ -529,7 +534,7 @@ describe('FindingsPanel resource links', () => {
expect(
screen.getByRole('button', {
name: 'Hide details for Unraid array running without parity protection while at 86% capacity',
name: 'Close review for Unraid array running without parity protection while at 86% capacity',
}),
).toHaveAttribute('aria-expanded', 'true');
expect(
@ -541,14 +546,14 @@ describe('FindingsPanel resource links', () => {
screen.getAllByText(
'Verify the parity check is progressing and let it complete before making array changes.',
),
).toHaveLength(2);
).toHaveLength(1);
expect(screen.getByText('Recommended next step:')).toBeInTheDocument();
expect(screen.getByText('What Pulse checked:')).toBeInTheDocument();
expect(
screen.getAllByText(
'Checked array status via Unraid API: parity_status=check, capacity=85.9%, disk2=95.8%.',
),
).toHaveLength(2);
).toHaveLength(1);
});
it('can render Patrol findings from the direct Patrol source', async () => {
@ -639,28 +644,18 @@ describe('FindingsPanel investigate action gating', () => {
const expandRow = () =>
fireEvent.click(
screen.getByRole('button', { name: 'View details for Provider connection issue' }),
screen.getByRole('button', { name: 'Review issue for Provider connection issue' }),
);
it('shows an Investigate action above Watch-only mode on an un-investigated patrol finding', async () => {
render(() => (
<FindingsPanel
findingsSource="patrol"
autonomyLevel="approval"
/>
));
render(() => <FindingsPanel findingsSource="patrol" autonomyLevel="approval" />);
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
expandRow();
expect(screen.getByRole('button', { name: /Investigate/ })).toBeInTheDocument();
});
it('hides the Investigate action in Watch-only (monitor) mode', async () => {
render(() => (
<FindingsPanel
findingsSource="patrol"
autonomyLevel="monitor"
/>
));
render(() => <FindingsPanel findingsSource="patrol" autonomyLevel="monitor" />);
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
expandRow();
expect(screen.queryByRole('button', { name: /^Investigate$/ })).toBeNull();

View file

@ -90,9 +90,7 @@ describe('FindingsPanel assistant handoff', () => {
// must project it through presentCapacityForecast so the operator sees a
// deterministic "Filling up / Stable · days-to-full · % used" line rather
// than relying on the model-authored description for urgency.
expect(findingsPanelSource).toContain(
"from '@/utils/patrolCapacityForecastPresentation'",
);
expect(findingsPanelSource).toContain("from '@/utils/patrolCapacityForecastPresentation'");
expect(findingsPanelSource).toContain('presentCapacityForecast(finding.capacityForecast)');
expect(findingsPanelSource).toContain('capacityForecastToneClass');
});
@ -185,9 +183,10 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain(
"aria-label={`${expandedId() === finding.id ? 'Close issue details' : 'Open issue details'} for ${title.label}`}",
);
expect(findingsPanelSource).toContain(
"aria-label={`${expandedId() === finding.id ? 'Hide' : 'View'} details for ${title.label}`}",
);
expect(findingsPanelSource).toContain("'Close review'");
expect(findingsPanelSource).toContain("'Review issue'");
expect(findingsPanelSource).toContain("'Hide'");
expect(findingsPanelSource).toContain("'View'");
expect(findingsPanelSource).toContain("e.key === 'Enter' || e.key === ' '");
expect(findingsPanelSource).toContain('onClick={toggleExpanded}');
});
@ -331,11 +330,11 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).not.toContain("workflow.stage === 'paused'");
});
it('turns approval-required Patrol findings into a direct collapsed action', () => {
it('keeps approval decisions inside the selected Patrol issue review', () => {
expect(findingsPanelSource).toContain('const collapsedApprovalAction = () => {');
expect(findingsPanelSource).toContain("workflow?.stage === 'approval'");
expect(findingsPanelSource).toContain(
'<Show when={expandedId() !== finding.id ? collapsedApprovalAction() : undefined}>',
'!isPatrolFindingsSource() && expandedId() !== finding.id',
);
expect(findingsPanelSource).toContain('title={action().detail}');
expect(findingsPanelSource).toContain('{action().label}');
@ -966,33 +965,18 @@ describe('aiFindingPresentation', () => {
expect(findingsPanelSource).toContain("{' · '}snoozed until");
});
it('uses shared metadata badges for the current Patrol queue resource count only', () => {
expect(patrolWorkspaceSource).toContain('aria-hidden="true"');
expect(patrolWorkspaceSource).toContain('<MetadataBadge');
expect(patrolWorkspaceSource).toContain('getPatrolQueueBadgeLabel');
it('keeps the Open work heading free of a duplicate count badge', () => {
expect(patrolWorkspaceSource).toContain('queueAffectedResourceCount');
expect(patrolWorkspaceSource).toContain('{queueBadgeLabel()}');
expect(patrolWorkspaceSource).not.toContain('{runHistoryCount()}');
});
it('routes the findings tab badge tone through the shared patrol findings badge helper', () => {
expect(patrolWorkspaceSource).toContain('getPatrolFindingsBadgePresentation');
expect(patrolWorkspaceSource).toContain('state.findingsTabBadgeFindings()');
expect(patrolWorkspaceSource).toContain('MetadataBadge');
expect(patrolWorkspaceSource).toContain('findingsBadgePresentation().tone');
expect(patrolWorkspaceSource).not.toContain('findingsBadgePresentation().toneClasses');
expect(patrolWorkspaceSource).not.toContain('getPatrolQueueBadgeLabel');
expect(patrolWorkspaceSource).not.toContain('{queueBadgeLabel()}');
});
it('surfaces work-type composition in the workspace description', () => {
expect(aiFindingPresentationSource).toContain(
'export function classifyPatrolFindingWorkType',
);
expect(aiFindingPresentationSource).toContain(
'export function getPatrolWorkTypeComposition',
);
expect(aiFindingPresentationSource).toContain(
'getPatrolWorkTypeCompositionClause',
);
expect(aiFindingPresentationSource).toContain('export function getPatrolWorkTypeComposition');
expect(aiFindingPresentationSource).toContain('getPatrolWorkTypeCompositionClause');
expect(patrolWorkspaceSource).toContain('getPatrolWorkTypeComposition');
expect(patrolWorkspaceSource).toContain('workTypeComposition');
});
@ -1000,21 +984,18 @@ describe('aiFindingPresentation', () => {
it('shows contract-sanctioned actionable state badges on collapsed Patrol rows', () => {
expect(findingsPanelSource).toContain('getPatrolFindingActionableState');
expect(findingsPanelSource).toContain('patrolActionableState');
expect(findingsPanelSource).toContain('isPatrolFindingsSource() && patrolActionableState()');
expect(findingsPanelSource).toContain('when={isPatrolFindingsSource()}');
expect(findingsPanelSource).toContain("patrolActionableState()?.label ?? 'Needs review'");
});
it('renders row-owned Patrol issue scaffolding without adding a trust strip', () => {
expect(findingsPanelSource).toContain('getPatrolFindingRowScaffold');
expect(findingsPanelSource).toContain('patrolRowScaffold');
expect(findingsPanelSource).toContain('aria-label="Patrol issue summary"');
expect(aiFindingPresentationSource).toContain("label: 'Problem'");
expect(aiFindingPresentationSource).toContain("label: 'Affected'");
expect(aiFindingPresentationSource).toContain("label: 'Why it matters'");
expect(aiFindingPresentationSource).toContain("label: 'What Pulse checked'");
expect(aiFindingPresentationSource).toContain("label: 'Safe workflow'");
expect(aiFindingPresentationSource).toContain("label: 'Recommended next step'");
expect(aiFindingPresentationSource).toContain("label: 'Verification'");
expect(aiFindingPresentationSource).toContain('getPatrolFindingWorkflowSummary');
it('keeps collapsed Patrol rows compact and moves full context into focused review', () => {
expect(findingsPanelSource).toContain('getPatrolFindingQueueSummary');
expect(findingsPanelSource).toContain("'Needs review'");
expect(findingsPanelSource).toContain("'Review issue'");
expect(findingsPanelSource).toContain('selectedPatrolFinding');
expect(findingsPanelSource).toContain('renderExpandedContent(finding()');
expect(findingsPanelSource).not.toContain('aria-label="Patrol issue summary"');
expect(findingsPanelSource).not.toContain('getPatrolFindingRowScaffold');
expect(findingsPanelSource).not.toContain('Patrol trust summary');
expect(findingsPanelSource).not.toContain('Patrol proof strip');
});
@ -1042,14 +1023,14 @@ describe('aiFindingPresentation', () => {
expect(findingsPanelSource).not.toContain('<Show when={collapsedPatrolStatusBadge()}>');
});
it('groups active Patrol queue findings by resource without changing run records', () => {
it('groups active Patrol queue findings by API-owned resource, node, and correlation facts', () => {
expect(findingsPanelSource).toContain('buildPatrolFindingDisplayGroups');
expect(findingsPanelSource).toContain('getPatrolFindingIssueCountLabel');
expect(findingsPanelSource).toContain('const shouldGroupPatrolFindingsByResource');
expect(findingsPanelSource).toContain('const shouldGroupActivePatrolFindings');
expect(findingsPanelSource).toContain("filter() === 'active'");
expect(findingsPanelSource).toContain('props.runSnapshot === undefined');
expect(findingsPanelSource).toContain(
'aria-label={`${group.resourceLabel}: ${getPatrolFindingIssueCountLabel(',
'aria-label={`${group.label}: ${getPatrolFindingIssueCountLabel(',
);
expect(findingsPanelSource).toContain(
'{getPatrolFindingIssueCountLabel(group.findings.length)}',
@ -1058,13 +1039,15 @@ describe('aiFindingPresentation', () => {
expect(findingsPanelSource).toContain('relatedFindings: group.relatedFindings');
expect(findingsPanelSource).not.toContain('<For each={group.findings}>');
expect(findingsPanelSource).toContain('hideSubject: true');
expect(aiFindingPresentationSource).toContain('correlatedFindingIds');
expect(aiFindingPresentationSource).toContain("String(finding.node || '').trim()");
});
it('uses canonical finding recency presentation instead of raw detected timestamps for active rows', () => {
expect(findingsPanelSource).toContain(
'const recency = getFindingRecencyPresentation(finding);',
);
expect(findingsPanelSource).toContain('{subject.label} - {recency.label} ');
expect(findingsPanelSource).toContain("{subject.label} {' · '} {recency.label}");
expect(findingsPanelSource).toContain('{formatTime(recency.timestamp)}');
});
@ -1093,7 +1076,7 @@ describe('aiFindingPresentation', () => {
expect(findingsPanelSource).toContain(
'const subject = getFindingSubjectPresentation(finding);',
);
expect(findingsPanelSource).toContain('{subject.label} - {recency.label}');
expect(findingsPanelSource).toContain("{subject.label} {' · '} {recency.label}");
expect(findingsPanelSource).not.toContain('{finding.resourceName} ({finding.resourceType})');
});
});
@ -1469,8 +1452,9 @@ describe('aiFindingPresentation', () => {
expect(groups).toHaveLength(2);
expect(groups[0]).toMatchObject({
resourceKey: 'subject:tower array (storage)',
resourceLabel: 'Tower Array (storage)',
affectedResourceCount: 1,
kind: 'resource',
label: 'Tower Array (storage)',
});
expect(groups[0].primaryFinding.id).toBe('critical');
expect(groups[0].findings.map((finding) => finding.id)).toEqual(['critical', 'warning']);
@ -1479,7 +1463,7 @@ describe('aiFindingPresentation', () => {
expect(groups[1].relatedFindings).toEqual([]);
});
it('groups display-identical resources even when backend finding ids differ', () => {
it('keeps display-identical resources separate when canonical resource ids differ', () => {
const groups = buildPatrolFindingDisplayGroups([
{
id: 'array-risk',
@ -1497,8 +1481,65 @@ describe('aiFindingPresentation', () => {
},
]);
expect(groups).toHaveLength(2);
expect(groups.map((group) => group.primaryFinding.id)).toEqual(['array-risk', 'pool-usage']);
});
it('groups different resources under their shared API-owned node', () => {
const groups = buildPatrolFindingDisplayGroups([
{
id: 'portal-restarts',
resourceId: 'container:portal',
resourceName: 'customer-portal',
resourceType: 'app-container',
node: 'edge-apps-01',
title: 'Container is restarting repeatedly',
},
{
id: 'inventory-restarts',
resourceId: 'container:inventory',
resourceName: 'inventory-api',
resourceType: 'app-container',
node: 'edge-apps-01',
title: 'Container is restarting repeatedly',
},
]);
expect(groups).toHaveLength(1);
expect(groups[0].findings.map((finding) => finding.id)).toEqual(['array-risk', 'pool-usage']);
expect(groups[0]).toMatchObject({
affectedResourceCount: 2,
kind: 'node',
label: 'Edge Apps 01',
});
});
it('groups explicitly correlated findings across different nodes', () => {
const groups = buildPatrolFindingDisplayGroups([
{
id: 'gateway',
resourceId: 'service:gateway',
resourceName: 'mail-gateway',
resourceType: 'pmg',
node: 'edge-01',
correlatedFindingIds: ['backup-link'],
title: 'Mail gateway is degraded',
},
{
id: 'backup-link',
resourceId: 'service:backup',
resourceName: 'dr-vault',
resourceType: 'pbs',
node: 'backup-01',
title: 'Backup connection is degraded',
},
]);
expect(groups).toHaveLength(1);
expect(groups[0]).toMatchObject({
affectedResourceCount: 2,
kind: 'correlated',
label: '2 related resources',
});
});
it('keeps findings without resource identity separate instead of inventing duplicates', () => {
@ -1508,7 +1549,8 @@ describe('aiFindingPresentation', () => {
]);
expect(groups).toHaveLength(2);
expect(groups.map((group) => group.resourceKey)).toEqual(['finding:a', 'finding:b']);
expect(groups.map((group) => group.kind)).toEqual(['finding', 'finding']);
expect(groups.map((group) => group.label)).toEqual(['', '']);
});
});

View file

@ -8,7 +8,6 @@ import { ApprovalBanner, RunHistoryPanel } from '@/components/patrol';
import {
getFindingTitlePresentation,
buildPatrolFindingDisplayGroups,
getPatrolFindingsBadgePresentation,
getPatrolWorkTypeComposition,
isPatrolRuntimeFinding,
} from '@/utils/aiFindingPresentation';
@ -25,7 +24,6 @@ import { StatusIndicatorBadge } from '@/components/shared/StatusIndicatorBadge';
import { getPatrolSetupAction, getPatrolSetupHint } from '@/utils/patrolRuntimeActions';
import {
getPatrolProInvestigationHandoff,
getPatrolQueueBadgeLabel,
getPatrolQueueWorkspaceDescription,
getPatrolWorkspaceWorkGroups,
isPatrolCoverageStale,
@ -46,24 +44,18 @@ import {
export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceState }) {
const state = props.state;
const findingsBadgePresentation = () =>
getPatrolFindingsBadgePresentation(state.findingsTabBadgeFindings());
const queueDisplayGroups = createMemo(() =>
buildPatrolFindingDisplayGroups(state.findingsTabBadgeFindings()),
);
const queueIssueCount = createMemo(
() => state.findingsTabBadgeCount() ?? state.findingsTabBadgeFindings().length,
);
const queueAffectedResourceCount = createMemo(() => queueDisplayGroups().length);
const queueAffectedResourceCount = createMemo(() =>
queueDisplayGroups().reduce((count, group) => count + group.affectedResourceCount, 0),
);
const workTypeComposition = createMemo(() =>
getPatrolWorkTypeComposition(state.findingsTabBadgeFindings()),
);
const queueBadgeLabel = createMemo(() =>
getPatrolQueueBadgeLabel({
affectedResourceCount: queueAffectedResourceCount(),
findingCount: queueIssueCount(),
}),
);
const isHistoryOpen = () => state.activeTab() === 'history';
const isSetupOnly = () =>
!isHistoryOpen() && !state.selectedRun() && state.shouldShowPatrolSetupOnly();
@ -165,18 +157,6 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
<div class="min-w-0">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h2 class="text-sm font-semibold text-base-content">{workspaceTitle()}</h2>
<Show
when={
!isHistoryOpen() &&
!isSetupOnly() &&
!state.selectedRun() &&
Boolean(queueBadgeLabel())
}
>
<MetadataBadge aria-hidden="true" tone={findingsBadgePresentation().tone} size="xs">
{queueBadgeLabel()}
</MetadataBadge>
</Show>
</div>
<p class="mt-1 text-xs text-muted">{workspaceDescription()}</p>
</div>

View file

@ -44,7 +44,7 @@ describe('PatrolIntelligenceWorkspace trust strip', () => {
expect(workspaceSource).toContain('PATROL_WORKSPACE_SETUP_TITLE');
expect(workspaceSource).toContain('PATROL_WORKSPACE_SETUP_DESCRIPTION');
expect(workspaceSource).toContain('buildPatrolFindingDisplayGroups');
expect(workspaceSource).toContain('getPatrolQueueBadgeLabel');
expect(workspaceSource).not.toContain('getPatrolQueueBadgeLabel');
expect(workspaceSource).toContain('queueIssueCount');
expect(workspaceSource).toContain('queueAffectedResourceCount');
expect(workspaceSource).toContain('affectedResourceCount: queueAffectedResourceCount()');
@ -59,8 +59,7 @@ describe('PatrolIntelligenceWorkspace trust strip', () => {
expect(workspaceSource).not.toContain('protectionPostureSummaries');
expect(workspaceSource).toContain('autonomyLevel: state.autonomyLevel()');
expect(workspaceSource).toContain('autonomyLocked: state.autoFixLocked()');
expect(workspaceSource).toContain('Boolean(queueBadgeLabel())');
expect(workspaceSource).toContain('{queueBadgeLabel()}');
expect(workspaceSource).not.toContain('queueBadgeLabel');
const removedAllModeCopy =
'Open work Patrol can ' +
'watch, investigate, ask you to approve, or record under your Patrol mode.';

View file

@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import {
getPatrolProInvestigationHandoff,
getPatrolQueueBadgeLabel,
getPatrolQueueWorkspaceDescription,
getMonitorContextPatrolProtectionPosture,
getPatrolReadyWorkDetail,
@ -24,8 +23,7 @@ describe('patrolControlPresentation', () => {
},
approval: {
label: 'Ask first',
detail:
'Patrol investigates and prepares fixes, but every change waits for your approval.',
detail: 'Patrol investigates and prepares fixes, but every change waits for your approval.',
compactLabel: 'Ask first',
},
assisted: {
@ -85,17 +83,6 @@ describe('patrolControlPresentation', () => {
).toBe('Current Patrol issues appear here.');
});
it('summarizes the open queue by affected resource instead of raw findings', () => {
expect(getPatrolQueueBadgeLabel({ findingCount: 2, affectedResourceCount: 1 })).toBe(
'1 resource',
);
expect(getPatrolQueueBadgeLabel({ findingCount: 3, affectedResourceCount: 2 })).toBe(
'2 resources',
);
expect(getPatrolQueueBadgeLabel({ findingCount: 2, affectedResourceCount: 0 })).toBeUndefined();
expect(getPatrolQueueBadgeLabel({ findingCount: 0, affectedResourceCount: 0 })).toBeUndefined();
});
it('uses the grouped queue count when open findings exist', () => {
expect(
getPatrolQueueWorkspaceDescription({

View file

@ -276,14 +276,6 @@ export function getPatrolReadyWorkDetail(input: PatrolControlCopyInput): string
}
}
export function getPatrolQueueBadgeLabel(input: PatrolQueueCountInput): string | undefined {
const affectedResourceCount = normalizeCount(input.affectedResourceCount);
if (affectedResourceCount <= 0) {
return undefined;
}
return formatCount(affectedResourceCount, 'resource');
}
export function getPatrolQueueWorkspaceDescription(
input: PatrolControlCopyInput & PatrolQueueCountInput,
): string {

View file

@ -148,6 +148,7 @@ export interface UnifiedFinding {
resourceId: string;
resourceName: string;
resourceType: string;
node?: string;
resourceCriticality?: ResourceCriticality;
alertIdentifier?: string;
alertType?: string;
@ -240,6 +241,7 @@ function normalizeUnifiedFindingRecord(item: UnifiedFindingRecord, now: number):
resourceId: item.resource_id,
resourceName: item.resource_name || item.resource_id,
resourceType: item.resource_type || 'unknown',
node: item.node,
resourceCriticality: validateResourceCriticality(item.resource_criticality),
alertIdentifier,
isThreshold: Boolean(item.is_threshold || item.source === 'threshold'),
@ -286,6 +288,7 @@ function normalizePatrolFindingRecord(item: PatrolFinding, now: number): Unified
resourceId: item.resource_id,
resourceName: item.resource_name || item.resource_id,
resourceType: item.resource_type || 'unknown',
node: item.node,
resourceCriticality: validateResourceCriticality(item.resource_criticality),
alertIdentifier: item.alertIdentifier,
isThreshold: false,

View file

@ -17,7 +17,6 @@ import {
getOperatorStateDismissCause,
getPatrolFindingsBadgePresentation,
getPatrolFindingResourceGroupKey,
getPatrolFindingRowScaffold,
getPatrolWorkTypeCompositionClause,
isPatrolRuntimeFinding,
normalizePatrolRuntimeFindingLabel,
@ -141,11 +140,15 @@ describe('getFindingSeverityBadgeClasses', () => {
});
it('returns the watch (neutral surface) classes', () => {
expect(getFindingSeverityBadgeClasses('watch')).toBe('border-border bg-surface-alt text-base-content');
expect(getFindingSeverityBadgeClasses('watch')).toBe(
'border-border bg-surface-alt text-base-content',
);
});
it('falls back to the default badge classes for an unknown severity', () => {
expect(getFindingSeverityBadgeClasses('severe')).toBe('border-border bg-surface-alt text-muted');
expect(getFindingSeverityBadgeClasses('severe')).toBe(
'border-border bg-surface-alt text-muted',
);
});
it('falls back to the default badge classes for the empty string', () => {
@ -211,8 +214,12 @@ describe('getFindingSeveritySortOrder', () => {
});
it('ranks critical before warning before watch before info', () => {
expect(getFindingSeveritySortOrder('critical')).toBeLessThan(getFindingSeveritySortOrder('warning'));
expect(getFindingSeveritySortOrder('warning')).toBeLessThan(getFindingSeveritySortOrder('watch'));
expect(getFindingSeveritySortOrder('critical')).toBeLessThan(
getFindingSeveritySortOrder('warning'),
);
expect(getFindingSeveritySortOrder('warning')).toBeLessThan(
getFindingSeveritySortOrder('watch'),
);
expect(getFindingSeveritySortOrder('watch')).toBeLessThan(getFindingSeveritySortOrder('info'));
});
});
@ -304,7 +311,9 @@ describe('getFindingSeverityPresentation', () => {
describe('isPatrolRuntimeFinding', () => {
it('is true when resourceId is "ai-service"', () => {
expect(isPatrolRuntimeFinding({ resourceId: 'ai-service', resourceName: '', title: '' })).toBe(true);
expect(isPatrolRuntimeFinding({ resourceId: 'ai-service', resourceName: '', title: '' })).toBe(
true,
);
});
it('is true when resourceName is "Pulse Patrol Service" (case-insensitive)', () => {
@ -320,9 +329,9 @@ describe('isPatrolRuntimeFinding', () => {
});
it('is false when none of the runtime identifiers match', () => {
expect(isPatrolRuntimeFinding({ resourceId: 'vm:1', resourceName: 'node-1', title: 'CPU high' })).toBe(
false,
);
expect(
isPatrolRuntimeFinding({ resourceId: 'vm:1', resourceName: 'node-1', title: 'CPU high' }),
).toBe(false);
});
it('is false when all fields are empty strings', () => {
@ -343,7 +352,11 @@ describe('isPatrolRuntimeFinding', () => {
it('does not match a title that contains but does not start with "Pulse Patrol:"', () => {
expect(
isPatrolRuntimeFinding({ resourceId: '', resourceName: '', title: 'Issue: Pulse Patrol: down' }),
isPatrolRuntimeFinding({
resourceId: '',
resourceName: '',
title: 'Issue: Pulse Patrol: down',
}),
).toBe(false);
});
});
@ -393,7 +406,9 @@ describe('normalizePatrolRuntimeFindingLabel', () => {
describe('getFindingTitlePresentation', () => {
it('returns the trimmed raw title for a non-runtime finding', () => {
expect(getFindingTitlePresentation(makeFinding({ title: ' CPU high ', resourceId: 'vm:1' }))).toEqual({
expect(
getFindingTitlePresentation(makeFinding({ title: ' CPU high ', resourceId: 'vm:1' })),
).toEqual({
label: 'CPU high',
});
});
@ -406,7 +421,9 @@ describe('getFindingTitlePresentation', () => {
it('returns the normalized label for a runtime finding with a standard title', () => {
expect(
getFindingTitlePresentation(makeFinding({ title: 'Pulse Patrol: CPU high', resourceId: 'ai-service' })),
getFindingTitlePresentation(
makeFinding({ title: 'Pulse Patrol: CPU high', resourceId: 'ai-service' }),
),
).toEqual({ label: 'CPU high' });
});
@ -430,26 +447,23 @@ describe('getFindingTitlePresentation', () => {
// ===================================================================
describe('getPatrolFindingResourceGroupKey', () => {
it('groups by subject label when resourceName is present', () => {
it('groups by canonical resource id before presentation fields', () => {
const key = getPatrolFindingResourceGroupKey(
makeFinding({ id: 'f1', resourceName: 'db-primary', resourceType: '', resourceId: 'vm:1' }),
);
expect(key).toBe('subject:db-primary');
expect(key).toBe('id:vm:1');
});
it('groups by subject label (with formatted type) when only resourceType is present', () => {
it('normalizes canonical resource ids for consistent grouping', () => {
const key = getPatrolFindingResourceGroupKey(
makeFinding({ id: 'f1', resourceName: '', resourceType: 'k8s_pod', resourceId: 'vm:1' }),
makeFinding({ id: 'f1', resourceName: '', resourceType: 'k8s_pod', resourceId: 'VM:1' }),
);
// getFindingSubjectPresentation formats as "resourceName (type)" but resourceName
// falls back to resourceId when empty. Here resourceName is '' so it uses resourceId.
// resourceType present → label = `${resourceName-or-id} (${formatIdentifierLabel(type)})`
expect(key).toBe('subject:vm:1 (k8s pod)');
expect(key).toBe('id:vm:1');
});
it('groups by subject label using resourceName when both name and type are present', () => {
it('falls back to a formatted subject label when no resource id is available', () => {
const key = getPatrolFindingResourceGroupKey(
makeFinding({ id: 'f1', resourceName: 'web-node', resourceType: 'vm', resourceId: 'vm:1' }),
makeFinding({ id: 'f1', resourceName: 'web-node', resourceType: 'vm', resourceId: '' }),
);
expect(key).toBe('subject:web-node (vm)');
});
@ -468,9 +482,9 @@ describe('getPatrolFindingResourceGroupKey', () => {
expect(key).toBe('finding:lonely');
});
it('lowercases the subject label for consistent grouping', () => {
it('lowercases a fallback subject label for consistent grouping', () => {
const key = getPatrolFindingResourceGroupKey(
makeFinding({ id: 'f1', resourceName: 'DB-Primary', resourceType: '', resourceId: 'vm:1' }),
makeFinding({ id: 'f1', resourceName: 'DB-Primary', resourceType: '', resourceId: '' }),
);
expect(key).toBe('subject:db-primary');
});
@ -586,7 +600,9 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns undefined for a dismissed finding (status not active/resolved/snoozed)', () => {
expect(
getFindingPatrolWorkflowPresentation(makeFinding({ source: 'ai-patrol', status: 'dismissed' })),
getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'dismissed' }),
),
).toBeUndefined();
});
@ -599,7 +615,12 @@ describe('getFindingPatrolWorkflowPresentation', () => {
});
it('returns the approval stage with "Approve or reject" when a live approval exists', () => {
const finding = makeFinding({ id: 'f1', source: 'ai-patrol', status: 'active', resourceId: 'vm:1' });
const finding = makeFinding({
id: 'f1',
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
});
const result = getFindingPatrolWorkflowPresentation(finding, [liveApproval('f1')], NOW);
expect(result?.stage).toBe('approval');
expect(result?.label).toBe('Approve or reject');
@ -608,7 +629,12 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the investigating stage when investigationStatus is running', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationStatus: 'running' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationStatus: 'running',
}),
);
expect(result?.stage).toBe('investigating');
expect(result?.label).toBe('Patrol investigating');
@ -617,21 +643,36 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the investigating stage when investigationStatus is pending', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationStatus: 'pending' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationStatus: 'pending',
}),
);
expect(result?.stage).toBe('investigating');
});
it('returns the investigating stage when loopState is investigating', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', loopState: 'investigating' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
loopState: 'investigating',
}),
);
expect(result?.stage).toBe('investigating');
});
it('returns the recorded stage for an active finding with fix_verified outcome', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_verified' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'fix_verified',
}),
);
expect(result?.stage).toBe('recorded');
expect(result?.label).toBe('Outcome recorded');
@ -639,14 +680,25 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the recorded stage for an active finding with resolved outcome', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'resolved' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'resolved',
}),
);
expect(result?.stage).toBe('recorded');
});
it('returns the approval stage with "Recover action" for fix_queued without a live approval', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ id: 'f1', source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_queued' }),
makeFinding({
id: 'f1',
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'fix_queued',
}),
);
expect(result?.stage).toBe('approval');
expect(result?.label).toBe('Recover action');
@ -654,7 +706,12 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the verification stage for fix_executed', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_executed' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'fix_executed',
}),
);
expect(result?.stage).toBe('verification');
expect(result?.label).toBe('Verify outcome');
@ -662,7 +719,12 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the attention stage for fix_failed', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_failed' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'fix_failed',
}),
);
expect(result?.stage).toBe('attention');
expect(result?.label).toBe('Fix failed');
@ -671,14 +733,24 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the attention stage for fix_verification_failed', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_verification_failed' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'fix_verification_failed',
}),
);
expect(result?.stage).toBe('attention');
});
it('returns the attention stage for fix_rejected with "Decide follow-up"', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_rejected' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'fix_rejected',
}),
);
expect(result?.stage).toBe('attention');
expect(result?.label).toBe('Decide follow-up');
@ -687,7 +759,12 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the verification stage for fix_verification_unknown', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_verification_unknown' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'fix_verification_unknown',
}),
);
expect(result?.stage).toBe('verification');
expect(result?.label).toBe('Check outcome');
@ -695,7 +772,12 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the attention stage with "Needs input" for needs_attention', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'needs_attention' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'needs_attention',
}),
);
expect(result?.stage).toBe('attention');
expect(result?.label).toBe('Needs input');
@ -703,14 +785,24 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns the attention stage for cannot_fix', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'cannot_fix' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'cannot_fix',
}),
);
expect(result?.stage).toBe('attention');
});
it('returns the attention stage for timed_out', () => {
const result = getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'timed_out' }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: 'timed_out',
}),
);
expect(result?.stage).toBe('attention');
});
@ -718,178 +810,17 @@ describe('getFindingPatrolWorkflowPresentation', () => {
it('returns undefined for an active finding with an unrecognised outcome and no investigation status', () => {
expect(
getFindingPatrolWorkflowPresentation(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: undefined }),
makeFinding({
source: 'ai-patrol',
status: 'active',
resourceId: 'vm:1',
investigationOutcome: undefined,
}),
),
).toBeUndefined();
});
});
// ===================================================================
// getPatrolFindingVerificationSummary (private, reached via
// getPatrolFindingRowScaffold — the scaffold guard requires source
// 'ai-patrol', status 'active', and NOT a patrol-runtime finding)
// ===================================================================
describe('getPatrolFindingVerificationSummary (via getPatrolFindingRowScaffold)', () => {
const verificationValue = (overrides: Partial<UnifiedFinding>): string | undefined => {
const scaffold = getPatrolFindingRowScaffold(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', ...overrides }),
);
return scaffold?.items.find((item) => item.id === 'verification')?.value;
};
it('reports waiting-for-approval for fix_queued', () => {
expect(verificationValue({ investigationOutcome: 'fix_queued' })).toBe(
'Waiting for the governed action record before any change runs.',
);
});
it('reports verification-in-progress for fix_executed', () => {
expect(verificationValue({ investigationOutcome: 'fix_executed' })).toBe(
'Fix ran; verification is in progress.',
);
});
it('reports verified outcome for fix_verified', () => {
expect(verificationValue({ investigationOutcome: 'fix_verified' })).toBe(
'Verified outcome recorded.',
);
});
it('reports verified outcome for resolved', () => {
expect(verificationValue({ investigationOutcome: 'resolved' })).toBe(
'Verified outcome recorded.',
);
});
it('reports verification failed for fix_verification_failed', () => {
expect(verificationValue({ investigationOutcome: 'fix_verification_failed' })).toBe(
'Verification failed and needs review.',
);
});
it('reports inconclusive for fix_verification_unknown', () => {
expect(verificationValue({ investigationOutcome: 'fix_verification_unknown' })).toBe(
'Verification was inconclusive.',
);
});
it('reports no verified fix for fix_failed', () => {
expect(verificationValue({ investigationOutcome: 'fix_failed' })).toBe(
'No verified fix; action needs review.',
);
});
it('reports no verified fix for cannot_fix', () => {
expect(verificationValue({ investigationOutcome: 'cannot_fix' })).toBe(
'No verified fix; action needs review.',
);
});
it('reports no verified fix for timed_out', () => {
expect(verificationValue({ investigationOutcome: 'timed_out' })).toBe(
'No verified fix; action needs review.',
);
});
it('reports no verified fix for needs_attention', () => {
expect(verificationValue({ investigationOutcome: 'needs_attention' })).toBe(
'No verified fix; action needs review.',
);
});
it('reports rejected fix for fix_rejected', () => {
expect(verificationValue({ investigationOutcome: 'fix_rejected' })).toBe(
'No change ran because the fix was rejected.',
);
});
it('reports investigating when investigationStatus is running and no outcome', () => {
expect(verificationValue({ investigationStatus: 'running' })).toBe(
'Patrol is investigating; no fix has run yet.',
);
});
it('reports investigating when investigationStatus is pending and no outcome', () => {
expect(verificationValue({ investigationStatus: 'pending' })).toBe(
'Patrol is investigating; no fix has run yet.',
);
});
it('reports no-fix-yet when no outcome and investigationStatus is completed', () => {
expect(verificationValue({ investigationStatus: 'completed' })).toBe('No fix has run yet.');
});
it('reports no-fix-yet when no outcome and no investigation status', () => {
expect(verificationValue({})).toBe('No fix has run yet.');
});
});
// ===================================================================
// getPatrolFindingWorkflowSummary (private, reached via
// getPatrolFindingRowScaffold). The 'paused' stage is unreachable here
// because the scaffold guard requires status 'active' and 'paused'
// only arises from status 'snoozed'.
// ===================================================================
describe('getPatrolFindingWorkflowSummary (via getPatrolFindingRowScaffold)', () => {
const workflowValue = (overrides: Partial<UnifiedFinding>, approvals: ApprovalRequest[] = []): string | undefined => {
const scaffold = getPatrolFindingRowScaffold(
makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', ...overrides }),
approvals,
NOW,
);
return scaffold?.items.find((item) => item.id === 'workflow')?.value;
};
it('returns the default review message when no workflow stage is determined', () => {
// Active, non-runtime, no investigation status/outcome → workflow undefined
expect(workflowValue({})).toBe(
'Review evidence, decide the next action, and verify any outcome before closing.',
);
});
it('returns the approve-or-reject message when a live approval is pending', () => {
expect(
workflowValue({ id: 'f1' }, [liveApproval('f1')]),
).toBe(
'Review evidence first; no change runs until the typed action is approved, then Patrol verifies the outcome.',
);
});
it('returns the recover-queued-fix message for fix_queued without a live approval (Recover action stage)', () => {
expect(
workflowValue({ investigationOutcome: 'fix_queued' }),
).toBe(
'Recover the queued action before any change can run, then verify the outcome after a decision.',
);
});
it('returns the verification message for fix_executed', () => {
expect(
workflowValue({ investigationOutcome: 'fix_executed' }),
).toBe('The governed action ran; review follow-up evidence before closing the issue.');
});
it('returns the attention message for fix_failed', () => {
expect(
workflowValue({ investigationOutcome: 'fix_failed' }),
).toBe('Review the blocked or failed step before approving another change or resolving manually.');
});
it('returns the investigating message for running investigationStatus', () => {
expect(
workflowValue({ investigationStatus: 'running' }),
).toBe('Patrol is explaining the issue and preparing the next decision point.');
});
it('returns the recorded message for fix_verified outcome', () => {
expect(
workflowValue({ investigationOutcome: 'fix_verified' }),
).toBe('Patrol recorded the outcome; use history if you need the completed trail.');
});
});
// ===================================================================
// getFindingResolutionReason
// ===================================================================
@ -905,9 +836,9 @@ describe('getFindingResolutionReason', () => {
describe('manual resolution priority', () => {
it('returns "Resolved by you" when autoResolved is explicitly false and outcome is not a patrol fix', () => {
expect(
getFindingResolutionReason({ ...base, autoResolved: false }, 'today'),
).toBe('Resolved by you today');
expect(getFindingResolutionReason({ ...base, autoResolved: false }, 'today')).toBe(
'Resolved by you today',
);
});
it('does NOT return "Resolved by you" when autoResolved is false but outcome is fix_verified', () => {
@ -956,39 +887,39 @@ describe('getFindingResolutionReason', () => {
});
it('returns cpu-normal for cpu alertType', () => {
expect(
getFindingResolutionReason({ ...thresholdBase, alertType: 'cpu' }, 'now'),
).toBe('CPU returned to normal now');
expect(getFindingResolutionReason({ ...thresholdBase, alertType: 'cpu' }, 'now')).toBe(
'CPU returned to normal now',
);
});
it('returns memory-normal for memory alertType', () => {
expect(
getFindingResolutionReason({ ...thresholdBase, alertType: 'memory' }, 'now'),
).toBe('Memory returned to normal now');
expect(getFindingResolutionReason({ ...thresholdBase, alertType: 'memory' }, 'now')).toBe(
'Memory returned to normal now',
);
});
it('returns disk-normal for disk alertType', () => {
expect(
getFindingResolutionReason({ ...thresholdBase, alertType: 'disk' }, 'now'),
).toBe('Disk usage returned to normal now');
expect(getFindingResolutionReason({ ...thresholdBase, alertType: 'disk' }, 'now')).toBe(
'Disk usage returned to normal now',
);
});
it('returns network-recovered for network alertType', () => {
expect(
getFindingResolutionReason({ ...thresholdBase, alertType: 'network' }, 'now'),
).toBe('Network recovered now');
expect(getFindingResolutionReason({ ...thresholdBase, alertType: 'network' }, 'now')).toBe(
'Network recovered now',
);
});
it('returns condition-cleared for an unrecognised alertType', () => {
expect(
getFindingResolutionReason({ ...thresholdBase, alertType: 'custom' }, 'now'),
).toBe('Condition cleared now');
expect(getFindingResolutionReason({ ...thresholdBase, alertType: 'custom' }, 'now')).toBe(
'Condition cleared now',
);
});
it('returns condition-cleared when alertType is undefined', () => {
expect(
getFindingResolutionReason({ ...thresholdBase, alertType: undefined }, 'now'),
).toBe('Condition cleared now');
expect(getFindingResolutionReason({ ...thresholdBase, alertType: undefined }, 'now')).toBe(
'Condition cleared now',
);
});
it('treats source=threshold (without isThreshold) as threshold', () => {
@ -1072,7 +1003,10 @@ describe('getFindingResolutionReason', () => {
it('returns "Resolved after manual review" for needs_attention', () => {
expect(
getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'needs_attention' }, 'now'),
getFindingResolutionReason(
{ ...patrolBase, investigationOutcome: 'needs_attention' },
'now',
),
).toBe('Resolved after manual review now');
});
@ -1094,11 +1028,15 @@ describe('getFindingResolutionReason', () => {
describe('fallback for non-threshold non-patrol sources', () => {
it('returns the generic "Resolved" message', () => {
expect(getFindingResolutionReason({ ...base, source: 'anomaly' }, 'now')).toBe('Resolved now');
expect(getFindingResolutionReason({ ...base, source: 'anomaly' }, 'now')).toBe(
'Resolved now',
);
});
it('returns the generic "Resolved" message for ai-chat source', () => {
expect(getFindingResolutionReason({ ...base, source: 'ai-chat' }, 'now')).toBe('Resolved now');
expect(getFindingResolutionReason({ ...base, source: 'ai-chat' }, 'now')).toBe(
'Resolved now',
);
});
});
});
@ -1194,9 +1132,7 @@ describe('formatFindingForClipboard', () => {
});
it('does not include regression when regressionCount is zero', () => {
const result = formatFindingForClipboard(
makeFinding({ title: 'Issue', regressionCount: 0 }),
);
const result = formatFindingForClipboard(makeFinding({ title: 'Issue', regressionCount: 0 }));
expect(result).not.toContain('Regressed:');
});
});
@ -1229,7 +1165,11 @@ describe('getOperatorStateDismissCause', () => {
expect(
getOperatorStateDismissCause({
lifecycle: [
{ at: 't1', type: 'dismissed', metadata: { operator_state_cause: 'intentionally_offline' } },
{
at: 't1',
type: 'dismissed',
metadata: { operator_state_cause: 'intentionally_offline' },
},
{ at: 't2', type: 'resolved' },
],
}),
@ -1294,39 +1234,37 @@ describe('getPatrolWorkTypeCompositionClause', () => {
});
it('uses singular "needs approval" for exactly one approval', () => {
expect(
getPatrolWorkTypeCompositionClause({ ...emptyComp, approval: 1 }),
).toBe(' — 1 needs approval');
expect(getPatrolWorkTypeCompositionClause({ ...emptyComp, approval: 1 })).toBe(
' — 1 needs approval',
);
});
it('uses plural "need approval" for multiple approvals', () => {
expect(
getPatrolWorkTypeCompositionClause({ ...emptyComp, approval: 3 }),
).toBe(' — 3 need approval');
expect(getPatrolWorkTypeCompositionClause({ ...emptyComp, approval: 3 })).toBe(
' — 3 need approval',
);
});
it('uses singular "failed fix" for exactly one failed', () => {
expect(
getPatrolWorkTypeCompositionClause({ ...emptyComp, failed: 1 }),
).toBe(' — 1 failed fix');
expect(getPatrolWorkTypeCompositionClause({ ...emptyComp, failed: 1 })).toBe(' — 1 failed fix');
});
it('uses plural "failed fixes" for multiple failed', () => {
expect(
getPatrolWorkTypeCompositionClause({ ...emptyComp, failed: 2 }),
).toBe(' — 2 failed fixes');
expect(getPatrolWorkTypeCompositionClause({ ...emptyComp, failed: 2 })).toBe(
' — 2 failed fixes',
);
});
it('includes in-progress count', () => {
expect(
getPatrolWorkTypeCompositionClause({ ...emptyComp, inProgress: 4 }),
).toBe(' — 4 in progress');
expect(getPatrolWorkTypeCompositionClause({ ...emptyComp, inProgress: 4 })).toBe(
' — 4 in progress',
);
});
it('includes recurring count', () => {
expect(
getPatrolWorkTypeCompositionClause({ ...emptyComp, recurring: 5 }),
).toBe(' — 5 recurring');
expect(getPatrolWorkTypeCompositionClause({ ...emptyComp, recurring: 5 })).toBe(
' — 5 recurring',
);
});
it('joins all parts with commas in priority order', () => {

View file

@ -5,12 +5,10 @@ import {
getPatrolWorkTypeComposition,
getPatrolWorkTypeCompositionClause,
getPatrolFindingActionableState,
getPatrolFindingRowScaffold,
getPatrolFindingQueueSummary,
} from '@/utils/aiFindingPresentation';
type ClassifyInput = Parameters<typeof classifyPatrolFindingWorkType>[0];
type RowScaffoldInput = Parameters<typeof getPatrolFindingRowScaffold>[0];
type RowScaffoldApprovalInput = NonNullable<Parameters<typeof getPatrolFindingRowScaffold>[1]>;
function makeFinding(overrides: Partial<ClassifyInput> = {}): ClassifyInput {
return {
@ -23,52 +21,30 @@ function makeFinding(overrides: Partial<ClassifyInput> = {}): ClassifyInput {
};
}
function makeRowScaffoldFinding(overrides: Partial<RowScaffoldInput> = {}): RowScaffoldInput {
return {
id: 'finding-active-work',
source: 'ai-patrol',
status: 'active',
resourceId: 'node:pve-main',
resourceName: 'pve-main',
resourceType: 'agent',
title: 'High CPU pressure on pve-main',
description: 'Patrol detected sustained CPU pressure on the monitored Proxmox host.',
impact: 'Sustained CPU pressure can slow hosted workloads on this Proxmox host.',
evidence: 'CPU stayed above the configured warning threshold during the scheduled Patrol check.',
recommendation: 'Review the host load and move or stop noisy workloads before approving a fix.',
investigationStatus: undefined,
investigationOutcome: undefined,
loopState: undefined,
...overrides,
};
}
describe('classifyPatrolFindingWorkType', () => {
it('classifies a plain active finding as new', () => {
expect(classifyPatrolFindingWorkType(makeFinding())).toBe('new');
});
it('classifies fix_queued as approval', () => {
expect(
classifyPatrolFindingWorkType(makeFinding({ investigationOutcome: 'fix_queued' })),
).toBe('approval');
});
it.each([
'fix_failed',
'fix_verification_failed',
'cannot_fix',
'timed_out',
] as const)('classifies %s as failed', (outcome) => {
expect(classifyPatrolFindingWorkType(makeFinding({ investigationOutcome: outcome }))).toBe(
'failed',
expect(classifyPatrolFindingWorkType(makeFinding({ investigationOutcome: 'fix_queued' }))).toBe(
'approval',
);
});
it.each(['fix_failed', 'fix_verification_failed', 'cannot_fix', 'timed_out'] as const)(
'classifies %s as failed',
(outcome) => {
expect(classifyPatrolFindingWorkType(makeFinding({ investigationOutcome: outcome }))).toBe(
'failed',
);
},
);
it('classifies investigation running as in_progress', () => {
expect(
classifyPatrolFindingWorkType(makeFinding({ investigationStatus: 'running' })),
).toBe('in_progress');
expect(classifyPatrolFindingWorkType(makeFinding({ investigationStatus: 'running' }))).toBe(
'in_progress',
);
});
it('classifies fix_executed as in_progress (verification pending)', () => {
@ -78,9 +54,7 @@ describe('classifyPatrolFindingWorkType', () => {
});
it('classifies regression as recurring', () => {
expect(
classifyPatrolFindingWorkType(makeFinding({ regressionCount: 2 })),
).toBe('recurring');
expect(classifyPatrolFindingWorkType(makeFinding({ regressionCount: 2 }))).toBe('recurring');
});
it('classifies multiple raises as recurring', () => {
@ -116,121 +90,27 @@ describe('classifyPatrolFindingWorkType', () => {
});
});
describe('getPatrolFindingRowScaffold', () => {
it('summarizes a plain active Patrol finding as row-owned issue text', () => {
expect(getPatrolFindingRowScaffold(makeRowScaffoldFinding())?.items).toEqual([
{ id: 'problem', label: 'Problem', value: 'High CPU pressure on pve-main' },
{ id: 'affected', label: 'Affected', value: 'pve-main (agent)' },
{
id: 'why',
label: 'Why it matters',
value: 'Sustained CPU pressure can slow hosted workloads on this Proxmox host.',
},
{
id: 'checked',
label: 'What Pulse checked',
value: 'CPU stayed above the configured warning threshold during the scheduled Patrol check.',
},
{
id: 'workflow',
label: 'Safe workflow',
value: 'Review evidence, decide the next action, and verify any outcome before closing.',
},
{
id: 'next-step',
label: 'Recommended next step',
value: 'Review the host load and move or stop noisy workloads before approving a fix.',
},
{ id: 'verification', label: 'Verification', value: 'No fix has run yet.' },
]);
describe('getPatrolFindingQueueSummary', () => {
it('prioritises the operator-facing consequence', () => {
expect(
getPatrolFindingQueueSummary({
impact: 'Hosted workloads may slow down.',
description: 'CPU stayed above the warning threshold.',
recommendation: 'Review host load.',
}),
).toBe('Hosted workloads may slow down.');
});
it('uses live approval workflow and verification state for queued fixes', () => {
const approvals: RowScaffoldApprovalInput = [
{
status: 'pending',
toolId: 'investigation_fix',
targetId: 'finding-active-work',
expiresAt: '2099-06-30T08:11:00Z',
},
];
expect(
getPatrolFindingRowScaffold(
makeRowScaffoldFinding({
investigationStatus: 'needs_attention',
investigationOutcome: 'fix_queued',
}),
approvals,
Date.parse('2026-06-30T08:07:00Z'),
)?.items,
).toContainEqual({
id: 'next-step',
label: 'Recommended next step',
value: 'A governed fix is waiting for an approve or reject decision.',
});
expect(
getPatrolFindingRowScaffold(
makeRowScaffoldFinding({
investigationStatus: 'needs_attention',
investigationOutcome: 'fix_queued',
}),
approvals,
Date.parse('2026-06-30T08:07:00Z'),
)?.items,
).toContainEqual({
id: 'workflow',
label: 'Safe workflow',
value:
'Review evidence first; no change runs until the typed action is approved, then Patrol verifies the outcome.',
});
expect(
getPatrolFindingRowScaffold(
makeRowScaffoldFinding({
investigationStatus: 'needs_attention',
investigationOutcome: 'fix_queued',
}),
approvals,
Date.parse('2026-06-30T08:07:00Z'),
)?.items,
).toContainEqual({
id: 'verification',
label: 'Verification',
value: 'Waiting for the governed action record before any change runs.',
});
});
it('keeps executed fixes on the verification step before closure', () => {
expect(
getPatrolFindingRowScaffold(
makeRowScaffoldFinding({
investigationStatus: 'completed',
investigationOutcome: 'fix_executed',
}),
)?.items,
).toContainEqual({
id: 'workflow',
label: 'Safe workflow',
value: 'The governed action ran; review follow-up evidence before closing the issue.',
});
});
it('does not scaffold non-Patrol, runtime setup, or resolved findings', () => {
expect(
getPatrolFindingRowScaffold(makeRowScaffoldFinding({ source: 'threshold' })),
).toBeUndefined();
expect(
getPatrolFindingRowScaffold(
makeRowScaffoldFinding({
resourceId: 'ai-service',
resourceName: 'Pulse Patrol service',
title: 'Pulse Patrol: insufficient API credits',
}),
),
).toBeUndefined();
expect(
getPatrolFindingRowScaffold(makeRowScaffoldFinding({ status: 'resolved' })),
).toBeUndefined();
it('falls back through description, recommendation, and review copy', () => {
expect(getPatrolFindingQueueSummary({ description: 'Disk usage is rising.' })).toBe(
'Disk usage is rising.',
);
expect(getPatrolFindingQueueSummary({ recommendation: 'Review the affected disk.' })).toBe(
'Review the affected disk.',
);
expect(getPatrolFindingQueueSummary({})).toBe(
'Open this issue to review what Patrol found and decide the next step.',
);
});
});
@ -334,9 +214,9 @@ describe('getPatrolFindingActionableState', () => {
it.each(['fix_failed', 'fix_verification_failed', 'cannot_fix', 'timed_out'] as const)(
'returns fix failed for %s',
(outcome) => {
expect(getPatrolFindingActionableState(makeFinding({ investigationOutcome: outcome }))).toEqual(
{ label: 'Fix failed', tone: 'danger' },
);
expect(
getPatrolFindingActionableState(makeFinding({ investigationOutcome: outcome })),
).toEqual({ label: 'Fix failed', tone: 'danger' });
},
);

View file

@ -4,7 +4,7 @@ import type { InvestigationOutcome, InvestigationStatus } from '@/api/patrol';
import type { MetadataBadgeTone } from '@/components/shared/MetadataBadge';
import { isLivePendingApproval } from '@/utils/approvalState';
import { getPatrolProviderSettingsAction } from '@/utils/patrolRuntimeActions';
import { formatIdentifierLabel } from '@/utils/textPresentation';
import { formatIdentifierLabel, titleCaseDelimitedLabel } from '@/utils/textPresentation';
import { formatBytes } from '@/utils/format';
const DEFAULT_BADGE_CLASSES = 'border-border bg-surface-alt text-muted';
@ -306,8 +306,9 @@ export interface PatrolFindingsBadgePresentation {
export interface PatrolFindingDisplayGroup<TFinding> {
id: string;
resourceKey: string;
resourceLabel: string;
affectedResourceCount: number;
kind: 'correlated' | 'finding' | 'node' | 'resource';
label: string;
primaryFinding: TFinding;
relatedFindings: TFinding[];
findings: TFinding[];
@ -524,51 +525,114 @@ export const getFindingSubjectPresentation = (
export const getPatrolFindingResourceGroupKey = (
finding: Pick<UnifiedFinding, 'id' | 'resourceId' | 'resourceName' | 'resourceType' | 'title'>,
): string => {
const resourceId = String(finding.resourceId || '').trim();
if (resourceId) {
return `id:${resourceId.toLowerCase()}`;
}
const resourceName = String(finding.resourceName || '').trim();
const resourceType = String(finding.resourceType || '').trim();
if (resourceName || resourceType) {
return `subject:${getFindingSubjectPresentation(finding).label.toLowerCase()}`;
}
const resourceId = String(finding.resourceId || '').trim();
if (resourceId) {
return `id:${resourceId}`;
}
return `finding:${finding.id}`;
};
export function buildPatrolFindingDisplayGroups<
TFinding extends Pick<
UnifiedFinding,
'id' | 'resourceId' | 'resourceName' | 'resourceType' | 'title'
| 'correlatedFindingIds'
| 'id'
| 'node'
| 'resourceId'
| 'resourceName'
| 'resourceType'
| 'title'
>,
>(findings: readonly TFinding[]): PatrolFindingDisplayGroup<TFinding>[] {
const groups: PatrolFindingDisplayGroup<TFinding>[] = [];
const groupsByResourceKey = new Map<string, PatrolFindingDisplayGroup<TFinding>>();
const findingById = new Map(findings.map((finding) => [finding.id, finding]));
const parentById = new Map(findings.map((finding) => [finding.id, finding.id]));
const findRoot = (findingId: string): string => {
const parent = parentById.get(findingId) ?? findingId;
if (parent === findingId) return parent;
const root = findRoot(parent);
parentById.set(findingId, root);
return root;
};
const union = (leftId: string, rightId: string) => {
const leftRoot = findRoot(leftId);
const rightRoot = findRoot(rightId);
if (leftRoot !== rightRoot) parentById.set(rightRoot, leftRoot);
};
const firstIdByResource = new Map<string, string>();
const firstIdByNode = new Map<string, string>();
for (const finding of findings) {
const resourceKey = getPatrolFindingResourceGroupKey(finding);
const existing = groupsByResourceKey.get(resourceKey);
if (existing) {
existing.findings.push(finding);
existing.relatedFindings.push(finding);
continue;
const firstResourceId = firstIdByResource.get(resourceKey);
if (firstResourceId) union(firstResourceId, finding.id);
else firstIdByResource.set(resourceKey, finding.id);
const node = String(finding.node || '').trim();
if (node) {
const nodeKey = node.toLowerCase();
const firstNodeId = firstIdByNode.get(nodeKey);
if (firstNodeId) union(firstNodeId, finding.id);
else firstIdByNode.set(nodeKey, finding.id);
}
const group: PatrolFindingDisplayGroup<TFinding> = {
id: resourceKey,
resourceKey,
resourceLabel: getFindingSubjectPresentation(finding).label,
primaryFinding: finding,
relatedFindings: [],
findings: [finding],
};
groupsByResourceKey.set(resourceKey, group);
groups.push(group);
for (const correlatedId of finding.correlatedFindingIds ?? []) {
if (findingById.has(correlatedId)) union(finding.id, correlatedId);
}
}
return groups;
const componentByRoot = new Map<string, TFinding[]>();
for (const finding of findings) {
const root = findRoot(finding.id);
const component = componentByRoot.get(root);
if (component) component.push(finding);
else componentByRoot.set(root, [finding]);
}
return [...componentByRoot.values()].map((component) => {
const primaryFinding = component[0];
const resourceKeys = new Set(component.map(getPatrolFindingResourceGroupKey));
const nodes = new Set(
component.map((finding) => String(finding.node || '').trim()).filter(Boolean),
);
const componentIds = new Set(component.map((finding) => finding.id));
const hasExplicitCorrelation = component.some((finding) =>
(finding.correlatedFindingIds ?? []).some((id) => componentIds.has(id)),
);
const kind: PatrolFindingDisplayGroup<TFinding>['kind'] =
component.length === 1
? 'finding'
: hasExplicitCorrelation
? 'correlated'
: resourceKeys.size === 1
? 'resource'
: nodes.size === 1
? 'node'
: 'correlated';
const commonNode = nodes.size === 1 ? [...nodes][0] : '';
const label =
kind === 'finding' || kind === 'resource'
? getFindingSubjectPresentation(primaryFinding).label
: (commonNode ? titleCaseDelimitedLabel(commonNode, { preserveShortAllCaps: true }) : '') ||
`${resourceKeys.size} related ${resourceKeys.size === 1 ? 'resource' : 'resources'}`;
return {
id: `group:${component.map((finding) => finding.id).join(':')}`,
affectedResourceCount: resourceKeys.size,
kind,
label,
primaryFinding,
relatedFindings: component.slice(1),
findings: component,
};
});
}
export type PatrolFindingWorkType = 'approval' | 'failed' | 'in_progress' | 'recurring' | 'new';
@ -673,21 +737,9 @@ export interface PatrolActionableStatePresentation {
tone: MetadataBadgeTone;
}
export type PatrolFindingRowScaffoldItemId =
'affected' | 'checked' | 'next-step' | 'problem' | 'verification' | 'workflow' | 'why';
export interface PatrolFindingRowScaffoldItem {
id: PatrolFindingRowScaffoldItemId;
label: string;
value: string;
}
export interface PatrolFindingRowScaffold {
items: PatrolFindingRowScaffoldItem[];
}
const formatBoundedFindingTime = (value: string): string | undefined => {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return undefined;
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value))
return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? undefined : parsed.toLocaleString();
};
@ -703,8 +755,10 @@ export function getFindingEvidencePresentation(
const count = match ? Number(match[1]) : Number.NaN;
const checkedAt = match ? formatBoundedFindingTime(match[2]) : undefined;
const receivedAt = match ? formatBoundedFindingTime(match[3]) : undefined;
if (!match || !Number.isSafeInteger(count) || count < 0 || !checkedAt || !receivedAt) return 'Pulse could not safely present the bounded host-update observation. Refresh before acting.';
const rebootBoundary = match[4] === 'true' ? 'Yes. No reboot is authorized by this finding or action.' : 'No.';
if (!match || !Number.isSafeInteger(count) || count < 0 || !checkedAt || !receivedAt)
return 'Pulse could not safely present the bounded host-update observation. Refresh before acting.';
const rebootBoundary =
match[4] === 'true' ? 'Yes. No reboot is authorized by this finding or action.' : 'No.';
return `${count} operating system update${count === 1 ? '' : 's'} ${count === 1 ? 'was' : 'were'} pending when the agent checked at ${checkedAt}. Pulse received that observation at ${receivedAt}. The host already reported reboot required: ${rebootBoundary}`;
}
if (finding.key === 'apt-package-cache-pressure') {
@ -715,7 +769,17 @@ export function getFindingEvidencePresentation(
const usage = match ? Number(match[2]) : Number.NaN;
const checkedAt = match ? formatBoundedFindingTime(match[3]) : undefined;
const receivedAt = match ? formatBoundedFindingTime(match[4]) : undefined;
if (!match || !Number.isSafeInteger(bytes) || bytes < 0 || !Number.isFinite(usage) || usage < 0 || usage > 100 || !checkedAt || !receivedAt) return 'Pulse could not safely present the bounded package-cleanup observation. Refresh before acting.';
if (
!match ||
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
!Number.isFinite(usage) ||
usage < 0 ||
usage > 100 ||
!checkedAt ||
!receivedAt
)
return 'Pulse could not safely present the bounded package-cleanup observation. Refresh before acting.';
return `${formatBytes(bytes)} of downloaded package data was reclaimable while the filesystem was ${usage}% full. The agent checked at ${checkedAt}, and Pulse received that observation at ${receivedAt}.`;
}
return evidence;
@ -745,171 +809,14 @@ export function getPatrolFindingActionableState(
return undefined;
}
function getPatrolFindingVerificationSummary(
finding: Pick<
UnifiedFinding,
'investigationStatus' | 'investigationOutcome' | 'investigationRecord'
>,
export function getPatrolFindingQueueSummary(
finding: Partial<Pick<UnifiedFinding, 'description' | 'impact' | 'recommendation'>>,
): string {
const actionState = finding.investigationRecord?.action?.state;
if (finding.investigationOutcome === 'fix_queued') {
if (actionState === 'planned') return 'The governed action is ready to run.';
if (actionState === 'approved') return 'The governed action is approved and ready to run.';
if (actionState === 'executing') return 'The governed action is running; verification follows.';
if (actionState === 'pending_approval')
return 'Waiting for an approval decision before the action runs.';
for (const candidate of [finding.impact, finding.description, finding.recommendation]) {
const normalized = String(candidate || '').trim();
if (normalized) return normalized;
}
switch (finding.investigationOutcome) {
case 'fix_queued':
return 'Waiting for the governed action record before any change runs.';
case 'fix_executed':
return 'Fix ran; verification is in progress.';
case 'fix_verified':
case 'resolved':
return 'Verified outcome recorded.';
case 'fix_verification_failed':
return 'Verification failed and needs review.';
case 'fix_verification_unknown':
return 'Verification was inconclusive.';
case 'fix_failed':
case 'cannot_fix':
case 'timed_out':
case 'needs_attention':
return 'No verified fix; action needs review.';
case 'fix_rejected':
return 'No change ran because the fix was rejected.';
default:
break;
}
if (finding.investigationStatus === 'running' || finding.investigationStatus === 'pending') {
return 'Patrol is investigating; no fix has run yet.';
}
return 'No fix has run yet.';
}
function getPatrolFindingWorkflowSummary(
workflow: FindingPatrolWorkflowPresentation | undefined,
): string {
if (!workflow) {
return 'Review evidence, decide the next action, and verify any outcome before closing.';
}
switch (workflow.stage) {
case 'approval':
if (workflow.label === 'Approve or reject') {
return 'Review evidence first; no change runs until the typed action is approved, then Patrol verifies the outcome.';
}
if (workflow.label === 'Run action' || workflow.label === 'Run approved action') {
return 'Review the typed plan, run it through the governed action lifecycle, then verify the outcome.';
}
return 'Recover the queued action before any change can run, then verify the outcome after a decision.';
case 'verification':
return 'The governed action ran; review follow-up evidence before closing the issue.';
case 'attention':
return 'Review the blocked or failed step before approving another change or resolving manually.';
case 'investigating':
return 'Patrol is explaining the issue and preparing the next decision point.';
case 'recorded':
return 'Patrol recorded the outcome; use history if you need the completed trail.';
case 'paused':
return 'Patrol will return this issue to the workflow when the reminder expires.';
}
}
const getNonEmptyPresentationText = (value: string | undefined, fallback: string): string => {
const normalized = String(value || '').trim();
return normalized || fallback;
};
export function getPatrolFindingRowScaffold(
finding: Pick<
UnifiedFinding,
| 'description'
| 'evidence'
| 'id'
| 'impact'
| 'investigationOutcome'
| 'investigationStatus'
| 'loopState'
| 'recommendation'
| 'resourceId'
| 'resourceName'
| 'resourceType'
| 'source'
| 'status'
| 'title'
>,
approvals: Pick<ApprovalRequest, 'status' | 'toolId' | 'targetId' | 'expiresAt'>[] = [],
now = Date.now(),
): PatrolFindingRowScaffold | undefined {
if (
finding.source !== 'ai-patrol' ||
finding.status !== 'active' ||
isPatrolRuntimeFinding(finding)
) {
return undefined;
}
const title = getNonEmptyPresentationText(
getFindingTitlePresentation(finding).label,
'Current Patrol issue',
);
const subject = getNonEmptyPresentationText(
getFindingSubjectPresentation(finding).label,
'Affected resource not specified',
);
const workflow = getFindingPatrolWorkflowPresentation(finding, approvals, now);
return {
items: [
{
id: 'problem',
label: 'Problem',
value: title,
},
{
id: 'affected',
label: 'Affected',
value: subject,
},
{
id: 'why',
label: 'Why it matters',
value: getNonEmptyPresentationText(
finding.impact || finding.description,
'Review this Patrol finding before making infrastructure changes.',
),
},
{
id: 'checked',
label: 'What Pulse checked',
value: getNonEmptyPresentationText(
getFindingEvidencePresentation(finding),
'Patrol recorded this from the current check.',
),
},
{
id: 'workflow',
label: 'Safe workflow',
value: getPatrolFindingWorkflowSummary(workflow),
},
{
id: 'next-step',
label: 'Recommended next step',
value: getNonEmptyPresentationText(
workflow?.detail || finding.recommendation,
'Open details to review evidence and decide the next action.',
),
},
{
id: 'verification',
label: 'Verification',
value: getPatrolFindingVerificationSummary(finding),
},
],
};
return 'Open this issue to review what Patrol found and decide the next step.';
}
export const getPatrolFindingIssueCountLabel = (count: number): string => {

View file

@ -19,6 +19,9 @@ type PatrolWorkbenchFixtureOptions = {
runStatus?: "healthy" | "issues_found" | "error";
};
const recentPatrolStartedAt = new Date(Date.now() - 102_000).toISOString();
const recentPatrolCompletedAt = new Date(Date.now() - 60_000).toISOString();
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
@ -116,8 +119,8 @@ const buildRuntimeState = () => ({
const buildRunHistory = (options: Required<PatrolWorkbenchFixtureOptions>) => [
{
id: options.findingCount > 0 ? "run-active-work" : "run-calm-day",
started_at: "2026-06-30T08:05:00Z",
completed_at: "2026-06-30T08:05:42Z",
started_at: recentPatrolStartedAt,
completed_at: recentPatrolCompletedAt,
duration_ms: 42_000,
type: "full",
trigger_reason: "scheduled",
@ -151,7 +154,7 @@ const buildPatrolStatus = (
runtime_state: "active",
running: false,
enabled: true,
last_patrol_at: "2026-06-30T08:05:42Z",
last_patrol_at: recentPatrolCompletedAt,
next_patrol_at: "2099-06-30T14:05:42Z",
last_duration_ms: 42_000,
resources_checked: options.resourcesChecked,
@ -231,8 +234,8 @@ const buildPatrolFindingsResponse = (
"CPU stayed above the configured warning threshold during the scheduled Patrol check.",
recommendation:
"Review the host load and move or stop noisy workloads before approving a fix.",
detected_at: "2026-06-30T08:05:00Z",
last_seen_at: "2026-06-30T08:05:42Z",
detected_at: recentPatrolStartedAt,
last_seen_at: recentPatrolCompletedAt,
auto_resolved: false,
times_raised: 1,
suppressed: false,
@ -558,9 +561,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
).toHaveCount(0);
await expect(page.getByText("Patrol checked 1 resource")).toHaveCount(0);
await expect(page.getByText("No Patrol work waiting")).toHaveCount(0);
await expect(
page.getByText("Next check scheduled"),
).toHaveCount(0);
await expect(page.getByText("Next check scheduled")).toHaveCount(0);
await page.getByRole("tab", { name: "Patrol" }).click();
await expect(page).toHaveURL(/\/patrol$/);
@ -629,88 +630,193 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
await expect(
page.getByRole("list", { name: "Patrol protection posture" }),
).toHaveCount(0);
const issueSummary = page.locator('dl[aria-label="Patrol issue summary"]');
await expect(issueSummary).toBeVisible();
await expect(issueSummary.getByText("Problem")).toBeVisible();
await expect(issueSummary.getByText("Affected")).toBeVisible();
await expect(issueSummary.getByText("Why it matters")).toBeVisible();
await expect(issueSummary.getByText("What Pulse checked")).toBeVisible();
await expect(issueSummary.getByText("Safe workflow")).toBeVisible();
await expect(issueSummary.getByText("Recommended next step")).toBeVisible();
await expect(issueSummary.getByText("Verification")).toBeVisible();
await expect(page.getByText("High CPU pressure on pve-main")).toBeVisible();
await expect(page.getByText("Approval required")).toBeVisible();
await expect(
issueSummary.getByText("High CPU pressure on pve-main"),
).toBeVisible();
await expect(issueSummary.getByText("pve-main (agent)")).toBeVisible();
await expect(
issueSummary.getByText(
page.getByText(
"Sustained CPU pressure can slow hosted workloads on this Proxmox host.",
),
).toBeVisible();
const collapsedIssue = page.getByRole("button", {
name: "Open issue details for High CPU pressure on pve-main",
});
await expect(collapsedIssue.getByText("Problem")).toHaveCount(0);
await expect(collapsedIssue.getByText("Affected")).toHaveCount(0);
await expect(collapsedIssue.getByText("Safe workflow")).toHaveCount(0);
await expect(collapsedIssue.getByText("Verification")).toHaveCount(0);
await expect(collapsedIssue.getByText("What Pulse checked:")).toHaveCount(
0,
);
await page
.getByRole("button", {
name: "Review issue for High CPU pressure on pve-main",
})
.click();
const issueReview = page.getByRole("complementary", {
name: "Review High CPU pressure on pve-main",
});
await expect(issueReview).toBeVisible();
await expect(issueReview.getByText("pve-main (agent)")).toBeVisible();
await expect(
issueSummary.getByText(
issueReview.getByText(
"CPU stayed above the configured warning threshold during the scheduled Patrol check.",
),
).toBeVisible();
await expect(
issueSummary.getByText(
"Review evidence first; no change runs until the proposed fix is approved, then Patrol verifies the outcome.",
),
).toBeVisible();
await expect(
issueSummary.getByText(
"A governed fix is waiting for an approve or reject decision.",
),
).toBeVisible();
await expect(
issueSummary.getByText("Waiting for approval before any fix runs."),
).toBeVisible();
await expect(issueReview.getByText("What Pulse checked:")).toBeVisible();
await expect(issueReview.getByText("Recommended next step:")).toBeVisible();
await expect(
page.getByRole("heading", { name: /^Pulse Assistant$/ }),
).toHaveCount(0);
});
test("Patrol presents bounded APT findings without package-manager internals", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.startsWith("mobile-"), "Phone action review is covered by the Actions journey");
await mockMonitorFirstPatrolWorkbench(page, { findingCount: 1, runStatus: "issues_found" });
test("Patrol presents bounded APT findings without package-manager internals", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Phone action review is covered by the Actions journey",
);
await mockMonitorFirstPatrolWorkbench(page, {
findingCount: 1,
runStatus: "issues_found",
});
const updateFinding = {
id: "apt-updates-active", key: "apt-host-updates", alertIdentifier: `${monitoredProxmoxResource.id}::apt/updates`, severity: "warning", category: "maintenance",
resource_id: monitoredProxmoxResource.id, resource_name: monitoredProxmoxResource.displayName, resource_type: monitoredProxmoxResource.type,
title: "Operating system updates need review", description: "Patrol found a bounded host-maintenance action for review.", impact: "Unapplied updates can leave the host behind its intended maintenance posture.",
evidence: "pending_updates=6 inventory=sha256:must-not-render checked_at=2026-07-12T10:00:00Z received_at=2026-07-12T10:05:00Z reboot_required=true",
recommendation: "Review the governed update action. A reboot, if required later, is a separate action.", detected_at: "2026-07-12T10:00:00Z", last_seen_at: "2026-07-12T10:05:00Z", auto_resolved: false, times_raised: 1, suppressed: false, investigation_attempts: 1,
investigation_status: "completed", investigation_outcome: "verification_failed",
};
id: "apt-updates-active",
key: "apt-host-updates",
alertIdentifier: `${monitoredProxmoxResource.id}::apt/updates`,
severity: "warning",
category: "maintenance",
resource_id: monitoredProxmoxResource.id,
resource_name: monitoredProxmoxResource.displayName,
resource_type: monitoredProxmoxResource.type,
title: "Operating system updates need review",
description: "Patrol found a bounded host-maintenance action for review.",
impact:
"Unapplied updates can leave the host behind its intended maintenance posture.",
evidence:
"pending_updates=6 inventory=sha256:must-not-render checked_at=2026-07-12T10:00:00Z received_at=2026-07-12T10:05:00Z reboot_required=true",
recommendation:
"Review the governed update action. A reboot, if required later, is a separate action.",
detected_at: "2026-07-12T10:00:00Z",
last_seen_at: "2026-07-12T10:05:00Z",
auto_resolved: false,
times_raised: 1,
suppressed: false,
investigation_attempts: 1,
investigation_status: "completed",
investigation_outcome: "verification_failed",
};
const cleanupFinding = {
id: "apt-cleanup-active", key: "apt-package-cache-pressure", alertIdentifier: `${monitoredProxmoxResource.id}::apt/cache`, severity: "warning", category: "storage",
resource_id: monitoredProxmoxResource.id, resource_name: monitoredProxmoxResource.displayName, resource_type: monitoredProxmoxResource.type,
title: "Downloaded package data is using needed space", description: "Patrol measured reclaimable downloaded package data on the pressured filesystem.", impact: "The host may run short of operational disk space.",
evidence: "reclaimable_bytes=104857600 filesystem_usage=91.5 fingerprint=sha256:must-not-render checked_at=2026-07-12T10:00:00Z received_at=2026-07-12T10:05:00Z",
recommendation: "Review the governed cleanup action and rescan after any inconclusive attempt.", detected_at: "2026-07-12T10:00:00Z", last_seen_at: "2026-07-12T10:05:00Z", auto_resolved: false, times_raised: 1, suppressed: false, investigation_attempts: 1,
investigation_status: "completed", investigation_outcome: "fix_failed",
};
id: "apt-cleanup-active",
key: "apt-package-cache-pressure",
alertIdentifier: `${monitoredProxmoxResource.id}::apt/cache`,
severity: "warning",
category: "storage",
resource_id: monitoredProxmoxResource.id,
resource_name: monitoredProxmoxResource.displayName,
resource_type: monitoredProxmoxResource.type,
title: "Downloaded package data is using needed space",
description:
"Patrol measured reclaimable downloaded package data on the pressured filesystem.",
impact: "The host may run short of operational disk space.",
evidence:
"reclaimable_bytes=104857600 filesystem_usage=91.5 fingerprint=sha256:must-not-render checked_at=2026-07-12T10:00:00Z received_at=2026-07-12T10:05:00Z",
recommendation:
"Review the governed cleanup action and rescan after any inconclusive attempt.",
detected_at: "2026-07-12T10:00:00Z",
last_seen_at: "2026-07-12T10:05:00Z",
auto_resolved: false,
times_raised: 1,
suppressed: false,
investigation_attempts: 1,
investigation_status: "completed",
investigation_outcome: "fix_failed",
};
const findings = [updateFinding];
await page.route("**/api/ai/patrol/findings*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(findings) }));
await page.route("**/api/ai/unified/findings*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ findings, count: 2, active_count: 2 }) }));
await page.route("**/api/ai/patrol/findings*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(findings),
}),
);
await page.route("**/api/ai/unified/findings*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ findings, count: 2, active_count: 2 }),
}),
);
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { level: 1, name: "Patrol" })).toBeVisible();
await expect(page.getByText("Operating system updates need review").first()).toBeVisible();
await expect(page.getByText("6 operating system updates were pending when the agent checked", { exact: false })).toBeVisible();
await expect(page.getByText("reboot required: Yes", { exact: false })).toBeVisible();
await expect(
page.getByRole("heading", { level: 1, name: "Patrol" }),
).toBeVisible();
await expect(
page.getByText("Operating system updates need review").first(),
).toBeVisible();
await page
.getByRole("button", {
name: "Review issue for Operating system updates need review",
})
.click();
const updateReview = page.getByRole("complementary", {
name: "Review Operating system updates need review",
});
await expect(
updateReview.getByText(
"6 operating system updates were pending when the agent checked",
{ exact: false },
),
).toBeVisible();
await expect(
updateReview.getByText("reboot required: Yes", { exact: false }),
).toBeVisible();
await expect(page.getByText("sha256:must-not-render")).toHaveCount(0);
await expect(page.getByText("inventory=", { exact: false })).toHaveCount(0);
await page.unroute("**/api/ai/patrol/findings*");
await page.unroute("**/api/ai/unified/findings*");
await page.route("**/api/ai/patrol/findings*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([cleanupFinding]) }));
await page.route("**/api/ai/unified/findings*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ findings: [cleanupFinding], count: 1, active_count: 1 }) }));
await page.route("**/api/ai/patrol/findings*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([cleanupFinding]),
}),
);
await page.route("**/api/ai/unified/findings*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
findings: [cleanupFinding],
count: 1,
active_count: 1,
}),
}),
);
await page.reload({ waitUntil: "domcontentloaded" });
const cleanupTitle = page.getByText("Downloaded package data is using needed space").first();
const cleanupTitle = page
.getByText("Downloaded package data is using needed space")
.first();
await expect(cleanupTitle).toBeVisible();
await cleanupTitle.locator("xpath=ancestor::*[@role='button'][1]").click();
await expect(page.getByText("100 MB of downloaded package data was reclaimable", { exact: false }).first()).toBeVisible();
await expect(page.getByText("91.5% full", { exact: false }).first()).toBeVisible();
await expect(page.getByText("fingerprint=", { exact: false })).toHaveCount(0);
await testInfo.attach("apt-bounded-patrol-findings", { body: await page.screenshot(), contentType: "image/png" });
await expect(
page
.getByText("100 MB of downloaded package data was reclaimable", {
exact: false,
})
.first(),
).toBeVisible();
await expect(
page.getByText("91.5% full", { exact: false }).first(),
).toBeVisible();
await expect(page.getByText("fingerprint=", { exact: false })).toHaveCount(
0,
);
await testInfo.attach("apt-bounded-patrol-findings", {
body: await page.screenshot(),
contentType: "image/png",
});
const resolvedFinding = {
...updateFinding,
@ -719,19 +825,47 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
auto_resolved: true,
resolved_at: "2026-07-12T10:06:00Z",
investigation_outcome: "fix_verified",
evidence: "pending_updates=0 inventory=sha256:must-not-render checked_at=2026-07-12T10:01:00Z received_at=2026-07-12T10:06:00Z reboot_required=false",
evidence:
"pending_updates=0 inventory=sha256:must-not-render checked_at=2026-07-12T10:01:00Z received_at=2026-07-12T10:06:00Z reboot_required=false",
};
await page.unroute("**/api/ai/patrol/findings*");
await page.unroute("**/api/ai/unified/findings*");
await page.route("**/api/ai/patrol/findings*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([resolvedFinding]) }));
await page.route("**/api/ai/unified/findings*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ findings: [resolvedFinding], count: 1, active_count: 0 }) }));
await page.route("**/api/ai/patrol/findings*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([resolvedFinding]),
}),
);
await page.route("**/api/ai/unified/findings*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
findings: [resolvedFinding],
count: 1,
active_count: 0,
}),
}),
);
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Resolved" }).click();
const resolvedTitle = page.getByText("Operating system updates confirmed complete").first();
const resolvedTitle = page
.getByText("Operating system updates confirmed complete")
.first();
await expect(resolvedTitle).toBeVisible();
await resolvedTitle.locator("xpath=ancestor::*[@role='button'][1]").click();
await expect(page.getByText("0 operating system updates were pending", { exact: false }).first()).toBeVisible();
await expect(page.getByText("Operating system updates need review")).toHaveCount(0);
await testInfo.attach("apt-confirmed-postcondition-resolved", { body: await page.screenshot(), contentType: "image/png" });
await expect(
page
.getByText("0 operating system updates were pending", { exact: false })
.first(),
).toBeVisible();
await expect(
page.getByText("Operating system updates need review"),
).toHaveCount(0);
await testInfo.attach("apt-confirmed-postcondition-resolved", {
body: await page.screenshot(),
contentType: "image/png",
});
});
});