Add Create rule from this button on Patrol findings

Last of the seven contextual entries from the captured Pulse
Intelligence rubric. "Remember as expected" handles one
instance; "Create rule from this" promotes the pattern: any
future finding on the same {resource, category} pair auto-
dismisses inside the backend's existing
FindingsStore.isSuppressedInternal /
MatchesSuppressionRule machinery rather than surfacing as a new
finding.

The backend endpoint already exists
(POST /api/ai/patrol/suppressions →
HandleAddSuppressionRule → FindingsStore.AddSuppressionRule).
The button + inline confirm panel is the missing surface:

- frontend-modern/src/api/patrol.ts gains
  createSuppressionRuleFromFinding(input) that POSTs to the
  existing endpoint with the finding's resource + category +
  operator-supplied reason.
- FindingsPanel adds a Create rule button at the end of the
  lifecycle action row, plus an inline confirmation that
  surfaces the rule scope (resource + category), requires a
  reason, and explains the future-auto-dismiss commitment.
  Submission goes through aiIntelligenceStore.loadDashboardData
  so the local view reflects the audit trail of record.
- Mirrors the visual pattern of the existing dismiss-confirmation
  panel but uses neutral surface styling because this isn't a
  dismissal — it's a permanent commitment, distinct from
  Remember as expected which dismisses just this instance.

No backend changes; the rule machinery and the API endpoint are
unchanged. This is the surface piece. Type-check clean.
This commit is contained in:
rcourtman 2026-05-11 10:33:26 +01:00
parent 5a7fde7b39
commit f13893b63f
2 changed files with 146 additions and 0 deletions

View file

