mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Redesign Patrol open work queue
This commit is contained in:
parent
2e9520da81
commit
f9f90a1a27
14 changed files with 953 additions and 961 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(['', '']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue