render capacity-forecast approval card in FindingsPanel

Adds a distinguishable approve/reject card variant inside the existing
remediation plan render path. Activates only when the finding category
is "capacity" AND the attached RemediationPlan carries a
proposed_action_plan with source === "capacity_forecast". All other
findings (capacity without proposal, non-capacity with proposal) keep
the existing generic card via the Show fallback.

Card surfaces current/predicted/threshold metric snapshot, time to
threshold breach, proposed change, and template SafetyChecks. Approve
flows through AIAPI.approveRemediationPlan (existing handler); Reject
flows through handleDismissPlan. No parallel state machine.

Adds RemediationPlan.proposed_action_plan and the ProposedActionPlan /
ProposedMetricSummary / ProposedActionPreflight wire types matching
pkg/aicontracts.
This commit is contained in:
rcourtman
2026-05-12 23:01:27 +01:00
parent 2e064e6c6c
commit 2c81815f5f
4 changed files with 628 additions and 79 deletions
+45
View File
@@ -494,6 +494,51 @@ export interface RemediationPlan {
risk_level: 'low' | 'medium' | 'high';
status: 'pending' | 'approved' | 'executing' | 'completed' | 'failed' | 'rolled_back';
created_at: string;
// Optional deterministic action proposal attached by the patrol pipeline
// (currently only forecast-driven capacity proposals - see internal/ai/forecast).
// RequiresApproval is invariant on every proposal; Allowed=false signals
// a preflight-only proposal (no write capability wired yet).
proposed_action_plan?: ProposedActionPlan;
}
// ProposedActionPlan mirrors pkg/aicontracts.ProposedActionPlan on the
// wire. The "capacity_forecast" Source value tells FindingsPanel to render
// the distinguishable forecast approval card; anything else falls back to
// the generic remediation plan card.
export interface ProposedActionPlan {
actionId: string;
capabilityName?: string;
allowed: boolean;
requiresApproval: boolean;
approvalPolicy?: string;
message?: string;
source?: 'capacity_forecast' | string;
projectedMetric?: ProposedMetricSummary;
preflight?: ProposedActionPreflight;
plannedAt?: string;
expiresAt?: string;
}
// ProposedMetricSummary is the operator-facing snapshot rendered at the
// top of the capacity-forecast approval card.
export interface ProposedMetricSummary {
metric: string;
currentValue: number;
predictedValue?: number;
thresholdValue?: number;
timeToThresholdSeconds?: number | null;
}
// ProposedActionPreflight mirrors unifiedresources.ActionPreflight on the
// wire, narrowed to the fields the proposal card surfaces.
export interface ProposedActionPreflight {
target?: string;
currentState?: string;
intendedChange?: string;
dryRunAvailable: boolean;
dryRunSummary?: string;
safetyChecks?: string[];
verificationSteps?: string[];
}
export interface RemediationStep {
@@ -196,6 +196,45 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
notificationStore.success('Remediation plan dismissed');
};
// Reuses AIAPI.approveRemediationPlan, the existing approval contract for
// remediation plans. The capacity-forecast card surfaces this as
// "Approve proposal" so the operator can record intent on a preflight-only
// proposal (Allowed=false) - the action broker will still refuse execution
// until a Pulse write capability is wired for the resource type. See
// internal/ai/forecast.BuildActionPlanForFinding for the contract.
const handleApproveProposedPlan = async (plan: RemediationPlan, e: Event) => {
e.stopPropagation();
try {
await AIAPI.approveRemediationPlan(plan.id);
setDismissedPlanIds((prev) => [...prev, plan.id]);
notificationStore.success('Capacity-forecast proposal approved');
} catch (err) {
notificationStore.error(
err instanceof Error
? `Failed to approve proposal: ${err.message}`
: 'Failed to approve proposal',
);
}
};
// formatTimeToThreshold collapses a "seconds until threshold breach"
// value into operator-readable copy. Mirrors forecast service phrasing
// ("3 days", "12 hours") so the proposal card and the existing forecast
// overview don't drift in tone.
const formatTimeToThreshold = (seconds?: number | null): string => {
if (!seconds || seconds <= 0) {
return '';
}
const hours = seconds / 3600;
if (hours < 1) {
return `${Math.max(1, Math.round(seconds / 60))} min`;
}
if (hours < 48) {
return `${Math.round(hours)} hr`;
}
return `${Math.round(hours / 24)} days`;
};
// Map of finding_id -> latest remediation plan artifact
const plansByFindingId = createMemo(() => {
const dismissedPlanIdsSet = new Set(dismissedPlanIds());
@@ -1677,87 +1716,213 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
{/* Remediation Plan artifact (generated by Patrol and/or an investigation) */}
<Show when={finding.status === 'active' && plansByFindingId().get(finding.id)}>
{(plan) => (
<div class="mt-3 pt-3 border-t border-border-subtle">
{(() => {
const planRisk = getApprovalRiskPresentation(plan().risk_level);
return (
<>
<div class="flex items-center gap-2 mb-2">
<svg
class="w-4 h-4 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<span class="text-sm font-medium text-base-content">Remediation Plan</span>
<span
class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${planRisk.badgeClass}`}
>
{planRisk.label} risk
</span>
</div>
<div class="space-y-2">
<For each={plan().steps}>
{(step) => (
<div class="flex items-start gap-2 text-sm">
<span class="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-full bg-surface-hover text-xs font-medium text-muted">
{step.order}
</span>
<div class="flex-1 min-w-0">
<div class="text-base-content">{step.action}</div>
<Show when={step.command}>
<div class="mt-1 font-mono text-[11px] whitespace-pre-wrap break-words text-muted bg-surface-alt px-2 py-1 rounded">
{step.command}
{(plan) => {
// Capacity-forecast proposal variant - rendered when patrol
// attached a deterministic forecast-driven ActionPlan via the
// template registry in internal/ai/forecast. Falls back to the
// generic remediation plan card when no proposal is attached
// or when source/category don't match.
const proposal = () => plan().proposed_action_plan;
const isCapacityForecastProposal = () =>
finding.category === 'capacity' &&
proposal()?.source === 'capacity_forecast';
return (
<div class="mt-3 pt-3 border-t border-border-subtle">
<Show
when={isCapacityForecastProposal()}
fallback={(() => {
const planRisk = getApprovalRiskPresentation(plan().risk_level);
return (
<>
<div class="flex items-center gap-2 mb-2">
<svg
class="w-4 h-4 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<span class="text-sm font-medium text-base-content">Remediation Plan</span>
<span
class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${planRisk.badgeClass}`}
>
{planRisk.label} risk
</span>
</div>
<div class="space-y-2">
<For each={plan().steps}>
{(step) => (
<div class="flex items-start gap-2 text-sm">
<span class="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-full bg-surface-hover text-xs font-medium text-muted">
{step.order}
</span>
<div class="flex-1 min-w-0">
<div class="text-base-content">{step.action}</div>
<Show when={step.command}>
<div class="mt-1 font-mono text-[11px] whitespace-pre-wrap break-words text-muted bg-surface-alt px-2 py-1 rounded">
{step.command}
</div>
</Show>
</div>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<button
type="button"
onClick={(e) => handleOpenPlanInAssistant(finding, plan(), e)}
class="flex-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded flex items-center justify-center gap-1.5"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 8h10M7 12h10M7 16h10"
/>
</svg>
Open In Assistant
</button>
<button
type="button"
onClick={(e) => handleDismissPlan(plan(), e)}
class="px-3 py-1.5 hover:bg-surface-hover text-muted text-xs font-medium rounded"
>
Dismiss
</button>
</div>
</>
);
})()}
</div>
)}
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<button
type="button"
onClick={(e) => handleOpenPlanInAssistant(finding, plan(), e)}
class="flex-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded flex items-center justify-center gap-1.5"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 8h10M7 12h10M7 16h10"
/>
</svg>
Open In Assistant
</button>
<button
type="button"
onClick={(e) => handleDismissPlan(plan(), e)}
class="px-3 py-1.5 hover:bg-surface-hover text-muted text-xs font-medium rounded"
>
Dismiss
</button>
</div>
</>
);
})()}
>
{(() => {
const p = proposal()!;
const metric = p.projectedMetric;
const preflight = p.preflight;
const ttbLabel = formatTimeToThreshold(metric?.timeToThresholdSeconds);
return (
<div data-testid="capacity-forecast-approval-card">
<div class="flex items-center gap-2 mb-2">
<svg
class="w-4 h-4 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 17l6-6 4 4 8-8M21 7v6h-6"
/>
</svg>
<span class="text-sm font-medium text-base-content">
Capacity-forecast proposal
</span>
<span class="px-1.5 py-0.5 text-[10px] font-medium rounded border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-300">
requires approval
</span>
<Show when={p.allowed === false}>
<span class="px-1.5 py-0.5 text-[10px] font-medium rounded bg-surface-alt text-muted">
preflight only
</span>
</Show>
</div>
<Show when={metric}>
<div class="text-xs text-muted mb-2">
<span class="font-medium text-base-content">
{metric!.currentValue.toFixed(1)}%
</span>{' '}
now
<Show
when={
typeof metric!.predictedValue === 'number' &&
metric!.predictedValue !== metric!.currentValue
}
>
{' '}
<span aria-hidden="true">→</span>{' '}
<span class="font-medium text-base-content">
{metric!.predictedValue!.toFixed(1)}%
</span>{' '}
projected
</Show>
<Show when={typeof metric!.thresholdValue === 'number' && metric!.thresholdValue! > 0}>
{' '}· threshold {metric!.thresholdValue!.toFixed(0)}%
</Show>
<Show when={ttbLabel}>
{' '}· breach in {ttbLabel}
</Show>
</div>
</Show>
<Show when={p.message}>
<p class="text-sm text-base-content mb-2">{p.message}</p>
</Show>
<Show when={preflight}>
<Show when={preflight!.intendedChange}>
<div class="text-xs text-muted mb-1">
<span class="font-medium text-base-content">Proposed change:</span>{' '}
{preflight!.intendedChange}
</div>
</Show>
<Show when={preflight!.safetyChecks && preflight!.safetyChecks!.length > 0}>
<ul class="text-[11px] text-muted list-disc list-inside space-y-0.5 mb-2">
<For each={preflight!.safetyChecks!}>
{(check) => <li>{check}</li>}
</For>
</ul>
</Show>
</Show>
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<button
type="button"
onClick={(e) => handleApproveProposedPlan(plan(), e)}
class="flex-1 px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded flex items-center justify-center gap-1.5"
data-testid="capacity-forecast-approve"
>
Approve proposal
</button>
<button
type="button"
onClick={(e) => handleOpenPlanInAssistant(finding, plan(), e)}
class="px-3 py-1.5 border border-border hover:bg-surface-hover text-base-content text-xs font-medium rounded"
>
Discuss
</button>
<button
type="button"
onClick={(e) => handleDismissPlan(plan(), e)}
class="px-3 py-1.5 hover:bg-surface-hover text-muted text-xs font-medium rounded"
data-testid="capacity-forecast-reject"
>
Reject
</button>
</div>
</div>
);
})()}
</Show>
</div>
);
}}
</Show>
</div>
);
@@ -0,0 +1,304 @@
import { fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import type { JSX } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FindingsPanel } from '../FindingsPanel';
// Pin the registry constant locally so the tests fail loudly if Go renames
// the source on the wire (see internal/ai/forecast.CapacityActionPlanSource).
const CAPACITY_FORECAST_SOURCE = 'capacity_forecast';
type RemediationPlanFixture = {
id: string;
finding_id: string;
resource_id: string;
title: string;
description: string;
steps: Array<{ order: number; action: string; risk_level: 'low' | 'medium' | 'high' }>;
risk_level: 'low' | 'medium' | 'high';
status: 'pending' | 'approved';
created_at: string;
proposed_action_plan?: {
actionId: string;
allowed: boolean;
requiresApproval: boolean;
source?: string;
message?: string;
projectedMetric?: {
metric: string;
currentValue: number;
predictedValue?: number;
thresholdValue?: number;
timeToThresholdSeconds?: number | null;
};
preflight?: {
intendedChange?: string;
dryRunAvailable: boolean;
safetyChecks?: string[];
};
};
};
const baseStep = { order: 1, action: 'Investigate', risk_level: 'low' as const };
function makeFinding(overrides: Record<string, unknown>) {
return {
id: 'finding-cap',
source: 'ai-patrol',
resourceId: 'node-a/storage/tank',
resourceName: 'tank',
resourceType: 'storage',
category: 'capacity',
severity: 'warning',
title: 'Storage pool tank at 87.3% usage',
description: 'Tank pool exceeded warning threshold.',
detectedAt: '2026-04-01T10:00:00Z',
lastSeenAt: '2026-04-01T10:05:00Z',
status: 'active',
...overrides,
};
}
function makePlan(findingId: string, withProposal: boolean): RemediationPlanFixture {
const plan: RemediationPlanFixture = {
id: `plan-${findingId}`,
finding_id: findingId,
resource_id: 'node-a/storage/tank',
title: 'Fix: Storage pool tank at 87.3% usage',
description: 'Tank pool exceeded warning threshold.',
steps: [baseStep],
risk_level: 'medium',
status: 'pending',
created_at: '2026-04-01T10:05:00Z',
};
if (withProposal) {
plan.proposed_action_plan = {
actionId: 'capacity-forecast-abc',
allowed: false,
requiresApproval: true,
source: CAPACITY_FORECAST_SOURCE,
message: 'Storage pool "tank" is at 87.3% usage. Propose: prune oldest auto-snapshots.',
projectedMetric: {
metric: 'usage_percent',
currentValue: 87.3,
predictedValue: 93.5,
thresholdValue: 75,
timeToThresholdSeconds: 36 * 3600,
},
preflight: {
intendedChange: 'Prune oldest auto-snapshots, then list largest reclaimable datasets.',
dryRunAvailable: false,
safetyChecks: [
'Operator must explicitly approve before any execution path is wired.',
'This proposal ships with Allowed=false; the action broker will refuse execution.',
],
},
};
}
return plan;
}
const mockState = vi.hoisted(() => {
const loadFindings = vi.fn();
const loadPatrolFindings = vi.fn();
const loadRemediationPlans = vi.fn();
const approveRemediationPlan = vi.fn().mockResolvedValue({ success: true });
return {
findings: [] as unknown[],
remediationPlans: [] as unknown[],
loadFindings,
loadPatrolFindings,
loadRemediationPlans,
approveRemediationPlan,
};
});
vi.mock('@solidjs/router', () => ({
A: (props: { href: string; children?: JSX.Element; [key: string]: unknown }) => (
<a href={props.href} aria-label={props['aria-label'] as string} onClick={props.onClick as any}>
{props.children}
</a>
),
useLocation: () => ({ hash: '' }),
}));
vi.mock('@/components/shared/Card', () => ({
Card: (props: { children?: JSX.Element }) => <div>{props.children}</div>,
}));
vi.mock('@/components/patrol', () => ({
InvestigationSection: () => null,
ApprovalSection: () => null,
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/stores/aiChat', () => ({
aiChatStore: {
openWithPrompt: vi.fn(),
},
}));
vi.mock('@/api/ai', () => ({
AIAPI: {
approveRemediationPlan: (...args: unknown[]) => mockState.approveRemediationPlan(...args),
},
}));
vi.mock('@/hooks/useResources', () => ({
useResources: () => ({
get: () => undefined,
}),
}));
vi.mock('@/stores/aiIntelligence', () => ({
aiIntelligenceStore: {
get findings() {
return mockState.findings;
},
get findingsLoading() {
return false;
},
get findingsError() {
return null;
},
get patrolFindings() {
return mockState.findings;
},
get patrolFindingsLoading() {
return false;
},
get patrolFindingsError() {
return null;
},
get findingsNeedingAttention() {
return [];
},
get patrolFindingsNeedingAttention() {
return [];
},
get findingsWithPendingApprovals() {
return [];
},
get patrolFindingsWithPendingApprovals() {
return [];
},
get remediationPlans() {
return mockState.remediationPlans;
},
findingsSignal: () => mockState.findings,
patrolFindingsSignal: () => mockState.findings,
loadFindings: mockState.loadFindings,
loadPatrolFindings: mockState.loadPatrolFindings,
loadRemediationPlans: mockState.loadRemediationPlans,
},
}));
beforeEach(() => {
mockState.findings = [];
mockState.remediationPlans = [];
mockState.loadFindings.mockClear();
mockState.loadPatrolFindings.mockClear();
mockState.loadRemediationPlans.mockClear();
mockState.approveRemediationPlan.mockClear();
if (typeof window.requestAnimationFrame !== 'function') {
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
}
});
afterEach(() => {
vi.clearAllMocks();
});
describe('FindingsPanel capacity-forecast approval card', () => {
it('renders the capacity-forecast card when a capacity finding has a forecast-driven proposal', async () => {
const finding = makeFinding({ id: 'finding-cap-1' });
mockState.findings = [finding];
mockState.remediationPlans = [makePlan(finding.id, true)];
render(() => <FindingsPanel findingsSource="patrol" />);
await waitFor(() => expect(mockState.loadRemediationPlans).toHaveBeenCalled());
fireEvent.click(screen.getByText('Storage pool tank at 87.3% usage'));
const card = screen.getByTestId('capacity-forecast-approval-card');
expect(card).toBeInTheDocument();
expect(within(card).getByText(/Capacity-forecast proposal/i)).toBeInTheDocument();
expect(within(card).getByText(/requires approval/i)).toBeInTheDocument();
expect(within(card).getByText(/preflight only/i)).toBeInTheDocument();
// Operator-facing snapshot must surface current/predicted/threshold and TTB.
// Current value appears in two places inside the card: the projected
// metric snapshot and the proposal message. Both are intentional - the
// first lets the operator see the projection at a glance, the second
// restates it inside the rationale paragraph. Use *AllBy* to match
// both without locking the exact placement.
expect(within(card).getAllByText(/87\.3%/).length).toBeGreaterThanOrEqual(1);
expect(within(card).getAllByText(/93\.5%/).length).toBeGreaterThanOrEqual(1);
expect(within(card).getByText(/threshold 75%/)).toBeInTheDocument();
expect(within(card).getByText(/breach in/i)).toBeInTheDocument();
// The generic "Open In Assistant" affordance must NOT appear when the
// forecast variant is active - the generic card is gated by fallback.
expect(screen.queryByText('Open In Assistant')).not.toBeInTheDocument();
});
it('renders the generic remediation plan card when a capacity finding has no proposal attached', async () => {
const finding = makeFinding({ id: 'finding-cap-2' });
mockState.findings = [finding];
mockState.remediationPlans = [makePlan(finding.id, false)];
render(() => <FindingsPanel findingsSource="patrol" />);
await waitFor(() => expect(mockState.loadRemediationPlans).toHaveBeenCalled());
fireEvent.click(screen.getByText('Storage pool tank at 87.3% usage'));
expect(screen.queryByTestId('capacity-forecast-approval-card')).not.toBeInTheDocument();
expect(screen.getByText('Open In Assistant')).toBeInTheDocument();
});
it('never renders the capacity-forecast card for a non-capacity finding even if a proposal somehow attaches', async () => {
// This case should not occur in production - the wire-in only attaches
// proposals to capacity findings - but the frontend gate must stay
// closed regardless. A drifted backend that mis-categorises a finding
// must not leak the capacity-forecast card onto the wrong finding type.
const finding = makeFinding({
id: 'finding-perf-1',
category: 'performance',
title: 'High CPU on appserver: 92.0%',
});
mockState.findings = [finding];
mockState.remediationPlans = [makePlan(finding.id, true)];
render(() => <FindingsPanel findingsSource="patrol" />);
await waitFor(() => expect(mockState.loadRemediationPlans).toHaveBeenCalled());
fireEvent.click(screen.getByText('High CPU on appserver: 92.0%'));
expect(screen.queryByTestId('capacity-forecast-approval-card')).not.toBeInTheDocument();
expect(screen.getByText('Open In Assistant')).toBeInTheDocument();
});
it('routes Approve through AIAPI.approveRemediationPlan and Reject through dismiss without bypassing handlers', async () => {
const finding = makeFinding({ id: 'finding-cap-3' });
mockState.findings = [finding];
mockState.remediationPlans = [makePlan(finding.id, true)];
render(() => <FindingsPanel findingsSource="patrol" />);
await waitFor(() => expect(mockState.loadRemediationPlans).toHaveBeenCalled());
fireEvent.click(screen.getByText('Storage pool tank at 87.3% usage'));
fireEvent.click(screen.getByTestId('capacity-forecast-approve'));
await waitFor(() => expect(mockState.approveRemediationPlan).toHaveBeenCalledWith('plan-finding-cap-3'));
});
});
@@ -236,6 +236,41 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain('text-amber-600 dark:text-amber-400');
});
it('renders a distinct capacity-forecast approval card when a forecast-driven proposal is attached', () => {
// The capacity-forecast card variant is only rendered when the
// RemediationPlan carries a proposed_action_plan with
// source === 'capacity_forecast' AND the finding category is
// 'capacity'. Pin the wiring so the variant doesn't accidentally take
// over the generic remediation plan card.
expect(findingsPanelSource).toContain("finding.category === 'capacity'");
expect(findingsPanelSource).toContain("proposal()?.source === 'capacity_forecast'");
expect(findingsPanelSource).toContain('isCapacityForecastProposal()');
expect(findingsPanelSource).toContain('data-testid="capacity-forecast-approval-card"');
// Card must surface current/projected/threshold so the operator can
// decide without digging into Patrol metrics.
expect(findingsPanelSource).toContain('metric!.currentValue.toFixed(1)');
expect(findingsPanelSource).toContain('metric!.predictedValue!.toFixed(1)');
expect(findingsPanelSource).toContain('metric!.thresholdValue!.toFixed(0)');
// Approval-gated by contract; the badge must communicate that even when
// the action is preflight-only (Allowed=false).
expect(findingsPanelSource).toContain('requires approval');
expect(findingsPanelSource).toContain('p.allowed === false');
expect(findingsPanelSource).toContain('preflight only');
// Must reuse the existing approval handlers - approve flows through
// AIAPI.approveRemediationPlan, reject flows through handleDismissPlan.
// No bypass / parallel state machine.
expect(findingsPanelSource).toContain('handleApproveProposedPlan');
expect(findingsPanelSource).toContain('AIAPI.approveRemediationPlan(plan.id)');
expect(findingsPanelSource).toContain('data-testid="capacity-forecast-approve"');
expect(findingsPanelSource).toContain('data-testid="capacity-forecast-reject"');
// The fallback path keeps the generic remediation plan card unchanged
// for non-capacity findings or capacity findings without a proposal -
// both must continue to read "Open In Assistant" / "Dismiss" as
// before, with no capacity-forecast affordances bleeding through.
expect(findingsPanelSource).toContain('fallback={(()');
expect(findingsPanelSource).toContain('Open In Assistant');
});
it('renders the operator-facing Impact line between Description and Recommendation', () => {
// The expanded finding card must surface Finding.Impact directly so
// detection-time consequence-if-ignored copy reaches the operator on the