@ -308,6 +308,40 @@ export async function dismissFinding(
});
}
/**
* Create a permanent suppression rule from a finding.
*
* Unlike dismissFinding (which acts on a single instance), this records a
* durable pattern: future findings on the same {resource, category}
* pair will be auto-dismissed by the backend's
* isSuppressedInternal/MatchesSuppressionRule machinery. Used by the
* per-finding "Create rule from this" button so an operator who has
* been silencing the same noisy pattern repeatedly can promote that
* judgment into a remembered rule rather than dismissing every recurrence.
*
* The description is required by the backend it's the operator's
* stated reason, surfaced when the rule is later listed or audited.
* Pass an empty resourceId or category to broaden the rule's scope
* (e.g. all categories on this resource, or this category on any
* resource).
*/
export async function createSuppressionRuleFromFinding(input: {
resourceId: string;
resourceName: string;
category: string;
description: string;
}): Promise<{ success: boolean; message: string; rule: { id: string } }> {
return apiFetchJSON('/api/ai/patrol/suppressions', {
method: 'POST',
body: JSON.stringify({
resource_id: input.resourceId,
resource_name: input.resourceName,
category: input.category,
description: input.description,
}),
});
}
/**
* Set or update a user note on a finding
* Notes provide context that Patrol sees on future runs.

View file

@ -30,6 +30,7 @@ import {
import { useResources } from '@/hooks/useResources';
import { InvestigationSection, ApprovalSection } from '@/components/patrol';
import { AIAPI, type ApprovalRequest, type RemediationPlan } from '@/api/ai';
import { createSuppressionRuleFromFinding } from '@/api/patrol';
import type { PatrolRunRecord, PatrolRuntimeState } from '@/api/patrol';
import { buildResolvedResourceSurfaceLinks } from '@/routing/resourceLinks';
import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation';
@ -635,6 +636,59 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
}
};
// Create rule from this is the promotion path: take the operator's
// implicit pattern (silencing the same {resource, category} pair
// repeatedly) and turn it into a durable suppression rule the backend
// remembers. After creation, future findings matching the rule
// auto-dismiss inside FindingsStore.isSuppressedInternal rather than
// re-surfacing every Patrol run. The button confirms the scope and
// requires a reason so the rule has audit context.
const [creatingRuleForId, setCreatingRuleForId] = createSignal<string | null>(null);
const [createRuleDescription, setCreateRuleDescription] = createSignal('');
const handleStartCreateRule = (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
setCreatingRuleForId(finding.id);
setCreateRuleDescription(finding.userNote || '');
setExpandedId(finding.id);
};
const handleCancelCreateRule = (e: Event) => {
e.stopPropagation();
setCreatingRuleForId(null);
};
const handleConfirmCreateRule = async (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
const description = createRuleDescription().trim();
if (!description) {
notificationStore.error('A reason for the rule is required');
return;
}
setActionLoading(finding.id);
try {
await createSuppressionRuleFromFinding({
resourceId: finding.resourceId,
resourceName: finding.resourceName,
category: finding.category || '',
description,
});
notificationStore.success(
`Rule created: future ${finding.category || 'matching'} findings on ${finding.resourceName} will auto-dismiss`,
);
setCreatingRuleForId(null);
// Refresh so the operator sees the finding update (typically the
// rule takes effect on next Patrol cycle, but the local view
// should reflect the audit-trail-of-record).
void aiIntelligenceStore.loadDashboardData();
} catch (err) {
console.error('Failed to create suppression rule:', err);
notificationStore.error('Failed to create suppression rule');
} finally {
setActionLoading(null);
}
};
// Copy a Markdown summary of the finding to the clipboard so the operator
// can paste it into a chat, ticket, or incident channel. The shape mirrors
// the seven-question schema (title + impact + recommendation + trust
@ -1400,10 +1454,68 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
>
Dismiss: Later
</button>
<button
type="button"
onClick={(e) => handleStartCreateRule(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover"
disabled={actionLoading() === finding.id}
title="Promote this dismissal into a permanent rule — future findings on this resource for this category will auto-dismiss without surfacing"
>
Create rule from this
</button>
</>
</Show>
</div>
</Show>
{/* Inline create-rule confirmation. Confirms scope (resource +
category) and requires a reason so the persisted rule has
audit context. Mirrors the dismiss-confirmation panel
visually but uses neutral surface styling this isn't a
dismissal, it's a permanent commitment. */}
<Show when={creatingRuleForId() === finding.id}>
<div class="mt-2 p-2 rounded border border-border bg-surface-alt">
<div class="flex items-center gap-2 mb-1.5">
<span class="text-xs font-medium text-base-content">
Create suppression rule for{' '}
<span class="font-semibold">{finding.resourceName}</span>
<Show when={finding.category}>
{' '}({finding.category})
</Show>
</span>
</div>
<p class="text-[11px] text-muted mb-1.5">
Future findings matching this resource and category will be
auto-dismissed by Patrol without surfacing as new findings.
You can list or remove rules later from the suppressions
management surface.
</p>
<textarea
class="w-full text-xs px-2 py-1.5 rounded border border-border bg-surface text-base-content resize-none focus:outline-none focus:ring-1 focus:ring-blue-400"
rows={2}
value={createRuleDescription()}
onInput={(e) => setCreateRuleDescription(e.currentTarget.value)}
placeholder="Why this rule? (required — e.g. 'delly backups are intentionally off-site, ignore failures')"
onClick={(e) => e.stopPropagation()}
/>
<div class="flex gap-2 mt-1.5">
<button
type="button"
onClick={(e) => handleConfirmCreateRule(finding, e)}
class="px-3 py-1 text-xs font-medium rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50"
disabled={actionLoading() === finding.id || !createRuleDescription().trim()}
>
Create rule
</button>
<button
type="button"
onClick={handleCancelCreateRule}
class="px-3 py-1 text-xs font-medium rounded border border-border hover:bg-surface-hover"
>
Cancel
</button>
</div>
</div>
</Show>
{/* Inline dismiss confirmation. The header verb tracks the
intent: "Remembering as" for expected_behavior (future-looking,
"Pulse should know this is expected"), "Dismiss as" for the