From 2c81815f5fcae1a411f21e39efbb8ae93561516e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 12 May 2026 23:01:27 +0100 Subject: [PATCH] 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. --- frontend-modern/src/api/ai.ts | 45 +++ .../src/components/AI/FindingsPanel.tsx | 323 +++++++++++++----- .../FindingsPanel.capacityForecast.test.tsx | 304 +++++++++++++++++ .../AI/__tests__/FindingsPanel.test.ts | 35 ++ 4 files changed, 628 insertions(+), 79 deletions(-) create mode 100644 frontend-modern/src/components/AI/__tests__/FindingsPanel.capacityForecast.test.tsx diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index bf222cb95..ebdeac3c7 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -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 { diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index fba699144..bf8be0dfa 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -196,6 +196,45 @@ export const FindingsPanel: Component = (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 = (props) => { {/* Remediation Plan artifact (generated by Patrol and/or an investigation) */} - {(plan) => ( -
- {(() => { - const planRisk = getApprovalRiskPresentation(plan().risk_level); - return ( - <> -
- - - - Remediation Plan - - {planRisk.label} risk - -
-
- - {(step) => ( -
- - {step.order} - -
-
{step.action}
- -
- {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 ( +
+ { + const planRisk = getApprovalRiskPresentation(plan().risk_level); + return ( + <> +
+ + + + Remediation Plan + + {planRisk.label} risk + +
+
+ + {(step) => ( +
+ + {step.order} + +
+
{step.action}
+ +
+ {step.command} +
+
- -
-
- )} - -
+
+ )} + +
-
- - -
- - ); - })()} -
- )} +
+ + +
+ + ); + })()} + > + {(() => { + const p = proposal()!; + const metric = p.projectedMetric; + const preflight = p.preflight; + const ttbLabel = formatTimeToThreshold(metric?.timeToThresholdSeconds); + return ( +
+
+ + + Capacity-forecast proposal + + + requires approval + + + + preflight only + + +
+ + +
+ + {metric!.currentValue.toFixed(1)}% + {' '} + now + + {' '} + {' '} + + {metric!.predictedValue!.toFixed(1)}% + {' '} + projected + + 0}> + {' '}· threshold {metric!.thresholdValue!.toFixed(0)}% + + + {' '}· breach in {ttbLabel} + +
+
+ + +

{p.message}

+
+ + + +
+ Proposed change:{' '} + {preflight!.intendedChange} +
+
+ 0}> +
    + + {(check) =>
  • {check}
  • } +
    +
+
+
+ +
+ + + +
+
+ ); + })()} + +
+ ); + }}
); diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.capacityForecast.test.tsx b/frontend-modern/src/components/AI/__tests__/FindingsPanel.capacityForecast.test.tsx new file mode 100644 index 000000000..ff30a9b15 --- /dev/null +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.capacityForecast.test.tsx @@ -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) { + 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 }) => ( + + {props.children} + + ), + useLocation: () => ({ hash: '' }), +})); + +vi.mock('@/components/shared/Card', () => ({ + Card: (props: { children?: JSX.Element }) =>
{props.children}
, +})); + +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(() => ); + + 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(() => ); + + 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(() => ); + + 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(() => ); + + 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')); + }); +}); diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index adfe9686f..22a8ad6d0 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -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