diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 30bcdef7d..ea9ade78d 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -170,3 +170,7 @@ slice that the prompt context uses. The system-wide `/api/ai/intelligence` summary should also surface the same canonical recent-change slice, alongside the count, so the aggregate payload and the prompt context stay aligned on the same shared timeline source. +The frontend Patrol intelligence page now also consumes that canonical +summary payload directly through the shared AI client and store, so the +visible summary card stays aligned with the same recent-change slice that the +runtime and API contracts expose. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9be461cf1..00bc597ff 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -9,7 +9,9 @@ "contract_file": "docs/release-control/v6/internal/subsystems/api-contracts.md", "status_file": "docs/release-control/v6/internal/status.json", "registry_file": "docs/release-control/v6/internal/subsystems/registry.json", - "dependency_subsystem_ids": [] + "dependency_subsystem_ids": [ + "patrol-intelligence" + ] } ``` @@ -73,11 +75,12 @@ 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 capability, relationship, 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 governed surface, including the backend-provided facet counts needed to distinguish loaded slices from total history -7. Route frontend API-client parsed error propagation, API-error-status fallback handling, allowed-status handling, custom status-specific error handling, command-trigger success envelope handling, shared response parsing pipelines, missing-resource lookup handling, metadata CRUD routing, stream event consumption, response status, collection normalization, scalar payload coercion, and structured error normalization through canonical shared helpers under `frontend-modern/src/api/` -8. Add or change API token scope, assignment, and revocation presentation through `frontend-modern/src/components/Settings/APITokenManager.tsx` -9. Add or change infrastructure operations token generation, lookup, assignment, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` -10. Keep `internal/api/session_store.go` on a fail-closed auth-persistence boundary: persisted OIDC refresh tokens may only round-trip through encrypted-at-rest session payloads, and any missing-crypto or invalid-ciphertext path must drop the token instead of preserving plaintext-at-rest session state. -11. Keep tenant AI handler wiring on canonical provider ownership: `internal/api/ai_handlers.go` may wire tenant `ReadState` and tenant-scoped unified-resource providers into AI services, but it must not revive tenant snapshot-provider bridges once Patrol can initialize and verify from those canonical providers directly. +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 +8. Route frontend API-client parsed error propagation, API-error-status fallback handling, allowed-status handling, custom status-specific error handling, command-trigger success envelope handling, shared response parsing pipelines, missing-resource lookup handling, metadata CRUD routing, stream event consumption, response status, collection normalization, scalar payload coercion, and structured error normalization through canonical shared helpers under `frontend-modern/src/api/` +9. Add or change API token scope, assignment, and revocation presentation through `frontend-modern/src/components/Settings/APITokenManager.tsx` +10. Add or change infrastructure operations token generation, lookup, assignment, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` +11. Keep `internal/api/session_store.go` on a fail-closed auth-persistence boundary: persisted OIDC refresh tokens may only round-trip through encrypted-at-rest session payloads, and any missing-crypto or invalid-ciphertext path must drop the token instead of preserving plaintext-at-rest session state. +12. Keep tenant AI handler wiring on canonical provider ownership: `internal/api/ai_handlers.go` may wire tenant `ReadState` and tenant-scoped unified-resource providers into AI services, but it must not revive tenant snapshot-provider bridges once Patrol can initialize and verify from those canonical providers directly. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index dd477069a..348014d82 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -81,3 +81,6 @@ Patrol-owned resource and global intelligence prompt contexts should also render the canonical recent changes section before any patrol-local change detector fallback so the prompt surface stays aligned with the shared unified-resource timeline. +The Patrol page also now renders the canonical intelligence summary card +through the governed AI client and store, so the visible page summary and the +resource/timeline sections stay aligned on the same shared backend slice. diff --git a/frontend-modern/src/api/__tests__/ai.test.ts b/frontend-modern/src/api/__tests__/ai.test.ts index 2bd917039..5e750dd32 100644 --- a/frontend-modern/src/api/__tests__/ai.test.ts +++ b/frontend-modern/src/api/__tests__/ai.test.ts @@ -105,6 +105,18 @@ describe('AIAPI', () => { }); }); + it('fetches canonical intelligence summaries with encoded resource ids', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as any); + await AIAPI.getIntelligenceSummary(); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/intelligence'); + + apiFetchJSONMock.mockResolvedValueOnce({} as any); + await AIAPI.getResourceIntelligence('vm/100?filter=all'); + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/ai/intelligence?resource_id=vm%2F100%3Ffilter%3Dall', + ); + }); + it('treats 402 responses from optional AI paywalled collections as empty state', async () => { const paymentRequiredError = Object.assign(new Error('Approval management requires Pulse Pro'), { status: 402, diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index 79fbe0bc3..f86912e29 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -17,7 +17,12 @@ import type { AIStreamEvent, AICostSummary, } from '@/types/ai'; -import type { AnomaliesResponse, LearningStatusResponse } from '@/types/aiIntelligence'; +import type { + AnomaliesResponse, + IntelligenceSummary, + LearningStatusResponse, + ResourceIntelligence, +} from '@/types/aiIntelligence'; export class AIAPI { private static baseUrl = '/api'; @@ -129,6 +134,21 @@ export class AIAPI { ) as Promise; } + private static async fetchIntelligence(resourceId?: string): Promise { + const params = resourceId ? `?resource_id=${encodeURIComponent(resourceId)}` : ''; + return apiFetchJSON(`${this.baseUrl}/ai/intelligence${params}`); + } + + // Get the canonical infrastructure-wide intelligence summary + static async getIntelligenceSummary(): Promise { + return (await this.fetchIntelligence()) as IntelligenceSummary; + } + + // Get canonical intelligence for a single resource + static async getResourceIntelligence(resourceId: string): Promise { + return (await this.fetchIntelligence(resourceId)) as ResourceIntelligence; + } + // Analyze a Kubernetes cluster with AI static async analyzeKubernetesCluster(clusterId: string): Promise { return apiFetchJSON(`${this.baseUrl}/ai/kubernetes/analyze`, { diff --git a/frontend-modern/src/pages/AIIntelligence.tsx b/frontend-modern/src/pages/AIIntelligence.tsx index dc5d17250..db53799b0 100644 --- a/frontend-modern/src/pages/AIIntelligence.tsx +++ b/frontend-modern/src/pages/AIIntelligence.tsx @@ -95,9 +95,42 @@ import { getTrialStartErrorMessage, getTrialTryAgainLaterMessage, } from '@/utils/upgradePresentation'; +import type { ResourceChange } from '@/types/resource'; type PatrolTab = 'findings' | 'history'; +function formatRecentChangeKind(kind: ResourceChange['kind']): string { + switch (kind) { + case 'state_transition': + return 'State transition'; + case 'restart': + return 'Restart'; + case 'config_update': + return 'Config change'; + case 'metric_anomaly': + return 'Metric anomaly'; + case 'relationship_change': + return 'Relationship change'; + case 'capability_change': + return 'Capability change'; + default: + return String(kind).replace(/_/g, ' '); + } +} + +function formatRecentChangeHeadline(change: ResourceChange): string { + if (change.kind === 'state_transition' && change.from && change.to) { + return `${formatRecentChangeKind(change.kind)}: ${change.from} → ${change.to}`; + } + if (change.kind === 'restart' && change.from && change.to) { + return `${formatRecentChangeKind(change.kind)}: ${change.from} → ${change.to}`; + } + if (change.reason) { + return `${formatRecentChangeKind(change.kind)}: ${change.reason}`; + } + return `${formatRecentChangeKind(change.kind)}: ${change.resourceId}`; +} + export function AIIntelligence() { const [activeTab, setActiveTab] = createSignal('findings'); const [findingsFilterOverride, setFindingsFilterOverride] = createSignal< @@ -537,6 +570,8 @@ export function AIIntelligence() { return getCanonicalScopeResourceIds(selectedRun()); }); + const intelligenceSummary = createMemo(() => aiIntelligenceStore.intelligenceSummary); + // Live in-progress run entry for history list const liveRunRecord = createMemo(() => { if (!shouldShowLiveRun()) return null; @@ -717,6 +752,7 @@ export function AIIntelligence() { setIsRefreshing(true); try { await Promise.all([ + aiIntelligenceStore.loadIntelligenceSummary(), aiIntelligenceStore.loadFindings(), aiIntelligenceStore.loadCircuitBreakerStatus(), aiIntelligenceStore.loadPendingApprovals(), @@ -1236,6 +1272,150 @@ export function AIIntelligence() { refreshTrigger={activityRefreshTrigger()} /> + + {(summary) => ( +
+
+
+

+ Canonical intelligence 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} + + + Recent changes {summary().recent_changes_count} + +
+
+ +
+
+
+

Recent changes

+ Canonical 24h timeline +
+ 0} + fallback={ +

+ No canonical changes were recorded in the last 24 hours. +

+ } + > +
    + + {(change) => ( +
  • +
    +
    +

    + {formatRecentChangeHeadline(change)} +

    +

    + {change.resourceId} + · + {formatRelativeTime(change.observedAt, { + compact: true, + emptyText: 'just now', + })} +

    +
    + +
    + + {formatRecentChangeKind(change.kind)} + + + {change.sourceType} + + + + {change.sourceAdapter} + + +
    +
    + + +

    {change.reason}

    +
    + + 0}> +

    + Related: {(change.relatedResources ?? []).slice(0, 3).join(', ')} +

    +
    +
  • + )} +
    +
+
+
+ +
+
+

Learning signals

+ + {summary().learning.resources_with_baselines} baselined + +
+ +
+
+
Knowledge
+
+ {summary().learning.resources_with_knowledge} +
+
+
+
Notes
+
+ {summary().learning.total_notes} +
+
+
+
Patterns
+
+ {summary().learning.patterns_detected} +
+
+
+
Correlations
+
+ {summary().learning.correlations_learned} +
+
+
+
Incidents
+
+ {summary().learning.incidents_tracked} +
+
+
+
Predictions
+
+ {summary().predictions_count} +
+
+
+
+
+
+ )} +
+ {/* Summary Cards */} ({ +const { findingsPanelState, runHistoryState, intelligenceState } = vi.hoisted(() => ({ findingsPanelState: { latestProps: null as { filterOverride?: string; @@ -16,6 +16,48 @@ const { findingsPanelState, runHistoryState } = vi.hoisted(() => ({ runHistoryState: { selection: null as Record | null, }, + intelligenceState: { + summary: null as + | { + timestamp: string; + overall_health: { + score: number; + grade: 'A' | 'B' | 'C' | 'D' | 'F'; + trend: 'improving' | 'stable' | 'declining'; + factors: Array>; + prediction: string; + }; + findings_count: { + critical: number; + warning: number; + watch: number; + info: number; + total: number; + }; + predictions_count: number; + recent_changes_count: number; + recent_changes: Array<{ + id: string; + observedAt: string; + resourceId: string; + kind: string; + sourceType: string; + sourceAdapter?: string; + confidence: string; + reason?: string; + relatedResources?: string[]; + }>; + learning: { + resources_with_knowledge: number; + total_notes: number; + resources_with_baselines: number; + patterns_detected: number; + correlations_learned: number; + incidents_tracked: number; + }; + } + | null, + }, })); const getPatrolStatusMock = vi.fn(); @@ -70,8 +112,12 @@ vi.mock('@/stores/aiIntelligence', () => ({ aiIntelligenceStore: { findings: [], loadFindings: vi.fn().mockResolvedValue(undefined), + loadIntelligenceSummary: vi.fn().mockResolvedValue(undefined), loadCircuitBreakerStatus: vi.fn().mockResolvedValue(undefined), loadPendingApprovals: vi.fn().mockResolvedValue(undefined), + get intelligenceSummary() { + return intelligenceState.summary; + }, }, })); @@ -206,6 +252,7 @@ describe('AIIntelligence entitlement gating', () => { notificationErrorMock.mockReset(); findingsPanelState.latestProps = null; runHistoryState.selection = null; + intelligenceState.summary = null; getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus()); getPatrolAutonomySettingsMock.mockResolvedValue({ @@ -307,6 +354,66 @@ describe('AIIntelligence entitlement gating', () => { expect(trackPaywallViewedMock).not.toHaveBeenCalled(); }); + it('renders the canonical intelligence summary card with recent changes', async () => { + hasFeatureMock.mockReturnValue(true); + licenseStatusMock.mockReturnValue({ subscription_state: 'active' }); + getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ license_required: false })); + intelligenceState.summary = { + timestamp: '2026-03-01T00:00:00Z', + overall_health: { + score: 91, + grade: 'A', + trend: 'stable', + factors: [], + prediction: 'Stable', + }, + findings_count: { + critical: 1, + warning: 2, + watch: 0, + info: 4, + total: 7, + }, + predictions_count: 3, + recent_changes_count: 1, + recent_changes: [ + { + id: 'change-1', + observedAt: '2026-03-01T00:00:00Z', + resourceId: 'vm-100', + kind: 'config_update', + sourceType: 'pulse_diff', + sourceAdapter: 'proxmox_adapter', + confidence: 'high', + reason: 'Updated guest configuration', + relatedResources: ['agent-1'], + }, + ], + learning: { + resources_with_knowledge: 4, + total_notes: 11, + resources_with_baselines: 3, + patterns_detected: 2, + correlations_learned: 1, + incidents_tracked: 5, + }, + }; + + render(() => ); + + await waitFor(() => { + expect(getPatrolStatusMock).toHaveBeenCalled(); + expect(screen.getByText('Canonical intelligence summary')).toBeInTheDocument(); + }); + + expect(screen.getByText(/Health A · 91\/100/)).toBeInTheDocument(); + expect(screen.getByText(/Recent changes 1/)).toBeInTheDocument(); + expect(screen.getByText('Config change: Updated guest configuration')).toBeInTheDocument(); + expect(screen.getByText('vm-100')).toBeInTheDocument(); + expect(screen.getByText('proxmox_adapter')).toBeInTheDocument(); + expect(screen.getByText('Learning signals')).toBeInTheDocument(); + }); + it('treats a selected zero-finding run as an empty snapshot and uses effective scope ids', async () => { hasFeatureMock.mockReturnValue(true); licenseStatusMock.mockReturnValue({ subscription_state: 'active' }); diff --git a/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts b/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts index a005ee4dd..2bb29890d 100644 --- a/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts +++ b/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts @@ -4,6 +4,7 @@ vi.mock('@/api/ai', () => ({ AIAPI: { getUnifiedFindings: vi.fn(), getPendingApprovals: vi.fn(), + getIntelligenceSummary: vi.fn(), }, })); @@ -60,6 +61,58 @@ describe('aiIntelligenceStore', () => { }); }); + it('loads the canonical intelligence summary', async () => { + vi.mocked(AIAPI.getIntelligenceSummary).mockResolvedValueOnce({ + timestamp: '2026-03-01T00:00:00Z', + overall_health: { + score: 87, + grade: 'B', + trend: 'stable', + factors: [], + prediction: 'Stable', + }, + findings_count: { + critical: 1, + warning: 2, + watch: 0, + info: 4, + total: 7, + }, + predictions_count: 3, + recent_changes_count: 1, + recent_changes: [ + { + id: 'change-1', + observedAt: '2026-03-01T00:00:00Z', + resourceId: 'vm-100', + kind: 'config_update', + sourceType: 'pulse_diff', + confidence: 'high', + }, + ], + learning: { + resources_with_knowledge: 4, + total_notes: 11, + resources_with_baselines: 3, + patterns_detected: 2, + correlations_learned: 1, + incidents_tracked: 5, + }, + }); + + await aiIntelligenceStore.loadIntelligenceSummary(); + + expect(aiIntelligenceStore.intelligenceSummary).toMatchObject({ + findings_count: { + critical: 1, + warning: 2, + total: 7, + }, + recent_changes_count: 1, + }); + expect(aiIntelligenceStore.intelligenceSummary?.recent_changes).toHaveLength(1); + }); + it('treats queued fixes without a live approval as findings needing attention', async () => { vi.mocked(AIAPI.getUnifiedFindings).mockResolvedValueOnce({ findings: [ diff --git a/frontend-modern/src/stores/aiIntelligence.ts b/frontend-modern/src/stores/aiIntelligence.ts index 9d0544be1..c60e56919 100644 --- a/frontend-modern/src/stores/aiIntelligence.ts +++ b/frontend-modern/src/stores/aiIntelligence.ts @@ -3,6 +3,7 @@ * * Central store for managing AI intelligence state: * - Unified findings (alerts + AI findings) + * - Canonical intelligence summary * - Remediation plans * - Circuit breaker status */ @@ -24,6 +25,7 @@ import { } from '@/utils/aiFindingPresentation'; import { getApprovalExpiryTime, isLivePendingApproval } from '@/utils/approvalState'; import { logger } from '@/utils/logger'; +import type { IntelligenceSummary } from '@/types/aiIntelligence'; // ============================================ // Enum validation helpers @@ -216,6 +218,14 @@ const [circuitBreakerStatus, setCircuitBreakerStatus] = createSignal( + null, +); + // ============================================ // Store API // ============================================ @@ -479,6 +489,24 @@ export const aiIntelligenceStore = { }, circuitBreakerStatusSignal: circuitBreakerStatus, + // Canonical Intelligence Summary + get intelligenceSummary() { + return intelligenceSummary(); + }, + intelligenceSummarySignal: intelligenceSummary, + + async loadIntelligenceSummary() { + try { + const summary = await AIAPI.getIntelligenceSummary(); + setIntelligenceSummary(summary); + return summary; + } catch (e) { + logger.error('Failed to load intelligence summary:', e); + setIntelligenceSummary(null); + return null; + } + }, + async loadCircuitBreakerStatus() { try { const status = await AIAPI.getCircuitBreakerStatus(); @@ -491,6 +519,7 @@ export const aiIntelligenceStore = { // Initialize - load all data async initialize() { await Promise.all([ + this.loadIntelligenceSummary(), this.loadFindings(), this.loadRemediationPlans(), this.loadCircuitBreakerStatus(), diff --git a/frontend-modern/src/types/aiIntelligence.ts b/frontend-modern/src/types/aiIntelligence.ts index bcb4889c1..a601836d9 100644 --- a/frontend-modern/src/types/aiIntelligence.ts +++ b/frontend-modern/src/types/aiIntelligence.ts @@ -4,10 +4,13 @@ * Shared type definitions for AI intelligence features: * - Anomaly detection (baseline deviation) * - Learning status (baseline progress) + * - Unified resource intelligence summaries * * Store logic lives in @/stores/aiIntelligence.ts */ +import type { ResourceChange } from '@/types/resource'; + // ============================================ // Anomaly Detection Types // ============================================ @@ -42,3 +45,60 @@ export interface LearningStatusResponse { message: string; license_required: boolean; } + +// ============================================ +// Unified Intelligence Summary Types +// ============================================ + +export interface IntelligenceHealthFactor { + name: string; + impact: number; + description: string; + category: string; +} + +export interface IntelligenceHealthScore { + score: number; + grade: 'A' | 'B' | 'C' | 'D' | 'F'; + trend: 'improving' | 'stable' | 'declining'; + factors: IntelligenceHealthFactor[]; + prediction: string; +} + +export interface IntelligenceFindingsCounts { + critical: number; + warning: number; + watch: number; + info: number; + total: number; +} + +export interface IntelligenceLearningStats { + resources_with_knowledge: number; + total_notes: number; + resources_with_baselines: number; + patterns_detected: number; + correlations_learned: number; + incidents_tracked: number; +} + +export interface IntelligenceSummary { + timestamp: string; + overall_health: IntelligenceHealthScore; + findings_count: IntelligenceFindingsCounts; + predictions_count: number; + recent_changes_count: number; + recent_changes?: ResourceChange[]; + recent_remediations?: Array>; + learning: IntelligenceLearningStats; + resources_at_risk?: Array>; +} + +export interface ResourceIntelligence { + resource_id: string; + resource_name?: string; + resource_type?: string; + health: IntelligenceHealthScore; + recent_changes?: ResourceChange[]; + note_count: number; +}