Expose resolved findings to the Patrol Resolved tab

The trust strip on the Patrol page credits "N auto-resolved" but
the Resolved tab next to it sat empty — operators could see the
count but not click through to audit which findings had been
resolved or by what mechanism. The /api/ai/patrol/findings
endpoint only returned active findings, so the frontend filter
(status === 'resolved' || 'dismissed' || 'snoozed') had nothing
to render.

Adds the audit-trail accessor end to end:

- PatrolService.GetAllFindingsIncludingResolved returns active +
  resolved + dismissed + snoozed findings at warning severity or
  higher, sorted with active first then by severity then recency.
  Two separate severity orderings — filter (info=0..critical=3,
  used with >= against the warning floor) and sort
  (critical=0..info=3, used with < to surface critical first).
  Conflating them initially let watch findings leak through the
  warning floor; the test fixture catches that.
- HandleGetPatrolFindings honors a new include_resolved=1 query
  parameter that routes to the new accessor. Default behaviour
  (active only) is unchanged for clients that just want the live
  findings list.
- Frontend getPatrolFindings accepts an options object with
  includeResolved and loadPatrolFindings threads it through.
- FindingsPanel triggers an includeResolved load whenever the
  Resolved filter becomes active for the Patrol-source view.

Test: TestPatrolService_GetAllFindingsIncludingResolved_IncludesResolvedAndDismissedSortsActiveFirst
covers active-first ordering, inclusion of resolved + dismissed,
and the warning severity floor (watch-level findings must not
leak through).
This commit is contained in:
rcourtman
2026-05-11 11:09:03 +01:00
parent d02255907c
commit 3c0b52c11d
6 changed files with 181 additions and 4 deletions
+7 -2
View File
@@ -236,8 +236,13 @@ export async function getPatrolStatus(): Promise<PatrolStatus> {
return apiFetchJSON<PatrolStatus>('/api/ai/patrol/status');
}
export async function getPatrolFindings(): Promise<Finding[]> {
const findings = await apiFetchJSON<Finding[]>('/api/ai/patrol/findings');
export async function getPatrolFindings(
options?: { includeResolved?: boolean },
): Promise<Finding[]> {
const path = options?.includeResolved
? '/api/ai/patrol/findings?include_resolved=1'
: '/api/ai/patrol/findings';
const findings = await apiFetchJSON<Finding[]>(path);
return arrayOrEmpty<Finding>(findings).map((finding) =>
promoteLegacyAlertIdentifier(finding as Finding & { alert_identifier?: string }),
);
@@ -178,6 +178,17 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
}
});
// The Patrol findings endpoint defaults to active-only. When the
// operator switches to the Resolved tab, we need to fetch the full
// active+resolved+dismissed+snoozed set so the audit-trail filter has
// data to show. The trust strip credits "N auto-resolved" without
// this load and the Resolved tab is otherwise empty.
createEffect(() => {
if (filter() === 'resolved' && isPatrolFindingsSource()) {
void aiIntelligenceStore.loadPatrolFindings({ includeResolved: true });
}
});
// Filter and sort findings
const hasUnknownRunSnapshot = createMemo(
() => props.runSnapshot !== undefined && props.filterFindingIds === undefined,
+2 -2
View File
@@ -442,11 +442,11 @@ export const aiIntelligenceStore = {
}
},
async loadPatrolFindings() {
async loadPatrolFindings(options?: { includeResolved?: boolean }) {
setPatrolFindingsLoading(true);
setPatrolFindingsError(null);
try {
const findings = await getPatrolFindings();
const findings = await getPatrolFindings(options);
const now = Date.now();
setPatrolFindings(findings.map((item) => normalizePatrolFindingRecord(item, now)));
} catch (e) {
+58
View File
@@ -658,6 +658,64 @@ func (p *PatrolService) GetAllFindings() []*Finding {
return findings
}
// GetAllFindingsIncludingResolved returns active + resolved + dismissed +
// snoozed findings at warning severity or higher, sorted by severity then
// recency. Used by the Resolved tab in the Patrol UI so operators can audit
// the auto_resolved set credited in the trust strip — without this path
// the strip surfaced "8 auto-resolved" but the operator could not click
// through to see what those eight were.
func (p *PatrolService) GetAllFindingsIncludingResolved() []*Finding {
all := p.findings.GetAll(nil)
normalizeFindingResourceTypes(all)
// Two orderings — they look similar but they're inverse:
// filterOrder is "low-to-high severity" (info=0, ..., critical=3) so
// `>= minOrder` keeps everything at or above the floor. This is the
// same convention FindingsStore.GetActive uses.
// sortOrder is "high-to-low priority" (critical=0, ..., info=3) so a
// `<` comparison surfaces critical first in the result slice.
// Conflating the two earlier let watch-severity findings leak through
// the warning floor in the test, because watch's "sort priority"
// number was higher than warning's even though its severity is lower.
filterOrder := map[FindingSeverity]int{
FindingSeverityInfo: 0,
FindingSeverityWatch: 1,
FindingSeverityWarning: 2,
FindingSeverityCritical: 3,
}
sortOrder := map[FindingSeverity]int{
FindingSeverityCritical: 0,
FindingSeverityWarning: 1,
FindingSeverityWatch: 2,
FindingSeverityInfo: 3,
}
minOrder := filterOrder[FindingSeverityWarning]
filtered := make([]*Finding, 0, len(all))
for _, f := range all {
if filterOrder[f.Severity] >= minOrder {
filtered = append(filtered, f)
}
}
sort.Slice(filtered, func(i, j int) bool {
// Active first, then resolved — operator typically wants to review
// the live set before drilling into history.
ai := filtered[i].IsActive()
aj := filtered[j].IsActive()
if ai != aj {
return ai
}
if sortOrder[filtered[i].Severity] != sortOrder[filtered[j].Severity] {
return sortOrder[filtered[i].Severity] < sortOrder[filtered[j].Severity]
}
// Most recent activity first within each bucket.
return filtered[i].LastSeenAt.After(filtered[j].LastSeenAt)
})
return filtered
}
func normalizeFindingResourceTypes(findings []*Finding) {
for _, f := range findings {
if f == nil {
@@ -1123,6 +1123,101 @@ func TestPatrolFindingCreatorAdapter_ResolveFinding_RejectsOutOfScopeFinding(t *
}
}
func TestPatrolService_GetAllFindingsIncludingResolved_IncludesResolvedAndDismissedSortsActiveFirst(t *testing.T) {
// GetAllFindingsIncludingResolved is the audit-trail accessor used by the
// Patrol UI's Resolved tab. Until it landed, the trust strip credited
// "N auto-resolved" but the operator could not click through to see
// what those N findings actually were — the /api/ai/patrol/findings
// endpoint only returned active findings. This test locks in:
// - resolved + dismissed findings are returned (not just active)
// - the warning-severity floor still applies (watch/info filtered)
// - active findings sort before resolved within the result
ps := NewPatrolService(nil, nil)
now := time.Now()
older := now.Add(-2 * time.Hour)
pastResolved := now.Add(-time.Hour)
active := &Finding{
ID: "active-warn",
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
ResourceID: "vm-1",
ResourceName: "web",
Title: "Active warning",
DetectedAt: older,
LastSeenAt: now,
}
resolved := &Finding{
ID: "resolved-warn",
Severity: FindingSeverityWarning,
Category: FindingCategoryBackup,
ResourceID: "vm-2",
ResourceName: "db",
Title: "Resolved backup failure",
DetectedAt: older,
LastSeenAt: older,
ResolvedAt: &pastResolved,
AutoResolved: true,
}
dismissed := &Finding{
ID: "dismissed-warn",
Severity: FindingSeverityWarning,
Category: FindingCategoryGeneral,
ResourceID: "vm-3",
ResourceName: "cache",
Title: "Dismissed as expected",
DetectedAt: older,
LastSeenAt: older,
DismissedReason: "expected_behavior",
}
belowSeverity := &Finding{
ID: "watch-noise",
Severity: FindingSeverityWatch,
Category: FindingCategoryPerformance,
ResourceID: "vm-4",
ResourceName: "noise",
Title: "Below warning floor",
DetectedAt: older,
LastSeenAt: now,
}
ps.findings.Add(active)
ps.findings.Add(resolved)
ps.findings.Add(dismissed)
ps.findings.Add(belowSeverity)
got := ps.GetAllFindingsIncludingResolved()
gotIDs := make([]string, 0, len(got))
for _, f := range got {
gotIDs = append(gotIDs, f.ID)
}
// Active-warn must be first (active before resolved/dismissed).
if len(got) == 0 || got[0].ID != "active-warn" {
t.Fatalf("expected active finding first, got order %v", gotIDs)
}
// Resolved and dismissed warnings must be present.
resolvedSeen := false
dismissedSeen := false
for _, f := range got {
if f.ID == "resolved-warn" {
resolvedSeen = true
}
if f.ID == "dismissed-warn" {
dismissedSeen = true
}
if f.ID == "watch-noise" {
t.Errorf("watch-severity finding leaked through warning floor: %v", gotIDs)
}
}
if !resolvedSeen {
t.Errorf("expected resolved finding in result, got %v", gotIDs)
}
if !dismissedSeen {
t.Errorf("expected dismissed finding in result, got %v", gotIDs)
}
}
func TestPatrolService_HasDeterministicVerifierForKey(t *testing.T) {
ps := NewPatrolService(nil, nil)
+8
View File
@@ -5590,9 +5590,17 @@ func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *ht
// Check for resource_id query parameter
resourceID := r.URL.Query().Get("resource_id")
// include_resolved=1 returns active + resolved + dismissed + snoozed
// findings, so the Patrol UI's Resolved tab can audit the
// auto_resolved set credited in the trust strip. Default behaviour
// remains active-only for clients that just want to render the
// live findings list.
includeResolved := r.URL.Query().Get("include_resolved") == "1"
var findings []*ai.Finding
if resourceID != "" {
findings = patrol.GetFindingsForResource(resourceID)
} else if includeResolved {
findings = patrol.GetAllFindingsIncludingResolved()
} else {
findings = patrol.GetAllFindings()
}