mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-04 13:16:01 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user