diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2a29201ba..568c83aa5 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -78,7 +78,7 @@ Own canonical runtime payload shapes between backend and frontend. 4. Route unified resource sensitivity, routing, and `aiSafeSummary` payload changes through `internal/api/resources.go`, `internal/api/contract_test.go`, and the canonical frontend resource consumer proofs together; resource governance metadata must not ship as an API-only or frontend-only heuristic 5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape 6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection -7. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store state, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice +7. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store state, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice while keeping the learning counters backend-only coverage, so the summary page keeps Patrol health and findings primary and renders timeline, correlation, and policy-posture data as secondary investigation context rather than as a separate headline product metric and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` card, so canonical recent-change timelines stay rendered through one governed frontend card instead of separate page-local list loops and the shared `frontend-modern/src/utils/resourceChangePresentation.ts` formatter used by the summary page and resource drawer, so canonical change wording does not drift across surfaces diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index e1abae657..b917184cb 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -15,26 +15,27 @@ ## Purpose -Own the Patrol intelligence page, its local state orchestration, findings and -approval presentation, run-history rendering, and Patrol-specific presentation -helpers. +Own the Patrol intelligence route shell, feature surface, local state +orchestration, findings and approval presentation, run-history rendering, and +Patrol-specific presentation helpers. ## Canonical Files -1. `frontend-modern/src/pages/AIIntelligence.tsx` -2. `frontend-modern/src/stores/aiIntelligence.ts` -3. `frontend-modern/src/types/aiIntelligence.ts` -4. `frontend-modern/src/components/AI/FindingsPanel.tsx` -5. `frontend-modern/src/components/Brand/PulsePatrolLogo.tsx` -6. `frontend-modern/src/components/patrol/` -7. `frontend-modern/src/utils/aiFindingPresentation.ts` -8. `frontend-modern/src/utils/approvalRiskPresentation.ts` -9. `frontend-modern/src/utils/findingAlertIdentity.ts` -10. `frontend-modern/src/utils/patrolEmptyStatePresentation.ts` -11. `frontend-modern/src/utils/patrolFormat.ts` -12. `frontend-modern/src/utils/patrolRunPresentation.ts` -13. `frontend-modern/src/utils/patrolSummaryPresentation.ts` -14. `frontend-modern/src/utils/textPresentation.ts` +1. `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` +2. `frontend-modern/src/pages/AIIntelligence.tsx` +3. `frontend-modern/src/stores/aiIntelligence.ts` +4. `frontend-modern/src/types/aiIntelligence.ts` +5. `frontend-modern/src/components/AI/FindingsPanel.tsx` +6. `frontend-modern/src/components/Brand/PulsePatrolLogo.tsx` +7. `frontend-modern/src/components/patrol/` +8. `frontend-modern/src/utils/aiFindingPresentation.ts` +9. `frontend-modern/src/utils/approvalRiskPresentation.ts` +10. `frontend-modern/src/utils/findingAlertIdentity.ts` +11. `frontend-modern/src/utils/patrolEmptyStatePresentation.ts` +12. `frontend-modern/src/utils/patrolFormat.ts` +13. `frontend-modern/src/utils/patrolRunPresentation.ts` +14. `frontend-modern/src/utils/patrolSummaryPresentation.ts` +15. `frontend-modern/src/utils/textPresentation.ts` ## Shared Boundaries @@ -42,7 +43,7 @@ helpers. ## Extension Points -1. Add or change Patrol page orchestration through `frontend-modern/src/pages/AIIntelligence.tsx` and `frontend-modern/src/stores/aiIntelligence.ts` +1. Add or change Patrol page orchestration through `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, keep `frontend-modern/src/pages/AIIntelligence.tsx` as the route shell, and update `frontend-modern/src/stores/aiIntelligence.ts` together 2. Add or change Patrol findings, approvals, investigation, or run-history presentation through `frontend-modern/src/components/AI/FindingsPanel.tsx` and `frontend-modern/src/components/patrol/` 3. Keep Patrol and chat identifier-label presentation aligned through the shared `frontend-modern/src/utils/textPresentation.ts` 4. Keep Patrol and chat stream-matching / mention dedupe aligned through the shared `frontend-modern/src/utils/chatIdentifiers.ts` @@ -68,6 +69,12 @@ surface for Patrol intelligence. This contract now owns that orchestration and presentation boundary while leaving shared transport and payload-shape ownership in the governed AI runtime and API contract surfaces. +The route file `frontend-modern/src/pages/AIIntelligence.tsx` is now also a +thin shell that delegates to the feature-owned +`frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, so Patrol +runtime state and presentation no longer accumulate directly in the route +component itself. + Patrol finding state must now also consume the canonical camelCase `alertIdentifier` field and pending-approval expiry metadata end to end. Frontend Patrol helpers may not keep shadow `alert_identifier` fallbacks or diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index 8dc9a7bde..9d33f9094 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -19,6 +19,8 @@ import infrastructureSelectorComponentSource from '@/components/shared/Infrastru import workloadsLinkSource from '@/components/Infrastructure/workloadsLink.ts?raw'; import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResourceTable.tsx?raw'; import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw'; +import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurationSurface.tsx?raw'; +import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw'; import collapsibleSectionSource from '@/components/Alerts/Thresholds/sections/CollapsibleSection.tsx?raw'; import alertThresholdsPresentationSource from '@/utils/alertThresholdsPresentation.ts?raw'; import alertThresholdsSectionPresentationSource from '@/utils/alertThresholdsSectionPresentation.ts?raw'; @@ -29,6 +31,7 @@ import reportingPanelSource from '../ReportingPanel.tsx?raw'; import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw'; import suggestProfileModalSource from '../SuggestProfileModal.tsx?raw'; import aiIntelligenceSource from '@/pages/AIIntelligence.tsx?raw'; +import patrolIntelligenceSurfaceSource from '@/features/patrol/PatrolIntelligenceSurface.tsx?raw'; import aiPatrolSchedulePresentationSource from '@/utils/aiPatrolSchedulePresentation.ts?raw'; import patrolSummaryPresentationSource from '@/utils/patrolSummaryPresentation.ts?raw'; import aiCostDashboardSource from '@/components/AI/AICostDashboard.tsx?raw'; @@ -394,10 +397,17 @@ describe('monitored-system model guardrails', () => { expect(reportingPanelSource).not.toContain(""); expect(reportingPresentationSource).toContain('export const REPORTING_RANGE_OPTIONS'); expect(reportingPresentationSource).not.toContain('getReportingToggleButtonClass'); - expect(aiIntelligenceSource).toContain('buildPatrolScheduleOptions'); - expect(aiIntelligenceSource).toContain('PATROL_NO_ISSUES_LABEL'); + expect(aiIntelligenceSource).toContain( + "import { PatrolIntelligenceSurface } from '@/features/patrol/PatrolIntelligenceSurface';", + ); + expect(aiIntelligenceSource).not.toContain('buildPatrolScheduleOptions'); + expect(aiIntelligenceSource).not.toContain('PATROL_NO_ISSUES_LABEL'); + expect(patrolIntelligenceSurfaceSource).toContain('buildPatrolScheduleOptions'); + expect(patrolIntelligenceSurfaceSource).toContain('PATROL_NO_ISSUES_LABEL'); expect(aiIntelligenceSource).not.toContain('No issues found'); + expect(patrolIntelligenceSurfaceSource).not.toContain('No issues found'); expect(aiIntelligenceSource).not.toContain('const SCHEDULE_PRESETS ='); + expect(patrolIntelligenceSurfaceSource).not.toContain('const SCHEDULE_PRESETS ='); expect(aiPatrolSchedulePresentationSource).toContain('export const PATROL_SCHEDULE_PRESETS'); expect(aiPatrolSchedulePresentationSource).toContain( 'export function buildPatrolScheduleOptions', @@ -524,14 +534,21 @@ describe('monitored-system model guardrails', () => { }); it('keeps alerts agent thresholds sourced from unified agent resources', () => { - expect(alertsPageSource).toContain('const agentResources = createMemo('); - expect(alertsPageSource).toContain('agents={agentResources()}'); - expect(alertsPageSource).toContain("resourceType: 'Agent Disk'"); - expect(alertsPageSource).not.toContain("resourceType: 'Host Disk'"); - expect(alertsPageSource).toContain('agentDefaults'); - expect(alertsPageSource).toContain('disableAllAgents'); - expect(alertsPageSource).not.toContain('hostDefaults'); - expect(alertsPageSource).not.toContain('disableAllHosts'); + expect(alertsPageSource).toContain( + "import { AlertsConfigurationSurface } from '@/features/alerts/AlertsConfigurationSurface';", + ); + expect(alertsPageSource).not.toContain('const agentResources = createMemo('); + expect(alertsPageSource).not.toContain("resourceType: 'Agent Disk'"); + expect(alertsConfigurationSurfaceSource).toContain(' { @@ -602,7 +619,11 @@ describe('monitored-system model guardrails', () => { expect(aiChatSource).not.toContain('const normalizeControlLevel ='); expect(aiChatSource).not.toContain('const labelForControlLevel ='); expect(aiChatSource).not.toContain('const controlTone ='); - expect(aiChatSource).toContain( + expect(aiChatSource).toContain('const mentionsForAPI ='); + expect(aiChatSource).toContain('? mentions.map((mention) => ({'); + expect(aiChatSource).toContain('name: mention.label,'); + expect(aiChatSource).toContain('type: mention.type,'); + expect(aiChatSource).not.toContain( 'const mentionsForAPI = mentions.length > 0 ? mentions : undefined;', ); expect(aiChatPresentationSource).toContain('export const AI_CHAT_SESSION_EMPTY_STATE'); @@ -862,10 +883,10 @@ describe('monitored-system model guardrails', () => { "if (path.includes('/thresholds/containers')) return 'docker';", ); expect(thresholdsTableSource).toContain('/thresholds/agents'); - expect(thresholdsTableSource).toContain("resourceType: 'Agent Disk'"); + expect(thresholdsDataHookSource).toContain("resourceType: 'Agent Disk'"); expect(thresholdsTableSource).toContain('getAlertThresholdsSectionTitles'); expect(thresholdsTableSource).toContain('title={sectionTitles.agentDisks}'); - expect(thresholdsTableSource).not.toContain("resourceType: 'Host Disk'"); + expect(thresholdsDataHookSource).not.toContain("resourceType: 'Host Disk'"); expect(thresholdsTableSource).toContain('props.agentDefaults'); expect(thresholdsTableSource).not.toContain('props.hostDefaults'); expect(thresholdsTableSource).not.toContain('timeThresholds().host'); diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx new file mode 100644 index 000000000..a464dbe73 --- /dev/null +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -0,0 +1,1522 @@ +/** + * Patrol Intelligence Surface + * + * Canonical Patrol runtime surface for findings, runs, and patrol controls. + */ + +import { + createSignal, + createEffect, + onMount, + onCleanup, + createMemo, + createResource, + For, + Show, +} from 'solid-js'; +import { aiIntelligenceStore } from '@/stores/aiIntelligence'; +import { FindingsPanel } from '@/components/AI/FindingsPanel'; +import { + getPatrolStatus, + getPatrolAutonomySettings, + updatePatrolAutonomySettings, + triggerPatrolRun, + getPatrolRunHistory, + type PatrolStatus, + type PatrolAutonomyLevel, + type PatrolRunRecord, +} from '@/api/patrol'; +import { apiFetchJSON } from '@/utils/apiClient'; +import { notificationStore } from '@/stores/notifications'; +import { hasTriggeringAlert } from '@/utils/findingAlertIdentity'; +import { getFindingSeverityToneClasses } from '@/utils/aiFindingPresentation'; +import { + getPatrolSummaryPresentation, + PATROL_NO_ISSUES_LABEL, +} from '@/utils/patrolSummaryPresentation'; + +interface ModelInfo { + id: string; + name: string; + description: string; + notable: boolean; +} + +interface AISettings { + patrol_model?: string; + patrol_interval_minutes?: number; + patrol_enabled?: boolean; + model?: string; + alert_triggered_analysis?: boolean; + patrol_event_triggers_enabled?: boolean; + patrol_auto_fix?: boolean; + auto_fix_model?: string; +} + +import ActivityIcon from 'lucide-solid/icons/activity'; +import ShieldAlertIcon from 'lucide-solid/icons/shield-alert'; +import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; +import PlayIcon from 'lucide-solid/icons/play'; +import CircleHelpIcon from 'lucide-solid/icons/circle-help'; +import XIcon from 'lucide-solid/icons/x'; + +import SparklesIcon from 'lucide-solid/icons/sparkles'; +import CheckCircleIcon from 'lucide-solid/icons/check-circle'; +import SettingsIcon from 'lucide-solid/icons/settings'; +import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo'; +import { PageHeader } from '@/components/shared/PageHeader'; +import { TogglePrimitive, Toggle } from '@/components/shared/Toggle'; +import { + ApprovalBanner, + PatrolStatusBar, + RunHistoryPanel, + CountdownTimer, +} from '@/components/patrol'; +import { usePatrolStream } from '@/hooks/usePatrolStream'; +import { + getUpgradeActionUrlOrFallback, + hasFeature, + licenseStatus, + loadLicenseStatus, + startProTrial, +} from '@/stores/license'; +import { formatRelativeTime } from '@/utils/format'; +import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; +import { + formatTriggerReason, + getCanonicalScopeResourceIds, + groupModelsByProvider, +} from '@/utils/patrolFormat'; +import { getAIQuickstartCreditsPresentation } from '@/utils/aiQuickstartPresentation'; +import { buildPatrolScheduleOptions } from '@/utils/aiPatrolSchedulePresentation'; +import { ResourcePolicySummary } from '@/components/Infrastructure/ResourcePolicySummary'; +import { ResourceCorrelationSummary } from '@/components/Infrastructure/ResourceCorrelationSummary'; +import { ResourceChangeSummary } from '@/components/Infrastructure/ResourceChangeSummary'; +import { + getProTrialStartedMessage, + getTrialAlreadyUsedMessage, + getTrialStartErrorMessage, + getTrialTryAgainLaterMessage, +} from '@/utils/upgradePresentation'; +type PatrolTab = 'findings' | 'history'; + +export function PatrolIntelligenceSurface() { + const [activeTab, setActiveTab] = createSignal('findings'); + const [showInvestigationContext, setShowInvestigationContext] = createSignal(false); + const [findingsFilterOverride, setFindingsFilterOverride] = createSignal< + 'all' | 'active' | 'resolved' | 'approvals' | 'attention' | undefined + >(undefined); + const [isRefreshing, setIsRefreshing] = createSignal(false); + const [autonomyLevel, setAutonomyLevel] = createSignal('monitor'); + const [isUpdatingAutonomy, setIsUpdatingAutonomy] = createSignal(false); + + // Trigger to refresh patrol activity visualizations + const [activityRefreshTrigger, setActivityRefreshTrigger] = createSignal(0); + + // Optimistic running state — set immediately on "Run Patrol" click to avoid race with backend + const [manualRunRequested, setManualRunRequested] = createSignal(false); + const [patrolEnabledLocal, setPatrolEnabledLocal] = createSignal(true); + const [liveRunStartedAt, setLiveRunStartedAt] = createSignal(''); + + // Safety timer ref — hoisted so onStart can clear it when SSE connects + let safetyTimerRef: ReturnType | undefined; + let scrollToFindingTimerRef: ReturnType | undefined; + let findingScrollTimerRef: ReturnType | undefined; + + const clearSafetyTimer = () => { + if (safetyTimerRef !== undefined) { + clearTimeout(safetyTimerRef); + safetyTimerRef = undefined; + } + }; + + const clearScrollToFindingTimer = () => { + if (scrollToFindingTimerRef !== undefined) { + clearTimeout(scrollToFindingTimerRef); + scrollToFindingTimerRef = undefined; + } + }; + + // Fetch patrol status (license_required reflects auto-fix, not patrol access) + const [patrolStatus, { refetch: refetchPatrolStatus }] = createResource( + async () => { + try { + return await getPatrolStatus(); + } catch { + return null; + } + }, + ); + + // Live patrol streaming + const patrolStream = usePatrolStream({ + running: () => + patrolEnabledLocal() && ((patrolStatus()?.running ?? false) || manualRunRequested()), + onStart: () => { + // SSE connected — clear the safety timeout + clearSafetyTimer(); + }, + onComplete: () => { + setManualRunRequested(false); + loadAllData(); + }, + onError: () => { + setManualRunRequested(false); + loadAllData(); + }, + }); + + // Advanced autonomy settings + const [investigationBudget, setInvestigationBudget] = createSignal(15); + const [investigationTimeout, setInvestigationTimeout] = createSignal(300); + const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false); + const [isSavingAdvanced, setIsSavingAdvanced] = createSignal(false); + const [fullModeUnlocked, setFullModeUnlocked] = createSignal(false); + let advancedSettingsRef: HTMLDivElement | undefined; + let patrolModelSelectRef: HTMLSelectElement | undefined; + + // Close popover when clicking outside + const handleClickOutside = (e: MouseEvent) => { + if (advancedSettingsRef && !advancedSettingsRef.contains(e.target as Node)) { + setShowAdvancedSettings(false); + } + }; + + createEffect(() => { + if (showAdvancedSettings()) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + }); + + onCleanup(() => { + document.removeEventListener('mousedown', handleClickOutside); + clearSafetyTimer(); + clearScrollToFindingTimer(); + }); + + // AI settings state + const [availableModels, setAvailableModels] = createSignal([]); + const [patrolModel, setPatrolModel] = createSignal(''); + const [defaultModel, setDefaultModel] = createSignal(''); + const [patrolInterval, setPatrolInterval] = createSignal(360); + const [isUpdatingSettings, setIsUpdatingSettings] = createSignal(false); + const [isTogglingPatrol, setIsTogglingPatrol] = createSignal(false); + const [isTriggeringPatrol, setIsTriggeringPatrol] = createSignal(false); + const [alertTriggeredAnalysis, setAlertTriggeredAnalysis] = createSignal(false); + const [patrolEventTriggers, setPatrolEventTriggers] = createSignal(true); + const [startingTrial, setStartingTrial] = createSignal(false); + const quickstartPresentation = createMemo(() => + getAIQuickstartCreditsPresentation( + patrolStatus()?.quickstart_credits_remaining ?? 0, + patrolStatus()?.quickstart_credits_total ?? 0, + ), + ); + const criticalSummaryPresentation = createMemo(() => + getPatrolSummaryPresentation('critical', summaryStats().criticalFindings > 0), + ); + const warningSummaryPresentation = createMemo(() => + getPatrolSummaryPresentation('warning', summaryStats().warningFindings > 0), + ); + const fixedSummaryPresentation = createMemo(() => + getPatrolSummaryPresentation('success', summaryStats().fixedCount > 0), + ); + + const canStartTrial = createMemo(() => { + const state = licenseStatus()?.subscription_state; + if (!state) return false; + return state !== 'active' && state !== 'trial'; + }); + + const handleStartTrial = async () => { + if (startingTrial()) return; + setStartingTrial(true); + try { + const result = await startProTrial(); + if (result?.outcome === 'redirect') { + if (typeof window !== 'undefined') { + window.location.href = result.actionUrl; + } + return; + } + notificationStore.success(getProTrialStartedMessage()); + } catch (err) { + const statusCode = (err as { status?: number } | null)?.status; + if (statusCode === 409) { + notificationStore.error(getTrialAlreadyUsedMessage()); + } else if (statusCode === 429) { + notificationStore.error(getTrialTryAgainLaterMessage()); + } else { + notificationStore.error( + getTrialStartErrorMessage(err instanceof Error ? err.message : undefined, { + branded: true, + }), + ); + } + } finally { + setStartingTrial(false); + } + }; + + // Re-apply patrol model select value when models load after settings + // (select value is ignored by the browser if no matching option exists yet) + createEffect(() => { + const model = patrolModel(); + const models = availableModels(); + if (patrolModelSelectRef && models.length > 0 && model) { + patrolModelSelectRef.value = model; + } + }); + + // Detect when saved patrol model is no longer in the available models list + const patrolModelStale = createMemo(() => { + const model = patrolModel(); + const models = availableModels(); + if (!model || models.length === 0) return false; + return !models.some((m) => m.id === model); + }); + + // License feature gates + const alertAnalysisLocked = createMemo(() => !hasFeature('ai_alerts')); + const autoFixLocked = createMemo(() => !hasFeature('ai_autofix')); + const [selectedRun, setSelectedRun] = createSignal(null); + + const scheduleOptions = createMemo(() => { + return buildPatrolScheduleOptions(patrolInterval()); + }); + + // Load available models + async function loadModels() { + try { + const data = await apiFetchJSON<{ models: ModelInfo[] }>('/api/ai/models'); + setAvailableModels(data?.models || []); + } catch (err) { + console.error('Failed to load models:', err); + } + } + + // Load AI settings + async function loadAISettings() { + try { + const data = await apiFetchJSON('/api/settings/ai'); + if (!data) return; + setPatrolModel(data.patrol_model || ''); + setDefaultModel(data.model || ''); + setPatrolInterval(data.patrol_interval_minutes ?? 360); + setPatrolEnabledLocal(data.patrol_enabled ?? true); + setAlertTriggeredAnalysis(!alertAnalysisLocked() && data.alert_triggered_analysis !== false); + setPatrolEventTriggers(data.patrol_event_triggers_enabled !== false); + } catch (err) { + console.error('Failed to load AI settings:', err); + } + } + + // Toggle patrol on/off + async function handleTogglePatrol() { + if (isTogglingPatrol()) return; + setIsTogglingPatrol(true); + const previousValue = patrolEnabledLocal(); + const newValue = !previousValue; + setPatrolEnabledLocal(newValue); + if (!newValue) { + setManualRunRequested(false); + clearSafetyTimer(); + } + try { + const data = await apiFetchJSON('/api/settings/ai/update', { + method: 'PUT', + body: JSON.stringify({ patrol_enabled: newValue }), + }); + if (typeof data?.patrol_enabled === 'boolean') { + setPatrolEnabledLocal(data.patrol_enabled); + } else { + setPatrolEnabledLocal(newValue); + } + if (typeof data?.patrol_interval_minutes === 'number') { + setPatrolInterval(data.patrol_interval_minutes); + } + if (refetchPatrolStatus) { + refetchPatrolStatus(); + } + } catch (err) { + console.error('Failed to toggle patrol:', err); + setPatrolEnabledLocal(previousValue); // Rollback + notificationStore.error('Failed to toggle patrol'); + } finally { + setIsTogglingPatrol(false); + } + } + + async function handleRunPatrol() { + if ( + isTriggeringPatrol() || + !canTriggerPatrol() || + manualRunRequested() || + patrolStream.isStreaming() + ) + return; + setIsTriggeringPatrol(true); + setManualRunRequested(true); + + // Safety timeout: if SSE never connects within 15s, clear optimistic state. + // Cleared early via onStart callback when the SSE connection opens. + clearSafetyTimer(); + safetyTimerRef = setTimeout(() => { + safetyTimerRef = undefined; + if (manualRunRequested() && !patrolStream.isStreaming()) { + setManualRunRequested(false); + notificationStore.error('Patrol run did not start — connection timed out'); + loadAllData(); + } + }, 15000); + + try { + await triggerPatrolRun(); + await loadAllData(); + } catch (err) { + console.error('Failed to trigger patrol run:', err); + setManualRunRequested(false); + notificationStore.error('Failed to start patrol run'); + // Clear safety timer on API error + clearSafetyTimer(); + } finally { + setIsTriggeringPatrol(false); + } + } + + // Update patrol model + async function handleModelChange(modelId: string) { + if (isUpdatingSettings()) return; + setIsUpdatingSettings(true); + try { + await apiFetchJSON('/api/settings/ai/update', { + method: 'PUT', + body: JSON.stringify({ patrol_model: modelId }), + }); + setPatrolModel(modelId); + } catch (err) { + console.error('Failed to update patrol model:', err); + notificationStore.error('Failed to update patrol model'); + } finally { + setIsUpdatingSettings(false); + } + } + + // Update patrol interval + async function handleIntervalChange(minutes: number) { + if (isUpdatingSettings()) return; + setIsUpdatingSettings(true); + try { + await apiFetchJSON('/api/settings/ai/update', { + method: 'PUT', + body: JSON.stringify({ patrol_interval_minutes: minutes }), + }); + setPatrolInterval(minutes); + setPatrolEnabledLocal(minutes > 0); + // Refetch patrol status so the countdown timer reflects the new interval + refetchPatrolStatus(); + } catch (err) { + console.error('Failed to update patrol interval:', err); + notificationStore.error('Failed to update patrol schedule'); + } finally { + setIsUpdatingSettings(false); + } + } + + // Toggle alert-triggered analysis + async function handleAlertTriggeredAnalysisChange(enabled: boolean) { + if (isUpdatingSettings()) return; + setIsUpdatingSettings(true); + const previous = alertTriggeredAnalysis(); + setAlertTriggeredAnalysis(enabled); + try { + await apiFetchJSON('/api/settings/ai/update', { + method: 'PUT', + body: JSON.stringify({ alert_triggered_analysis: enabled }), + }); + } catch (err) { + console.error('Failed to update alert-triggered analysis:', err); + setAlertTriggeredAnalysis(previous); + notificationStore.error('Failed to update alert analysis setting'); + } finally { + setIsUpdatingSettings(false); + } + } + + // Toggle event-triggered patrols + async function handlePatrolEventTriggersChange(enabled: boolean) { + if (isUpdatingSettings()) return; + setIsUpdatingSettings(true); + const previous = patrolEventTriggers(); + setPatrolEventTriggers(enabled); + try { + await apiFetchJSON('/api/settings/ai/update', { + method: 'PUT', + body: JSON.stringify({ patrol_event_triggers_enabled: enabled }), + }); + } catch (err) { + console.error('Failed to update event-triggered patrols:', err); + setPatrolEventTriggers(previous); + notificationStore.error('Failed to update event triggers setting'); + } finally { + setIsUpdatingSettings(false); + } + } + + const [patrolRunHistory] = createResource( + () => activityRefreshTrigger(), + async () => { + try { + return await getPatrolRunHistory(30); + } catch (err) { + console.error('Failed to load patrol run history:', err); + return []; + } + }, + ); + + const licenseRequired = createMemo(() => patrolStatus()?.license_required ?? false); + const upgradeUrl = createMemo(() => getUpgradeActionUrlOrFallback('ai_autofix')); + const blockedReason = createMemo(() => patrolStatus()?.blocked_reason?.trim() ?? ''); + const blockedAt = createMemo(() => patrolStatus()?.blocked_at); + const showBlockedBanner = createMemo(() => patrolEnabledLocal() && !!blockedReason()); + const canTriggerPatrol = createMemo(() => patrolEnabledLocal() && !showBlockedBanner()); + const triggerPatrolDisabledReason = createMemo(() => { + if (!patrolEnabledLocal()) return 'Patrol is disabled'; + if (showBlockedBanner()) return blockedReason() || 'Patrol is paused'; + return ''; + }); + + createEffect((wasAutoFixLocked) => { + const isAutoFixLocked = autoFixLocked(); + if (isAutoFixLocked && !wasAutoFixLocked) { + trackPaywallViewed('ai_autofix', 'ai_intelligence'); + } + return isAutoFixLocked; + }, false); + + createEffect((wasAlertAnalysisLocked) => { + const isAlertAnalysisLocked = alertAnalysisLocked(); + if (isAlertAnalysisLocked && !wasAlertAnalysisLocked) { + trackPaywallViewed('ai_alerts', 'ai_intelligence'); + } + return isAlertAnalysisLocked; + }, false); + + createEffect((wasLicenseBannerVisible) => { + const isLicenseBannerVisible = licenseRequired() && !showBlockedBanner(); + if (isLicenseBannerVisible && !wasLicenseBannerVisible) { + trackPaywallViewed('ai_autofix', 'ai_intelligence_banner'); + } + return isLicenseBannerVisible; + }, false); + + const shouldShowLiveRun = createMemo( + () => + patrolEnabledLocal() && + ((patrolStatus()?.running ?? false) || manualRunRequested() || patrolStream.isStreaming()), + ); + + createEffect(() => { + if (shouldShowLiveRun()) { + if (!liveRunStartedAt()) { + setLiveRunStartedAt(new Date().toISOString()); + } + return; + } + if (liveRunStartedAt()) { + setLiveRunStartedAt(''); + } + }); + + const selectedRunFindingIds = createMemo(() => { + const run = selectedRun(); + if (!run) return undefined; + return run.finding_ids; + }); + + const selectedRunScopeResourceIds = createMemo(() => { + return getCanonicalScopeResourceIds(selectedRun()); + }); + + const intelligenceSummary = createMemo(() => aiIntelligenceStore.intelligenceSummary); + const policyPosture = createMemo(() => intelligenceSummary()?.policy_posture); + const correlationTotal = createMemo( + () => + aiIntelligenceStore.correlations?.count ?? + aiIntelligenceStore.correlations?.correlations?.length ?? + 0, + ); + const recentChangeCount = createMemo( + () => + intelligenceSummary()?.recent_changes_count ?? intelligenceSummary()?.recent_changes?.length ?? 0, + ); + const hasInvestigationContext = createMemo( + () => + recentChangeCount() > 0 || + correlationTotal() > 0 || + (policyPosture()?.total_resources ?? 0) > 0, + ); + const investigationContextSummary = createMemo(() => { + const parts: string[] = []; + if (recentChangeCount() > 0) { + parts.push( + `${recentChangeCount()} recent change${recentChangeCount() === 1 ? '' : 's'}`, + ); + } + if (correlationTotal() > 0) { + parts.push( + `${correlationTotal()} correlation${correlationTotal() === 1 ? '' : 's'}`, + ); + } + const governedResources = policyPosture()?.total_resources ?? 0; + if (governedResources > 0) { + parts.push( + `${governedResources} governed resource${governedResources === 1 ? '' : 's'}`, + ); + } + return parts.join(' · '); + }); + + // Live in-progress run entry for history list + const liveRunRecord = createMemo(() => { + if (!shouldShowLiveRun()) return null; + return { + id: '__live__', + started_at: liveRunStartedAt() || new Date().toISOString(), + completed_at: '', + duration_ms: 0, + type: 'full', + trigger_reason: 'manual', + resources_checked: 0, + nodes_checked: 0, + guests_checked: 0, + docker_checked: 0, + storage_checked: 0, + hosts_checked: 0, + pbs_checked: 0, + pmg_checked: 0, + kubernetes_checked: 0, + new_findings: 0, + existing_findings: 0, + rejected_findings: 0, + resolved_findings: 0, + auto_fix_count: 0, + findings_summary: '', + finding_ids: [], + error_count: 0, + status: 'healthy', + triage_flags: 0, + tool_call_count: 0, + }; + }); + + // Combined run history: live entry (if any) prepended to real history + const displayRunHistory = createMemo(() => { + const live = liveRunRecord(); + const history = patrolRunHistory() || []; + return live ? [live, ...history] : history; + }); + + // Load autonomy settings + async function loadAutonomySettings() { + try { + const settings = await getPatrolAutonomySettings(); + if (!settings) return; + // Clamp locally in case license state resolves before or after this load. + // The GET endpoint also clamps, but this prevents a confusing active+disabled + // visual state if the response is stale or the feature flag flips mid-session. + const effectiveLevel = + autoFixLocked() && settings.autonomy_level !== 'monitor' + ? 'monitor' + : settings.autonomy_level; + setAutonomyLevel(effectiveLevel); + setFullModeUnlocked(settings.full_mode_unlocked); + setInvestigationBudget(settings.investigation_budget); + setInvestigationTimeout(settings.investigation_timeout_sec); + } catch (err) { + console.error('Failed to load autonomy settings:', err); + } + } + + // Update autonomy level (optimistic UI) + // When user picks "Auto-fix" (assisted), the actual backend level depends on whether + // the "auto-fix critical issues" toggle is on — if so, we send 'full', otherwise 'assisted'. + async function handleAutonomyChange(level: PatrolAutonomyLevel) { + if (isUpdatingAutonomy()) return; + if (autoFixLocked() && (level === 'approval' || level === 'assisted')) return; + + const previousLevel = autonomyLevel(); + const effectiveLevel = level === 'assisted' && fullModeUnlocked() ? 'full' : level; + setAutonomyLevel(effectiveLevel); // Optimistic update + setIsUpdatingAutonomy(true); + + try { + await updatePatrolAutonomySettings({ + autonomy_level: effectiveLevel, + full_mode_unlocked: fullModeUnlocked(), + investigation_budget: investigationBudget(), + investigation_timeout_sec: investigationTimeout(), + }); + } catch (err) { + console.error('Failed to update autonomy:', err); + setAutonomyLevel(previousLevel); // Rollback on error + notificationStore.error((err as Error).message || 'Failed to update autonomy level'); + } finally { + setIsUpdatingAutonomy(false); + } + } + + // Save advanced settings + // When the "auto-fix critical issues" toggle changes, adjust the autonomy level: + // - Toggle on + currently assisted → switch to full + // - Toggle off + currently full → switch to assisted + async function saveAdvancedSettings() { + setIsSavingAdvanced(true); + try { + let effectiveLevel = autonomyLevel(); + const inAutoFix = effectiveLevel === 'assisted' || effectiveLevel === 'full'; + if (inAutoFix) { + effectiveLevel = fullModeUnlocked() ? 'full' : 'assisted'; + } + + const result = await updatePatrolAutonomySettings({ + autonomy_level: effectiveLevel, + full_mode_unlocked: fullModeUnlocked(), + investigation_budget: investigationBudget(), + investigation_timeout_sec: investigationTimeout(), + }); + // Update local state from server response (handles auto-downgrade) + if (result.settings) { + setAutonomyLevel(result.settings.autonomy_level); + setFullModeUnlocked(result.settings.full_mode_unlocked); + } + setShowAdvancedSettings(false); + } catch (err) { + console.error('Failed to save advanced settings:', err); + notificationStore.error('Failed to save advanced settings'); + } finally { + setIsSavingAdvanced(false); + } + } + + onMount(async () => { + await Promise.all([ + loadLicenseStatus(), + loadAllData(), + loadAutonomySettings(), + loadModels(), + loadAISettings(), + ]); + }); + + // Polling intervals — paused when tab is hidden to save resources + let refreshInterval: ReturnType; + let approvalPollInterval: ReturnType; + + function startPolling() { + clearInterval(refreshInterval); + clearInterval(approvalPollInterval); + refreshInterval = setInterval(() => loadAllData(), 60000); + // Approval polling: 10s interval for 5-min expiry approvals + approvalPollInterval = setInterval(() => aiIntelligenceStore.loadPendingApprovals(), 10000); + } + + function stopPolling() { + clearInterval(refreshInterval); + clearInterval(approvalPollInterval); + } + + onMount(() => { + startPolling(); + + const handleVisibility = () => { + if (document.hidden) { + stopPolling(); + } else { + // Refresh immediately on tab return, then resume polling + loadAllData(); + startPolling(); + } + }; + document.addEventListener('visibilitychange', handleVisibility); + onCleanup(() => document.removeEventListener('visibilitychange', handleVisibility)); + }); + onCleanup(() => { + stopPolling(); + if (safetyTimerRef !== undefined) { + clearTimeout(safetyTimerRef); + safetyTimerRef = undefined; + } + if (findingScrollTimerRef !== undefined) { + clearTimeout(findingScrollTimerRef); + findingScrollTimerRef = undefined; + } + }); + + async function loadAllData() { + setIsRefreshing(true); + try { + await Promise.all([aiIntelligenceStore.loadDashboardData(), refetchPatrolStatus()]); + // Trigger refresh of patrol status bar + setActivityRefreshTrigger((prev) => prev + 1); + } finally { + setIsRefreshing(false); + } + } + + function summaryStats() { + const allFindings = aiIntelligenceStore.findings; + // Only count Patrol findings (exclude threshold alerts) + const patrolFindings = allFindings.filter( + (f) => f.source !== 'threshold' && !f.isThreshold && !hasTriggeringAlert(f), + ); + const activeFindings = patrolFindings.filter((f) => f.status === 'active'); + + const criticalCount = activeFindings.filter((f) => f.severity === 'critical').length; + const warningCount = activeFindings.filter((f) => f.severity === 'warning').length; + const totalActive = activeFindings.length; + const fixedCount = patrolFindings.filter( + (f) => + f.investigationOutcome === 'fix_verified' || + f.investigationOutcome === 'fix_executed' || + f.investigationOutcome === 'resolved', + ).length; + + return { + criticalFindings: criticalCount, + warningFindings: warningCount, + totalActive, + fixedCount, + hasAnyPatrolFindings: patrolFindings.length > 0, + }; + } + + return ( +
+ {/* Header */} +
+ + + Patrol + + } + description="Pulse Patrol monitoring and analysis" + class="mb-3" + actions={ +
+ + + + + + + +
+ } + /> + + {/* Settings row - Simplified for Enterprise Feel */} +
+ {/* Global Patrol Toggle */} +
+ + + {patrolEnabledLocal() ? 'Patrol Active' : 'Patrol Disabled'} + +
+ + {/* Quickstart Credits Badge */} + 0 && + patrolStatus()!.quickstart_credits_remaining !== undefined) + } + > +
+ 0} + fallback={{quickstartPresentation().summary}} + > + {quickstartPresentation().summary} + +
+
+ +
+ + {/* Configuration Popover */} +
+ + + +
+
+

+ Patrol Configuration +

+ +
+ +
+ {/* Model & Schedule grouped */} +
+
+ + +
+ +
+ + +
+
+ + {/* Operational Mode */} +
+
+ +
+ +
+ + {(level) => { + const isProLocked = () => + autoFixLocked() && (level === 'approval' || level === 'assisted'); + const isDisabled = () => !patrolEnabledLocal() || isProLocked(); + const isActive = () => + level === 'assisted' + ? autonomyLevel() === 'assisted' || autonomyLevel() === 'full' + : autonomyLevel() === level; + + return ( + + ); + }} + +
+ +
+ + Upgrade to Pro + {' '} + to unlock investigation and auto-fix. + + {' '} + + +
+
+
+ + {/* Toggles */} +
+
+
+ +

+ Analyze infrastructure automatically when critical alerts fire. +

+
+ + handleAlertTriggeredAnalysisChange(e.currentTarget.checked) + } + disabled={isUpdatingSettings() || alertAnalysisLocked()} + /> +
+ + +
+ + Upgrade + {' '} + to enable. + + + +
+
+ +
+
+ +

+ Run extra patrols when alerts fire or anomalies are detected. +

+
+ handlePatrolEventTriggersChange(e.currentTarget.checked)} + disabled={isUpdatingSettings() || !patrolEnabledLocal()} + /> +
+ +
+
+ +

+ Permit Patrol to execute critical remediations without approval. +

+
+ setFullModeUnlocked(e.currentTarget.checked)} + disabled={ + autoFixLocked() || + !(autonomyLevel() === 'assisted' || autonomyLevel() === 'full') + } + /> +
+
+ + {/* Save Footer */} +
+ +
+
+
+
+
+
+
+ + {/* Live patrol streaming status bar */} + +
+
+
+
+ Patrol running +
+ + {patrolStream.phase()} + + + + {patrolStream.currentTool()} + + + 0}> + + {patrolStream.tokens().toLocaleString()} tokens + + +
+
+ + + +
+
+

+ trackUpgradeClicked('ai_intelligence_banner', 'ai_autofix')} + > + Upgrade to Pro + {' '} + to unlock automatic fixes and alert-triggered analysis. +

+
+
+
+ + +
+
+
+
+ +
+
+

+ Patrol paused +

+

{blockedReason()}

+ +

+ Blocked {formatRelativeTime(blockedAt(), { compact: true })} +

+
+
+
+ +
+
+
+ + {/* Content */} +
+
+ {/* Approval Banner */} + { + setActiveTab('findings'); + setFindingsFilterOverride('approvals'); + // Allow SolidJS to re-render with new filter before scrolling + clearScrollToFindingTimer(); + scrollToFindingTimerRef = setTimeout(() => { + scrollToFindingTimerRef = undefined; + const el = document.getElementById(`finding-${findingId}`); + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + findingScrollTimerRef = undefined; + }, 100); + }} + /> + + {/* Status Bar (replaces Activity tab) */} + + + + {(summary) => ( +
+
+
+

+ Patrol summary +

+

+ Health {summary().overall_health.grade} ·{' '} + {Math.round(summary().overall_health.score)}/100 +

+

{summary().overall_health.prediction}

+
+ +
+ + Critical {summary().findings_count.critical} + + + Warning {summary().findings_count.warning} + +
+
+ + +
+
+
+

+ Investigation context +

+

+ Secondary change and policy signals for deeper investigation. +

+ +

+ {investigationContextSummary()} +

+
+
+ + +
+ + +
+ 0}> + + + +
+ 0} + > + + + + +
+
+
+
+
+
+ )} +
+ + {/* Summary Cards */} + 0 || + summaryStats().warningFindings > 0 || + summaryStats().fixedCount > 0 + } + fallback={ + +
+ + {PATROL_NO_ISSUES_LABEL} +
+
+ } + > +
+ {/* Critical */} +
+
+
+ +
+
+

Critical

+

+ {summaryStats().criticalFindings} +

+
+
+
+ + {/* Warnings */} +
+
+
+ +
+
+

Warnings

+

+ {summaryStats().warningFindings} +

+
+
+
+ + {/* Fixed (issues resolved by Patrol) */} +
+
+
+ +
+
+

Fixed

+

+ {summaryStats().fixedCount} +

+
+
+
+
+
+ + {/* Tab Bar */} +
+ + +
+ + {/* Tab Content */} + + + {(run) => ( +
+ + Filtered to run {formatRelativeTime(run().started_at, { compact: true })} ( + {formatTriggerReason(run().trigger_reason)}) + + +
+ )} +
+ + +
+ + + + +
+
+
+ ); +} + +export default PatrolIntelligenceSurface; diff --git a/frontend-modern/src/pages/AIIntelligence.tsx b/frontend-modern/src/pages/AIIntelligence.tsx index 8e9b37e07..9ea4dec14 100644 --- a/frontend-modern/src/pages/AIIntelligence.tsx +++ b/frontend-modern/src/pages/AIIntelligence.tsx @@ -1,1522 +1,7 @@ -/** - * Patrol Page - * - * Central hub for Patrol intelligence - AI-powered findings with investigation support. - */ - -import { - createSignal, - createEffect, - onMount, - onCleanup, - createMemo, - createResource, - For, - Show, -} from 'solid-js'; -import { aiIntelligenceStore } from '@/stores/aiIntelligence'; -import { FindingsPanel } from '@/components/AI/FindingsPanel'; -import { - getPatrolStatus, - getPatrolAutonomySettings, - updatePatrolAutonomySettings, - triggerPatrolRun, - getPatrolRunHistory, - type PatrolStatus, - type PatrolAutonomyLevel, - type PatrolRunRecord, -} from '@/api/patrol'; -import { apiFetchJSON } from '@/utils/apiClient'; -import { notificationStore } from '@/stores/notifications'; -import { hasTriggeringAlert } from '@/utils/findingAlertIdentity'; -import { getFindingSeverityToneClasses } from '@/utils/aiFindingPresentation'; -import { - getPatrolSummaryPresentation, - PATROL_NO_ISSUES_LABEL, -} from '@/utils/patrolSummaryPresentation'; - -interface ModelInfo { - id: string; - name: string; - description: string; - notable: boolean; -} - -interface AISettings { - patrol_model?: string; - patrol_interval_minutes?: number; - patrol_enabled?: boolean; - model?: string; - alert_triggered_analysis?: boolean; - patrol_event_triggers_enabled?: boolean; - patrol_auto_fix?: boolean; - auto_fix_model?: string; -} - -import ActivityIcon from 'lucide-solid/icons/activity'; -import ShieldAlertIcon from 'lucide-solid/icons/shield-alert'; -import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; -import PlayIcon from 'lucide-solid/icons/play'; -import CircleHelpIcon from 'lucide-solid/icons/circle-help'; -import XIcon from 'lucide-solid/icons/x'; - -import SparklesIcon from 'lucide-solid/icons/sparkles'; -import CheckCircleIcon from 'lucide-solid/icons/check-circle'; -import SettingsIcon from 'lucide-solid/icons/settings'; -import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo'; -import { PageHeader } from '@/components/shared/PageHeader'; -import { TogglePrimitive, Toggle } from '@/components/shared/Toggle'; -import { - ApprovalBanner, - PatrolStatusBar, - RunHistoryPanel, - CountdownTimer, -} from '@/components/patrol'; -import { usePatrolStream } from '@/hooks/usePatrolStream'; -import { - getUpgradeActionUrlOrFallback, - hasFeature, - licenseStatus, - loadLicenseStatus, - startProTrial, -} from '@/stores/license'; -import { formatRelativeTime } from '@/utils/format'; -import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; -import { - formatTriggerReason, - getCanonicalScopeResourceIds, - groupModelsByProvider, -} from '@/utils/patrolFormat'; -import { getAIQuickstartCreditsPresentation } from '@/utils/aiQuickstartPresentation'; -import { buildPatrolScheduleOptions } from '@/utils/aiPatrolSchedulePresentation'; -import { ResourcePolicySummary } from '@/components/Infrastructure/ResourcePolicySummary'; -import { ResourceCorrelationSummary } from '@/components/Infrastructure/ResourceCorrelationSummary'; -import { ResourceChangeSummary } from '@/components/Infrastructure/ResourceChangeSummary'; -import { - getProTrialStartedMessage, - getTrialAlreadyUsedMessage, - getTrialStartErrorMessage, - getTrialTryAgainLaterMessage, -} from '@/utils/upgradePresentation'; -type PatrolTab = 'findings' | 'history'; +import { PatrolIntelligenceSurface } from '@/features/patrol/PatrolIntelligenceSurface'; export function AIIntelligence() { - const [activeTab, setActiveTab] = createSignal('findings'); - const [showInvestigationContext, setShowInvestigationContext] = createSignal(false); - const [findingsFilterOverride, setFindingsFilterOverride] = createSignal< - 'all' | 'active' | 'resolved' | 'approvals' | 'attention' | undefined - >(undefined); - const [isRefreshing, setIsRefreshing] = createSignal(false); - const [autonomyLevel, setAutonomyLevel] = createSignal('monitor'); - const [isUpdatingAutonomy, setIsUpdatingAutonomy] = createSignal(false); - - // Trigger to refresh patrol activity visualizations - const [activityRefreshTrigger, setActivityRefreshTrigger] = createSignal(0); - - // Optimistic running state — set immediately on "Run Patrol" click to avoid race with backend - const [manualRunRequested, setManualRunRequested] = createSignal(false); - const [patrolEnabledLocal, setPatrolEnabledLocal] = createSignal(true); - const [liveRunStartedAt, setLiveRunStartedAt] = createSignal(''); - - // Safety timer ref — hoisted so onStart can clear it when SSE connects - let safetyTimerRef: ReturnType | undefined; - let scrollToFindingTimerRef: ReturnType | undefined; - let findingScrollTimerRef: ReturnType | undefined; - - const clearSafetyTimer = () => { - if (safetyTimerRef !== undefined) { - clearTimeout(safetyTimerRef); - safetyTimerRef = undefined; - } - }; - - const clearScrollToFindingTimer = () => { - if (scrollToFindingTimerRef !== undefined) { - clearTimeout(scrollToFindingTimerRef); - scrollToFindingTimerRef = undefined; - } - }; - - // Fetch patrol status (license_required reflects auto-fix, not patrol access) - const [patrolStatus, { refetch: refetchPatrolStatus }] = createResource( - async () => { - try { - return await getPatrolStatus(); - } catch { - return null; - } - }, - ); - - // Live patrol streaming - const patrolStream = usePatrolStream({ - running: () => - patrolEnabledLocal() && ((patrolStatus()?.running ?? false) || manualRunRequested()), - onStart: () => { - // SSE connected — clear the safety timeout - clearSafetyTimer(); - }, - onComplete: () => { - setManualRunRequested(false); - loadAllData(); - }, - onError: () => { - setManualRunRequested(false); - loadAllData(); - }, - }); - - // Advanced autonomy settings - const [investigationBudget, setInvestigationBudget] = createSignal(15); - const [investigationTimeout, setInvestigationTimeout] = createSignal(300); - const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false); - const [isSavingAdvanced, setIsSavingAdvanced] = createSignal(false); - const [fullModeUnlocked, setFullModeUnlocked] = createSignal(false); - let advancedSettingsRef: HTMLDivElement | undefined; - let patrolModelSelectRef: HTMLSelectElement | undefined; - - // Close popover when clicking outside - const handleClickOutside = (e: MouseEvent) => { - if (advancedSettingsRef && !advancedSettingsRef.contains(e.target as Node)) { - setShowAdvancedSettings(false); - } - }; - - createEffect(() => { - if (showAdvancedSettings()) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - }); - - onCleanup(() => { - document.removeEventListener('mousedown', handleClickOutside); - clearSafetyTimer(); - clearScrollToFindingTimer(); - }); - - // AI settings state - const [availableModels, setAvailableModels] = createSignal([]); - const [patrolModel, setPatrolModel] = createSignal(''); - const [defaultModel, setDefaultModel] = createSignal(''); - const [patrolInterval, setPatrolInterval] = createSignal(360); - const [isUpdatingSettings, setIsUpdatingSettings] = createSignal(false); - const [isTogglingPatrol, setIsTogglingPatrol] = createSignal(false); - const [isTriggeringPatrol, setIsTriggeringPatrol] = createSignal(false); - const [alertTriggeredAnalysis, setAlertTriggeredAnalysis] = createSignal(false); - const [patrolEventTriggers, setPatrolEventTriggers] = createSignal(true); - const [startingTrial, setStartingTrial] = createSignal(false); - const quickstartPresentation = createMemo(() => - getAIQuickstartCreditsPresentation( - patrolStatus()?.quickstart_credits_remaining ?? 0, - patrolStatus()?.quickstart_credits_total ?? 0, - ), - ); - const criticalSummaryPresentation = createMemo(() => - getPatrolSummaryPresentation('critical', summaryStats().criticalFindings > 0), - ); - const warningSummaryPresentation = createMemo(() => - getPatrolSummaryPresentation('warning', summaryStats().warningFindings > 0), - ); - const fixedSummaryPresentation = createMemo(() => - getPatrolSummaryPresentation('success', summaryStats().fixedCount > 0), - ); - - const canStartTrial = createMemo(() => { - const state = licenseStatus()?.subscription_state; - if (!state) return false; - return state !== 'active' && state !== 'trial'; - }); - - const handleStartTrial = async () => { - if (startingTrial()) return; - setStartingTrial(true); - try { - const result = await startProTrial(); - if (result?.outcome === 'redirect') { - if (typeof window !== 'undefined') { - window.location.href = result.actionUrl; - } - return; - } - notificationStore.success(getProTrialStartedMessage()); - } catch (err) { - const statusCode = (err as { status?: number } | null)?.status; - if (statusCode === 409) { - notificationStore.error(getTrialAlreadyUsedMessage()); - } else if (statusCode === 429) { - notificationStore.error(getTrialTryAgainLaterMessage()); - } else { - notificationStore.error( - getTrialStartErrorMessage(err instanceof Error ? err.message : undefined, { - branded: true, - }), - ); - } - } finally { - setStartingTrial(false); - } - }; - - // Re-apply patrol model select value when models load after settings - // (select value is ignored by the browser if no matching option exists yet) - createEffect(() => { - const model = patrolModel(); - const models = availableModels(); - if (patrolModelSelectRef && models.length > 0 && model) { - patrolModelSelectRef.value = model; - } - }); - - // Detect when saved patrol model is no longer in the available models list - const patrolModelStale = createMemo(() => { - const model = patrolModel(); - const models = availableModels(); - if (!model || models.length === 0) return false; - return !models.some((m) => m.id === model); - }); - - // License feature gates - const alertAnalysisLocked = createMemo(() => !hasFeature('ai_alerts')); - const autoFixLocked = createMemo(() => !hasFeature('ai_autofix')); - const [selectedRun, setSelectedRun] = createSignal(null); - - const scheduleOptions = createMemo(() => { - return buildPatrolScheduleOptions(patrolInterval()); - }); - - // Load available models - async function loadModels() { - try { - const data = await apiFetchJSON<{ models: ModelInfo[] }>('/api/ai/models'); - setAvailableModels(data?.models || []); - } catch (err) { - console.error('Failed to load models:', err); - } - } - - // Load AI settings - async function loadAISettings() { - try { - const data = await apiFetchJSON('/api/settings/ai'); - if (!data) return; - setPatrolModel(data.patrol_model || ''); - setDefaultModel(data.model || ''); - setPatrolInterval(data.patrol_interval_minutes ?? 360); - setPatrolEnabledLocal(data.patrol_enabled ?? true); - setAlertTriggeredAnalysis(!alertAnalysisLocked() && data.alert_triggered_analysis !== false); - setPatrolEventTriggers(data.patrol_event_triggers_enabled !== false); - } catch (err) { - console.error('Failed to load AI settings:', err); - } - } - - // Toggle patrol on/off - async function handleTogglePatrol() { - if (isTogglingPatrol()) return; - setIsTogglingPatrol(true); - const previousValue = patrolEnabledLocal(); - const newValue = !previousValue; - setPatrolEnabledLocal(newValue); - if (!newValue) { - setManualRunRequested(false); - clearSafetyTimer(); - } - try { - const data = await apiFetchJSON('/api/settings/ai/update', { - method: 'PUT', - body: JSON.stringify({ patrol_enabled: newValue }), - }); - if (typeof data?.patrol_enabled === 'boolean') { - setPatrolEnabledLocal(data.patrol_enabled); - } else { - setPatrolEnabledLocal(newValue); - } - if (typeof data?.patrol_interval_minutes === 'number') { - setPatrolInterval(data.patrol_interval_minutes); - } - if (refetchPatrolStatus) { - refetchPatrolStatus(); - } - } catch (err) { - console.error('Failed to toggle patrol:', err); - setPatrolEnabledLocal(previousValue); // Rollback - notificationStore.error('Failed to toggle patrol'); - } finally { - setIsTogglingPatrol(false); - } - } - - async function handleRunPatrol() { - if ( - isTriggeringPatrol() || - !canTriggerPatrol() || - manualRunRequested() || - patrolStream.isStreaming() - ) - return; - setIsTriggeringPatrol(true); - setManualRunRequested(true); - - // Safety timeout: if SSE never connects within 15s, clear optimistic state. - // Cleared early via onStart callback when the SSE connection opens. - clearSafetyTimer(); - safetyTimerRef = setTimeout(() => { - safetyTimerRef = undefined; - if (manualRunRequested() && !patrolStream.isStreaming()) { - setManualRunRequested(false); - notificationStore.error('Patrol run did not start — connection timed out'); - loadAllData(); - } - }, 15000); - - try { - await triggerPatrolRun(); - await loadAllData(); - } catch (err) { - console.error('Failed to trigger patrol run:', err); - setManualRunRequested(false); - notificationStore.error('Failed to start patrol run'); - // Clear safety timer on API error - clearSafetyTimer(); - } finally { - setIsTriggeringPatrol(false); - } - } - - // Update patrol model - async function handleModelChange(modelId: string) { - if (isUpdatingSettings()) return; - setIsUpdatingSettings(true); - try { - await apiFetchJSON('/api/settings/ai/update', { - method: 'PUT', - body: JSON.stringify({ patrol_model: modelId }), - }); - setPatrolModel(modelId); - } catch (err) { - console.error('Failed to update patrol model:', err); - notificationStore.error('Failed to update patrol model'); - } finally { - setIsUpdatingSettings(false); - } - } - - // Update patrol interval - async function handleIntervalChange(minutes: number) { - if (isUpdatingSettings()) return; - setIsUpdatingSettings(true); - try { - await apiFetchJSON('/api/settings/ai/update', { - method: 'PUT', - body: JSON.stringify({ patrol_interval_minutes: minutes }), - }); - setPatrolInterval(minutes); - setPatrolEnabledLocal(minutes > 0); - // Refetch patrol status so the countdown timer reflects the new interval - refetchPatrolStatus(); - } catch (err) { - console.error('Failed to update patrol interval:', err); - notificationStore.error('Failed to update patrol schedule'); - } finally { - setIsUpdatingSettings(false); - } - } - - // Toggle alert-triggered analysis - async function handleAlertTriggeredAnalysisChange(enabled: boolean) { - if (isUpdatingSettings()) return; - setIsUpdatingSettings(true); - const previous = alertTriggeredAnalysis(); - setAlertTriggeredAnalysis(enabled); - try { - await apiFetchJSON('/api/settings/ai/update', { - method: 'PUT', - body: JSON.stringify({ alert_triggered_analysis: enabled }), - }); - } catch (err) { - console.error('Failed to update alert-triggered analysis:', err); - setAlertTriggeredAnalysis(previous); - notificationStore.error('Failed to update alert analysis setting'); - } finally { - setIsUpdatingSettings(false); - } - } - - // Toggle event-triggered patrols - async function handlePatrolEventTriggersChange(enabled: boolean) { - if (isUpdatingSettings()) return; - setIsUpdatingSettings(true); - const previous = patrolEventTriggers(); - setPatrolEventTriggers(enabled); - try { - await apiFetchJSON('/api/settings/ai/update', { - method: 'PUT', - body: JSON.stringify({ patrol_event_triggers_enabled: enabled }), - }); - } catch (err) { - console.error('Failed to update event-triggered patrols:', err); - setPatrolEventTriggers(previous); - notificationStore.error('Failed to update event triggers setting'); - } finally { - setIsUpdatingSettings(false); - } - } - - const [patrolRunHistory] = createResource( - () => activityRefreshTrigger(), - async () => { - try { - return await getPatrolRunHistory(30); - } catch (err) { - console.error('Failed to load patrol run history:', err); - return []; - } - }, - ); - - const licenseRequired = createMemo(() => patrolStatus()?.license_required ?? false); - const upgradeUrl = createMemo(() => getUpgradeActionUrlOrFallback('ai_autofix')); - const blockedReason = createMemo(() => patrolStatus()?.blocked_reason?.trim() ?? ''); - const blockedAt = createMemo(() => patrolStatus()?.blocked_at); - const showBlockedBanner = createMemo(() => patrolEnabledLocal() && !!blockedReason()); - const canTriggerPatrol = createMemo(() => patrolEnabledLocal() && !showBlockedBanner()); - const triggerPatrolDisabledReason = createMemo(() => { - if (!patrolEnabledLocal()) return 'Patrol is disabled'; - if (showBlockedBanner()) return blockedReason() || 'Patrol is paused'; - return ''; - }); - - createEffect((wasAutoFixLocked) => { - const isAutoFixLocked = autoFixLocked(); - if (isAutoFixLocked && !wasAutoFixLocked) { - trackPaywallViewed('ai_autofix', 'ai_intelligence'); - } - return isAutoFixLocked; - }, false); - - createEffect((wasAlertAnalysisLocked) => { - const isAlertAnalysisLocked = alertAnalysisLocked(); - if (isAlertAnalysisLocked && !wasAlertAnalysisLocked) { - trackPaywallViewed('ai_alerts', 'ai_intelligence'); - } - return isAlertAnalysisLocked; - }, false); - - createEffect((wasLicenseBannerVisible) => { - const isLicenseBannerVisible = licenseRequired() && !showBlockedBanner(); - if (isLicenseBannerVisible && !wasLicenseBannerVisible) { - trackPaywallViewed('ai_autofix', 'ai_intelligence_banner'); - } - return isLicenseBannerVisible; - }, false); - - const shouldShowLiveRun = createMemo( - () => - patrolEnabledLocal() && - ((patrolStatus()?.running ?? false) || manualRunRequested() || patrolStream.isStreaming()), - ); - - createEffect(() => { - if (shouldShowLiveRun()) { - if (!liveRunStartedAt()) { - setLiveRunStartedAt(new Date().toISOString()); - } - return; - } - if (liveRunStartedAt()) { - setLiveRunStartedAt(''); - } - }); - - const selectedRunFindingIds = createMemo(() => { - const run = selectedRun(); - if (!run) return undefined; - return run.finding_ids; - }); - - const selectedRunScopeResourceIds = createMemo(() => { - return getCanonicalScopeResourceIds(selectedRun()); - }); - - const intelligenceSummary = createMemo(() => aiIntelligenceStore.intelligenceSummary); - const policyPosture = createMemo(() => intelligenceSummary()?.policy_posture); - const correlationTotal = createMemo( - () => - aiIntelligenceStore.correlations?.count ?? - aiIntelligenceStore.correlations?.correlations?.length ?? - 0, - ); - const recentChangeCount = createMemo( - () => - intelligenceSummary()?.recent_changes_count ?? intelligenceSummary()?.recent_changes?.length ?? 0, - ); - const hasInvestigationContext = createMemo( - () => - recentChangeCount() > 0 || - correlationTotal() > 0 || - (policyPosture()?.total_resources ?? 0) > 0, - ); - const investigationContextSummary = createMemo(() => { - const parts: string[] = []; - if (recentChangeCount() > 0) { - parts.push( - `${recentChangeCount()} recent change${recentChangeCount() === 1 ? '' : 's'}`, - ); - } - if (correlationTotal() > 0) { - parts.push( - `${correlationTotal()} correlation${correlationTotal() === 1 ? '' : 's'}`, - ); - } - const governedResources = policyPosture()?.total_resources ?? 0; - if (governedResources > 0) { - parts.push( - `${governedResources} governed resource${governedResources === 1 ? '' : 's'}`, - ); - } - return parts.join(' · '); - }); - - // Live in-progress run entry for history list - const liveRunRecord = createMemo(() => { - if (!shouldShowLiveRun()) return null; - return { - id: '__live__', - started_at: liveRunStartedAt() || new Date().toISOString(), - completed_at: '', - duration_ms: 0, - type: 'full', - trigger_reason: 'manual', - resources_checked: 0, - nodes_checked: 0, - guests_checked: 0, - docker_checked: 0, - storage_checked: 0, - hosts_checked: 0, - pbs_checked: 0, - pmg_checked: 0, - kubernetes_checked: 0, - new_findings: 0, - existing_findings: 0, - rejected_findings: 0, - resolved_findings: 0, - auto_fix_count: 0, - findings_summary: '', - finding_ids: [], - error_count: 0, - status: 'healthy', - triage_flags: 0, - tool_call_count: 0, - }; - }); - - // Combined run history: live entry (if any) prepended to real history - const displayRunHistory = createMemo(() => { - const live = liveRunRecord(); - const history = patrolRunHistory() || []; - return live ? [live, ...history] : history; - }); - - // Load autonomy settings - async function loadAutonomySettings() { - try { - const settings = await getPatrolAutonomySettings(); - if (!settings) return; - // Clamp locally in case license state resolves before or after this load. - // The GET endpoint also clamps, but this prevents a confusing active+disabled - // visual state if the response is stale or the feature flag flips mid-session. - const effectiveLevel = - autoFixLocked() && settings.autonomy_level !== 'monitor' - ? 'monitor' - : settings.autonomy_level; - setAutonomyLevel(effectiveLevel); - setFullModeUnlocked(settings.full_mode_unlocked); - setInvestigationBudget(settings.investigation_budget); - setInvestigationTimeout(settings.investigation_timeout_sec); - } catch (err) { - console.error('Failed to load autonomy settings:', err); - } - } - - // Update autonomy level (optimistic UI) - // When user picks "Auto-fix" (assisted), the actual backend level depends on whether - // the "auto-fix critical issues" toggle is on — if so, we send 'full', otherwise 'assisted'. - async function handleAutonomyChange(level: PatrolAutonomyLevel) { - if (isUpdatingAutonomy()) return; - if (autoFixLocked() && (level === 'approval' || level === 'assisted')) return; - - const previousLevel = autonomyLevel(); - const effectiveLevel = level === 'assisted' && fullModeUnlocked() ? 'full' : level; - setAutonomyLevel(effectiveLevel); // Optimistic update - setIsUpdatingAutonomy(true); - - try { - await updatePatrolAutonomySettings({ - autonomy_level: effectiveLevel, - full_mode_unlocked: fullModeUnlocked(), - investigation_budget: investigationBudget(), - investigation_timeout_sec: investigationTimeout(), - }); - } catch (err) { - console.error('Failed to update autonomy:', err); - setAutonomyLevel(previousLevel); // Rollback on error - notificationStore.error((err as Error).message || 'Failed to update autonomy level'); - } finally { - setIsUpdatingAutonomy(false); - } - } - - // Save advanced settings - // When the "auto-fix critical issues" toggle changes, adjust the autonomy level: - // - Toggle on + currently assisted → switch to full - // - Toggle off + currently full → switch to assisted - async function saveAdvancedSettings() { - setIsSavingAdvanced(true); - try { - let effectiveLevel = autonomyLevel(); - const inAutoFix = effectiveLevel === 'assisted' || effectiveLevel === 'full'; - if (inAutoFix) { - effectiveLevel = fullModeUnlocked() ? 'full' : 'assisted'; - } - - const result = await updatePatrolAutonomySettings({ - autonomy_level: effectiveLevel, - full_mode_unlocked: fullModeUnlocked(), - investigation_budget: investigationBudget(), - investigation_timeout_sec: investigationTimeout(), - }); - // Update local state from server response (handles auto-downgrade) - if (result.settings) { - setAutonomyLevel(result.settings.autonomy_level); - setFullModeUnlocked(result.settings.full_mode_unlocked); - } - setShowAdvancedSettings(false); - } catch (err) { - console.error('Failed to save advanced settings:', err); - notificationStore.error('Failed to save advanced settings'); - } finally { - setIsSavingAdvanced(false); - } - } - - onMount(async () => { - await Promise.all([ - loadLicenseStatus(), - loadAllData(), - loadAutonomySettings(), - loadModels(), - loadAISettings(), - ]); - }); - - // Polling intervals — paused when tab is hidden to save resources - let refreshInterval: ReturnType; - let approvalPollInterval: ReturnType; - - function startPolling() { - clearInterval(refreshInterval); - clearInterval(approvalPollInterval); - refreshInterval = setInterval(() => loadAllData(), 60000); - // Approval polling: 10s interval for 5-min expiry approvals - approvalPollInterval = setInterval(() => aiIntelligenceStore.loadPendingApprovals(), 10000); - } - - function stopPolling() { - clearInterval(refreshInterval); - clearInterval(approvalPollInterval); - } - - onMount(() => { - startPolling(); - - const handleVisibility = () => { - if (document.hidden) { - stopPolling(); - } else { - // Refresh immediately on tab return, then resume polling - loadAllData(); - startPolling(); - } - }; - document.addEventListener('visibilitychange', handleVisibility); - onCleanup(() => document.removeEventListener('visibilitychange', handleVisibility)); - }); - onCleanup(() => { - stopPolling(); - if (safetyTimerRef !== undefined) { - clearTimeout(safetyTimerRef); - safetyTimerRef = undefined; - } - if (findingScrollTimerRef !== undefined) { - clearTimeout(findingScrollTimerRef); - findingScrollTimerRef = undefined; - } - }); - - async function loadAllData() { - setIsRefreshing(true); - try { - await Promise.all([aiIntelligenceStore.loadDashboardData(), refetchPatrolStatus()]); - // Trigger refresh of patrol status bar - setActivityRefreshTrigger((prev) => prev + 1); - } finally { - setIsRefreshing(false); - } - } - - function summaryStats() { - const allFindings = aiIntelligenceStore.findings; - // Only count Patrol findings (exclude threshold alerts) - const patrolFindings = allFindings.filter( - (f) => f.source !== 'threshold' && !f.isThreshold && !hasTriggeringAlert(f), - ); - const activeFindings = patrolFindings.filter((f) => f.status === 'active'); - - const criticalCount = activeFindings.filter((f) => f.severity === 'critical').length; - const warningCount = activeFindings.filter((f) => f.severity === 'warning').length; - const totalActive = activeFindings.length; - const fixedCount = patrolFindings.filter( - (f) => - f.investigationOutcome === 'fix_verified' || - f.investigationOutcome === 'fix_executed' || - f.investigationOutcome === 'resolved', - ).length; - - return { - criticalFindings: criticalCount, - warningFindings: warningCount, - totalActive, - fixedCount, - hasAnyPatrolFindings: patrolFindings.length > 0, - }; - } - - return ( -
- {/* Header */} -
- - - Patrol - - } - description="Pulse Patrol monitoring and analysis" - class="mb-3" - actions={ -
- - - - - - - -
- } - /> - - {/* Settings row - Simplified for Enterprise Feel */} -
- {/* Global Patrol Toggle */} -
- - - {patrolEnabledLocal() ? 'Patrol Active' : 'Patrol Disabled'} - -
- - {/* Quickstart Credits Badge */} - 0 && - patrolStatus()!.quickstart_credits_remaining !== undefined) - } - > -
- 0} - fallback={{quickstartPresentation().summary}} - > - {quickstartPresentation().summary} - -
-
- -
- - {/* Configuration Popover */} -
- - - -
-
-

- Patrol Configuration -

- -
- -
- {/* Model & Schedule grouped */} -
-
- - -
- -
- - -
-
- - {/* Operational Mode */} -
-
- -
- -
- - {(level) => { - const isProLocked = () => - autoFixLocked() && (level === 'approval' || level === 'assisted'); - const isDisabled = () => !patrolEnabledLocal() || isProLocked(); - const isActive = () => - level === 'assisted' - ? autonomyLevel() === 'assisted' || autonomyLevel() === 'full' - : autonomyLevel() === level; - - return ( - - ); - }} - -
- -
- - Upgrade to Pro - {' '} - to unlock investigation and auto-fix. - - {' '} - - -
-
-
- - {/* Toggles */} -
-
-
- -

- Analyze infrastructure automatically when critical alerts fire. -

-
- - handleAlertTriggeredAnalysisChange(e.currentTarget.checked) - } - disabled={isUpdatingSettings() || alertAnalysisLocked()} - /> -
- - -
- - Upgrade - {' '} - to enable. - - - -
-
- -
-
- -

- Run extra patrols when alerts fire or anomalies are detected. -

-
- handlePatrolEventTriggersChange(e.currentTarget.checked)} - disabled={isUpdatingSettings() || !patrolEnabledLocal()} - /> -
- -
-
- -

- Permit Patrol to execute critical remediations without approval. -

-
- setFullModeUnlocked(e.currentTarget.checked)} - disabled={ - autoFixLocked() || - !(autonomyLevel() === 'assisted' || autonomyLevel() === 'full') - } - /> -
-
- - {/* Save Footer */} -
- -
-
-
-
-
-
-
- - {/* Live patrol streaming status bar */} - -
-
-
-
- Patrol running -
- - {patrolStream.phase()} - - - - {patrolStream.currentTool()} - - - 0}> - - {patrolStream.tokens().toLocaleString()} tokens - - -
-
- - - -
-
-

- trackUpgradeClicked('ai_intelligence_banner', 'ai_autofix')} - > - Upgrade to Pro - {' '} - to unlock automatic fixes and alert-triggered analysis. -

-
-
-
- - -
-
-
-
- -
-
-

- Patrol paused -

-

{blockedReason()}

- -

- Blocked {formatRelativeTime(blockedAt(), { compact: true })} -

-
-
-
- -
-
-
- - {/* Content */} -
-
- {/* Approval Banner */} - { - setActiveTab('findings'); - setFindingsFilterOverride('approvals'); - // Allow SolidJS to re-render with new filter before scrolling - clearScrollToFindingTimer(); - scrollToFindingTimerRef = setTimeout(() => { - scrollToFindingTimerRef = undefined; - const el = document.getElementById(`finding-${findingId}`); - el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - findingScrollTimerRef = undefined; - }, 100); - }} - /> - - {/* Status Bar (replaces Activity tab) */} - - - - {(summary) => ( -
-
-
-

- Patrol summary -

-

- Health {summary().overall_health.grade} ·{' '} - {Math.round(summary().overall_health.score)}/100 -

-

{summary().overall_health.prediction}

-
- -
- - Critical {summary().findings_count.critical} - - - Warning {summary().findings_count.warning} - -
-
- - -
-
-
-

- Investigation context -

-

- Secondary change and policy signals for deeper investigation. -

- -

- {investigationContextSummary()} -

-
-
- - -
- - -
- 0}> - - - -
- 0} - > - - - - -
-
-
-
-
-
- )} -
- - {/* Summary Cards */} - 0 || - summaryStats().warningFindings > 0 || - summaryStats().fixedCount > 0 - } - fallback={ - -
- - {PATROL_NO_ISSUES_LABEL} -
-
- } - > -
- {/* Critical */} -
-
-
- -
-
-

Critical

-

- {summaryStats().criticalFindings} -

-
-
-
- - {/* Warnings */} -
-
-
- -
-
-

Warnings

-

- {summaryStats().warningFindings} -

-
-
-
- - {/* Fixed (issues resolved by Patrol) */} -
-
-
- -
-
-

Fixed

-

- {summaryStats().fixedCount} -

-
-
-
-
-
- - {/* Tab Bar */} -
- - -
- - {/* Tab Content */} - - - {(run) => ( -
- - Filtered to run {formatRelativeTime(run().started_at, { compact: true })} ( - {formatTriggerReason(run().trigger_reason)}) - - -
- )} -
- - -
- - - - -
-
-
- ); + return ; } export default AIIntelligence; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index beeee0cbd..b96ee1a04 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -273,6 +273,7 @@ import diagnosticsPanelSource from '@/components/Settings/DiagnosticsPanel.tsx?r import diagnosticsPresentationSource from '@/utils/diagnosticsPresentation.ts?raw'; import aiSettingsSource from '@/components/Settings/AISettings.tsx?raw'; import aiIntelligenceSource from '@/pages/AIIntelligence.tsx?raw'; +import patrolIntelligenceSurfaceSource from '@/features/patrol/PatrolIntelligenceSurface.tsx?raw'; import aiQuickstartPresentationSource from '@/utils/aiQuickstartPresentation.ts?raw'; import aiCostPresentationSource from '@/utils/aiCostPresentation.ts?raw'; import thresholdSliderPresentationSource from '@/utils/thresholdSliderPresentation.ts?raw'; @@ -2660,21 +2661,26 @@ describe('frontend resource type boundaries', () => { expect(aiSettingsSource).not.toContain( "providerTestResult()?.success ? 'text-green-600' : 'text-red-600'", ); - expect(aiIntelligenceSource).toContain('getPatrolSummaryPresentation'); - expect(aiIntelligenceSource).toContain('getAIQuickstartCreditsPresentation'); - expect(aiIntelligenceSource).not.toContain( + expect(aiIntelligenceSource).toContain( + "import { PatrolIntelligenceSurface } from '@/features/patrol/PatrolIntelligenceSurface';", + ); + expect(aiIntelligenceSource).not.toContain('getPatrolSummaryPresentation'); + expect(aiIntelligenceSource).not.toContain('getAIQuickstartCreditsPresentation'); + expect(patrolIntelligenceSurfaceSource).toContain('getPatrolSummaryPresentation'); + expect(patrolIntelligenceSurfaceSource).toContain('getAIQuickstartCreditsPresentation'); + expect(patrolIntelligenceSurfaceSource).not.toContain( "summaryStats().criticalFindings > 0\n ? 'bg-red-50 dark:bg-red-900 border-red-200 dark:border-red-800'", ); - expect(aiIntelligenceSource).not.toContain( + expect(patrolIntelligenceSurfaceSource).not.toContain( "summaryStats().warningFindings > 0\n ? 'bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-800'", ); - expect(aiIntelligenceSource).not.toContain( + expect(patrolIntelligenceSurfaceSource).not.toContain( "summaryStats().fixedCount > 0\n ? 'bg-green-50 dark:bg-green-900 border-green-200 dark:border-green-800'", ); expect(patrolSummaryPresentationSource).toContain( 'export function getPatrolSummaryPresentation', ); - expect(aiIntelligenceSource).not.toContain( + expect(patrolIntelligenceSurfaceSource).not.toContain( "(patrolStatus()?.quickstart_credits_remaining ?? 0) > 0\n ? 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300'", ); expect(aiQuickstartPresentationSource).toContain(