Expose Patrol verification activity mix

This commit is contained in:
rcourtman
2026-03-29 14:50:09 +01:00
parent fa7afc47de
commit df448c41d9
8 changed files with 285 additions and 0 deletions
@@ -135,6 +135,7 @@ Own canonical runtime payload shapes between backend and frontend.
and the Patrol findings empty-state behavior, so `0 active findings` only renders as a healthy frontend conclusion when the same governed AI summary contract still reports healthy overall health; degraded or not-fully-verified health predictions must flow through to the Patrol findings surface instead of being replaced by page-local "looks healthy" copy
and the Patrol assessment headline plus compact summary-strip behavior, so the same governed AI summary contract decides whether the page leads with verified health, issues detected, coverage incomplete, or another attention state instead of letting count-only page fragments emit a stale `No issues found` conclusion
and the Patrol verification summary derived from run history, so the page also states whether recent Patrol evidence came from a successful full patrol or only from scoped/erroring runs instead of leaving verification scope implicit
and the same-day activity-mix explanation derived from that governed run history, so when a recent full patrol is followed by alert-triggered or anomaly-triggered scoped work the verification surface can explain the mix directly instead of reconstructing it from page-local timing heuristics
and the Patrol status recency split, so `last_patrol_at` remains reserved for completed full Patrol sweeps while scoped runs and verification checks advance `last_activity_at` without claiming a fresh full-estate verification pass
and the canonical alert-triggered Patrol enqueue path in `internal/api/router.go`, so alert-fired Patrol work flows through the unified alert bridge and trigger manager instead of being duplicated by monitor callback wiring
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` card, so canonical recent-change timelines stay rendered through one governed frontend card instead of separate page-local list loops
@@ -1076,6 +1076,10 @@ When the same governed run-history contract shows a recent full patrol plus
same-day scoped follow-up work, that summary shell should also carry a compact
activity-mix explanation rather than forcing operators to infer why Patrol
looked busy from a separate activity strip alone.
That explanation belongs on the verification surface itself when operators are
reconciling `Recently verified` copy against same-day scoped Patrol bursts; the
status strip may support the readout, but it is not sufficient as the only
explanation path.
That same shell rule also owns Patrol recency labels. Shared Patrol header and
status-shell surfaces must keep `Last full patrol` tied only to the full-sweep
transport fact and use `Last activity` for scoped or verification work instead
@@ -227,6 +227,10 @@ operator whether Patrol recently completed a successful full patrol, only ran
scoped alert-triggered checks, or ended its most recent full patrol with
errors, so the page does not leave trust and coverage as implicit background
knowledge.
When same-day run history shows both a recent full patrol and a burst of
scoped follow-up activity, that same verification surface should expose the
recent activity mix explicitly instead of leaving operators to reconcile a
`Recently verified` headline with a busy Patrol strip elsewhere on the page.
Fix-verification checks belong to that same explanation layer as targeted
activity, not as evidence of a fresh full-estate sweep.
The same hierarchy applies to investigation context. Correlations, recent
@@ -205,6 +205,11 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
</Show>
</p>
<p class="mt-1 text-sm text-muted">{verification().description}</p>
<Show when={verification().activityMixLabel}>
<p class="mt-2 text-xs font-medium text-base-content">
Recent activity mix: {verification().activityMixLabel}
</p>
</Show>
</div>
</div>
</div>
@@ -1029,6 +1029,142 @@ describe('AIIntelligence entitlement gating', () => {
).not.toBeInTheDocument();
});
it('surfaces the recent activity mix in the verification summary when scoped runs are creating noise', async () => {
hasFeatureMock.mockReturnValue(true);
licenseStatusMock.mockReturnValue({ subscription_state: 'active' });
getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ license_required: false }));
getPatrolRunHistoryMock.mockResolvedValue([
{
id: 'run-scoped-alert',
started_at: '2026-03-12T10:00:00Z',
completed_at: '2026-03-12T10:01:00Z',
duration_ms: 60000,
type: 'scoped',
trigger_reason: 'alert_fired',
scope_resource_ids: [],
effective_scope_resource_ids: [],
scope_resource_types: [],
resources_checked: 1,
nodes_checked: 0,
guests_checked: 0,
docker_checked: 0,
storage_checked: 0,
hosts_checked: 0,
pbs_checked: 0,
pmg_checked: 0,
kubernetes_checked: 0,
new_findings: 0,
existing_findings: 0,
rejected_findings: 0,
resolved_findings: 0,
auto_fix_count: 0,
findings_summary: '',
finding_ids: [],
error_count: 0,
status: 'healthy',
triage_flags: 0,
tool_call_count: 0,
},
{
id: 'run-scoped-anomaly',
started_at: '2026-03-12T09:58:00Z',
completed_at: '2026-03-12T09:59:00Z',
duration_ms: 60000,
type: 'scoped',
trigger_reason: 'anomaly',
scope_resource_ids: [],
effective_scope_resource_ids: [],
scope_resource_types: [],
resources_checked: 1,
nodes_checked: 0,
guests_checked: 0,
docker_checked: 0,
storage_checked: 0,
hosts_checked: 0,
pbs_checked: 0,
pmg_checked: 0,
kubernetes_checked: 0,
new_findings: 0,
existing_findings: 0,
rejected_findings: 0,
resolved_findings: 0,
auto_fix_count: 0,
findings_summary: '',
finding_ids: [],
error_count: 0,
status: 'healthy',
triage_flags: 0,
tool_call_count: 0,
},
{
id: 'run-full',
started_at: '2026-03-12T09:50:00Z',
completed_at: '2026-03-12T09:57:00Z',
duration_ms: 420000,
type: 'patrol',
trigger_reason: 'scheduled',
scope_resource_ids: [],
effective_scope_resource_ids: [],
scope_resource_types: [],
resources_checked: 58,
nodes_checked: 0,
guests_checked: 0,
docker_checked: 0,
storage_checked: 0,
hosts_checked: 0,
pbs_checked: 0,
pmg_checked: 0,
kubernetes_checked: 0,
new_findings: 0,
existing_findings: 0,
rejected_findings: 0,
resolved_findings: 0,
auto_fix_count: 0,
findings_summary: 'No active findings',
finding_ids: [],
error_count: 0,
status: 'healthy',
triage_flags: 0,
tool_call_count: 0,
},
]);
intelligenceState.summary = {
timestamp: '2026-03-12T10:05:00Z',
overall_health: {
score: 100,
grade: 'A',
trend: 'stable',
factors: [],
prediction: 'Infrastructure is healthy with no significant issues detected.',
},
findings_count: {
critical: 0,
warning: 0,
watch: 0,
info: 0,
total: 0,
},
predictions_count: 0,
recent_changes_count: 0,
learning: {
resources_with_knowledge: 0,
total_notes: 0,
resources_with_baselines: 0,
patterns_detected: 0,
correlations_learned: 0,
incidents_tracked: 0,
},
};
render(() => <AIIntelligence />);
await waitFor(() => {
expect(
screen.getByText('Recent activity mix: 1 full, 1 alert-triggered, 1 anomaly-triggered'),
).toBeInTheDocument();
});
});
it('treats a selected zero-finding run as an empty snapshot and uses effective scope ids', async () => {
hasFeatureMock.mockReturnValue(true);
licenseStatusMock.mockReturnValue({ subscription_state: 'active' });
@@ -344,6 +344,105 @@ describe('getPatrolSummaryPresentation', () => {
});
});
it('adds an activity mix when scoped runs make recent verification look busy', () => {
expect(
getPatrolVerificationPresentation({
runs: [
{
id: 'run-scoped-alert',
started_at: '2026-03-12T10:00:00Z',
completed_at: '2026-03-12T10:01:00Z',
duration_ms: 60000,
type: 'scoped',
trigger_reason: 'alert_fired',
resources_checked: 1,
nodes_checked: 0,
guests_checked: 0,
docker_checked: 0,
storage_checked: 0,
hosts_checked: 0,
pbs_checked: 0,
pmg_checked: 0,
kubernetes_checked: 0,
new_findings: 0,
existing_findings: 0,
rejected_findings: 0,
resolved_findings: 0,
auto_fix_count: 0,
findings_summary: '',
finding_ids: [],
error_count: 0,
status: 'healthy',
triage_flags: 0,
tool_call_count: 0,
},
{
id: 'run-scoped-anomaly',
started_at: '2026-03-12T09:58:00Z',
completed_at: '2026-03-12T09:59:00Z',
duration_ms: 60000,
type: 'scoped',
trigger_reason: 'anomaly',
resources_checked: 1,
nodes_checked: 0,
guests_checked: 0,
docker_checked: 0,
storage_checked: 0,
hosts_checked: 0,
pbs_checked: 0,
pmg_checked: 0,
kubernetes_checked: 0,
new_findings: 0,
existing_findings: 0,
rejected_findings: 0,
resolved_findings: 0,
auto_fix_count: 0,
findings_summary: '',
finding_ids: [],
error_count: 0,
status: 'healthy',
triage_flags: 0,
tool_call_count: 0,
},
{
id: 'run-full',
started_at: '2026-03-12T09:50:00Z',
completed_at: '2026-03-12T09:57:00Z',
duration_ms: 420000,
type: 'patrol',
resources_checked: 58,
nodes_checked: 0,
guests_checked: 0,
docker_checked: 0,
storage_checked: 0,
hosts_checked: 0,
pbs_checked: 0,
pmg_checked: 0,
kubernetes_checked: 0,
new_findings: 0,
existing_findings: 1,
rejected_findings: 0,
resolved_findings: 0,
auto_fix_count: 0,
findings_summary: '1 warning',
finding_ids: ['finding-1'],
error_count: 0,
status: 'issues_found',
triage_flags: 0,
tool_call_count: 0,
},
] as never,
}),
).toEqual({
title: 'Recently verified',
description: 'The most recent full patrol completed successfully and checked 58 resources.',
compactLabel: 'Recently verified',
tone: 'success',
lastFullRunAt: '2026-03-12T09:57:00Z',
activityMixLabel: '1 full, 1 alert-triggered, 1 anomaly-triggered',
});
});
it('reports partial verification when only scoped runs are recent', () => {
expect(
getPatrolVerificationPresentation({
@@ -2,6 +2,10 @@ import type { PatrolRunRecord, PatrolRuntimeState } from '@/api/patrol';
import type { UnifiedFinding } from '@/stores/aiIntelligence';
import type { IntelligenceHealthScore } from '@/types/aiIntelligence';
import { isPatrolRuntimeFinding } from '@/utils/aiFindingPresentation';
import {
formatPatrolActivityBreakdown,
getPatrolActivityBreakdown,
} from '@/utils/patrolRunPresentation';
import type { SemanticTone } from '@/utils/semanticTonePresentation';
import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation';
@@ -50,6 +54,7 @@ export interface PatrolVerificationPresentation {
compactLabel: string;
tone: SemanticTone;
lastFullRunAt?: string;
activityMixLabel?: string;
}
export interface PatrolRecencyPresentation {
@@ -312,6 +317,28 @@ function isVerificationPatrolRun(run: PatrolRunRecord): boolean {
return normalizeRunType(run.type) === 'verification';
}
function getVerificationActivityMixLabel(runs: PatrolRunRecord[]): string | undefined {
const latestCompletedRun = runs.find((run) => isCompletedPatrolRun(run));
const referenceTimestamp = latestCompletedRun?.completed_at || latestCompletedRun?.started_at;
if (!referenceTimestamp) {
return undefined;
}
const breakdown = getPatrolActivityBreakdown(runs, new Date(referenceTimestamp));
const scopedRuns =
breakdown.alertTriggeredRuns +
breakdown.anomalyTriggeredRuns +
breakdown.alertClearedRuns +
breakdown.verificationChecks +
breakdown.otherScopedRuns;
if (breakdown.totalRuns <= 1 || scopedRuns <= 0) {
return undefined;
}
const label = formatPatrolActivityBreakdown(breakdown);
return label || undefined;
}
function isCompletedPatrolRun(run: PatrolRunRecord): boolean {
return Boolean(run.completed_at?.trim());
}
@@ -442,6 +469,7 @@ export function getPatrolVerificationPresentation(args: {
}
const completedRuns = (args.runs ?? []).filter((run) => isCompletedPatrolRun(run));
const activityMixLabel = getVerificationActivityMixLabel(completedRuns);
const recentFullRun = completedRuns.find((run) => isFullPatrolRun(run));
if (recentFullRun) {
@@ -456,6 +484,7 @@ export function getPatrolVerificationPresentation(args: {
compactLabel: 'Verification limited',
tone: 'warning',
lastFullRunAt: recentFullRun.completed_at,
activityMixLabel,
};
}
@@ -468,6 +497,7 @@ export function getPatrolVerificationPresentation(args: {
compactLabel: 'Recently verified',
tone: 'success',
lastFullRunAt: recentFullRun.completed_at,
activityMixLabel,
};
}
@@ -496,6 +526,7 @@ export function getPatrolVerificationPresentation(args: {
description,
compactLabel: 'Partial verification',
tone: 'warning',
activityMixLabel,
};
}
@@ -516,6 +516,11 @@ test.describe("Patrol runtime-state browser contract", () => {
"Breakdown: 1 full, 1 alert-triggered, 1 anomaly-triggered",
),
).toBeVisible();
await expect(
page.getByText(
"Recent activity mix: 1 full, 1 alert-triggered, 1 anomaly-triggered",
),
).toBeVisible();
await expect(
page.getByText("Scoped triggers: 4 queued · busy mode · anomalies off"),
).toBeVisible();