diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e64a44794a..f6ae4d0b7e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -106,7 +106,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/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 +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/usePatrolIntelligenceState.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, runtime hook, 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/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0efa82bb6b..b8f515d293 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -316,6 +316,15 @@ and the summary-ordering helper in `frontend-modern/src/features/alerts/types.ts must stay shared across alert timeline surfaces instead of rebuilding page-local event summaries or bespoke incident-card markup. +Feature-owned route surfaces under `frontend-modern/src/features/` must also +keep their shell/runtime split explicit once a subsystem grows real transport +or polling lifecycle. The Patrol feature is the current reference shape: +`frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` stays the +render shell while `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` +owns the runtime state machine. Shared shell governance should reinforce that +pattern instead of letting feature render surfaces re-accumulate API and timer +orchestration inline. + Shared primitive consumers that split status-dot tone and status-text tone must now keep both values routed through the same exported presentation helper. Feature cards such as RAID status may not call shadow local aliases that drift diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index b917184cb3..6e25b459f9 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -22,20 +22,21 @@ Patrol-specific presentation helpers. ## Canonical Files 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` +2. `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` +3. `frontend-modern/src/pages/AIIntelligence.tsx` +4. `frontend-modern/src/stores/aiIntelligence.ts` +5. `frontend-modern/src/types/aiIntelligence.ts` +6. `frontend-modern/src/components/AI/FindingsPanel.tsx` +7. `frontend-modern/src/components/Brand/PulsePatrolLogo.tsx` +8. `frontend-modern/src/components/patrol/` +9. `frontend-modern/src/utils/aiFindingPresentation.ts` +10. `frontend-modern/src/utils/approvalRiskPresentation.ts` +11. `frontend-modern/src/utils/findingAlertIdentity.ts` +12. `frontend-modern/src/utils/patrolEmptyStatePresentation.ts` +13. `frontend-modern/src/utils/patrolFormat.ts` +14. `frontend-modern/src/utils/patrolRunPresentation.ts` +15. `frontend-modern/src/utils/patrolSummaryPresentation.ts` +16. `frontend-modern/src/utils/textPresentation.ts` ## Shared Boundaries @@ -43,7 +44,7 @@ Patrol-specific presentation helpers. ## Extension Points -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 +1. Add or change Patrol page orchestration through `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, keep `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` as the feature render shell, 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` @@ -74,6 +75,13 @@ 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. +The feature surface now also keeps the same shell/runtime split internally: +`frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` owns render +composition and Patrol page presentation, while +`frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` owns Patrol +state, transport, polling, and effect lifecycle. The render shell must not +re-accumulate Patrol API calls, timer orchestration, or store refresh flow +inline. Patrol finding state must now also consume the canonical camelCase `alertIdentifier` field and pending-approval expiry metadata end to end. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index be02018046..d52b13d3e9 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2405,6 +2405,8 @@ "frontend-modern/src/components/patrol/RunHistoryEntry.tsx", "frontend-modern/src/components/patrol/RunHistoryPanel.tsx", "frontend-modern/src/components/patrol/RunToolCallTrace.tsx", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts", "frontend-modern/src/pages/AIIntelligence.tsx", "frontend-modern/src/stores/aiIntelligence.ts", "frontend-modern/src/types/aiIntelligence.ts", @@ -2433,6 +2435,8 @@ "match_files": [ "frontend-modern/src/components/Brand/PulsePatrolLogo.tsx", "frontend-modern/src/components/patrol/index.ts", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts", "frontend-modern/src/pages/AIIntelligence.tsx", "frontend-modern/src/stores/aiIntelligence.ts", "frontend-modern/src/types/aiIntelligence.ts" diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index 52bd69e820..5d82cbd99c 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -57,6 +57,7 @@ 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 patrolIntelligenceStateSource from '@/features/patrol/usePatrolIntelligenceState.ts?raw'; import aiPatrolSchedulePresentationSource from '@/utils/aiPatrolSchedulePresentation.ts?raw'; import patrolSummaryPresentationSource from '@/utils/patrolSummaryPresentation.ts?raw'; import aiCostDashboardSource from '@/components/AI/AICostDashboard.tsx?raw'; @@ -572,8 +573,13 @@ describe('monitored-system model guardrails', () => { ); expect(aiIntelligenceSource).not.toContain('buildPatrolScheduleOptions'); expect(aiIntelligenceSource).not.toContain('PATROL_NO_ISSUES_LABEL'); + expect(patrolIntelligenceSurfaceSource).toContain("./usePatrolIntelligenceState"); expect(patrolIntelligenceSurfaceSource).toContain('buildPatrolScheduleOptions'); expect(patrolIntelligenceSurfaceSource).toContain('PATROL_NO_ISSUES_LABEL'); + expect(patrolIntelligenceStateSource).toContain('export function usePatrolIntelligenceState'); + expect(patrolIntelligenceStateSource).toContain('getPatrolStatus'); + expect(patrolIntelligenceStateSource).toContain('usePatrolStream'); + expect(patrolIntelligenceStateSource).toContain('updatePatrolAutonomySettings'); expect(aiIntelligenceSource).not.toContain('No issues found'); expect(patrolIntelligenceSurfaceSource).not.toContain('No issues found'); expect(aiIntelligenceSource).not.toContain('const SCHEDULE_PRESETS ='); @@ -1390,7 +1396,8 @@ describe('monitored-system model guardrails', () => { expect(settingsSource).not.toContain('Loading settings...'); expect(settingsSystemPanelsSource).toContain('getSettingsConfigurationLoadingState'); expect(settingsSystemPanelsSource).not.toContain('Loading configuration...'); - expect(settingsPanelRegistrySource).toContain('systemPanels: SettingsSystemPanels'); + expect(settingsPanelRegistrySource).toContain('createSettingsPanelRegistry'); + expect(settingsPanelRegistrySource).toContain('buildSettingsPanelRegistryContext(params)'); expect(settingsPanelRegistrySource).not.toContain('getSettingsConfigurationLoadingState'); expect(settingsShellPresentationSource).toContain('export function getSettingsLoadingState'); expect(settingsShellPresentationSource).toContain( diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx index a464dbe730..24a1820445 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -4,55 +4,14 @@ * 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 { createMemo, For, Show } from 'solid-js'; 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'; @@ -72,141 +31,88 @@ import { 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 { trackUpgradeClicked } from '@/utils/upgradeMetrics'; +import { formatTriggerReason, 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 { usePatrolIntelligenceState } from './usePatrolIntelligenceState'; 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); + const { + activeTab, + activityRefreshTrigger, + alertAnalysisUpgradeUrl, + alertAnalysisLocked, + alertTriggeredAnalysis, + autonomyLevel, + autoFixLocked, + availableModels, + blockedAt, + blockedReason, + canStartTrial, + canTriggerPatrol, + correlationTotal, + correlations, + clearScrollToFindingTimer, + defaultModel, + displayRunHistory, + findingsFilterOverride, + fullModeUnlocked, + handleAlertTriggeredAnalysisChange, + handleAutonomyChange, + handleIntervalChange, + handleModelChange, + handlePatrolEventTriggersChange, + handleRunPatrol, + handleStartTrial, + handleTogglePatrol, + hasInvestigationContext, + intelligenceSummary, + investigationContextSummary, + isRefreshing, + isSavingAdvanced, + isTogglingPatrol, + isTriggeringPatrol, + isUpdatingSettings, + licenseRequired, + loadAllData, + manualRunRequested, + patrolEnabledLocal, + patrolEventTriggers, + patrolInterval, + patrolModel, + patrolRunHistory, + patrolStatus, + patrolStream, + policyPosture, + recentChangeCount, + saveAdvancedSettings, + selectedRun, + selectedRunFindingIds, + selectedRunScopeResourceIds, + setActiveTab, + setAdvancedSettingsRef, + setFindingScrollTimer, + setFindingsFilterOverride, + setFullModeUnlocked, + setPatrolModelSelectRef, + setScrollToFindingTimer, + setSelectedRun, + setShowAdvancedSettings, + setShowInvestigationContext, + showAdvancedSettings, + showBlockedBanner, + showInvestigationContext, + startingTrial, + summaryStats, + triggerPatrolDisabledReason, + upgradeUrl, + } = usePatrolIntelligenceState(); - // 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, @@ -222,577 +128,14 @@ export function PatrolIntelligenceSurface() { 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 scheduleOptions = createMemo(() => buildPatrolScheduleOptions(patrolInterval())); const patrolModelStale = createMemo(() => { const model = patrolModel(); const models = availableModels(); if (!model || models.length === 0) return false; - return !models.some((m) => m.id === model); + return !models.some((candidate) => candidate.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 */} @@ -906,7 +249,7 @@ export function PatrolIntelligenceSurface() {
{/* Configuration Popover */} -
+