Surface canonical intelligence summary in frontend

This commit is contained in:
rcourtman
2026-03-19 00:24:32 +00:00
parent 771f2a3e41
commit 680e478c9e
10 changed files with 479 additions and 8 deletions
@@ -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.
@@ -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
@@ -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.
@@ -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,
+21 -1
View File
@@ -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<LearningStatusResponse>;
}
private static async fetchIntelligence(resourceId?: string): Promise<unknown> {
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<IntelligenceSummary> {
return (await this.fetchIntelligence()) as IntelligenceSummary;
}
// Get canonical intelligence for a single resource
static async getResourceIntelligence(resourceId: string): Promise<ResourceIntelligence> {
return (await this.fetchIntelligence(resourceId)) as ResourceIntelligence;
}
// Analyze a Kubernetes cluster with AI
static async analyzeKubernetesCluster(clusterId: string): Promise<AIExecuteResponse> {
return apiFetchJSON(`${this.baseUrl}/ai/kubernetes/analyze`, {
@@ -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<PatrolTab>('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<PatrolRunRecord | null>(() => {
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()}
/>
<Show when={intelligenceSummary()}>
{(summary) => (
<section class="rounded-md border border-border bg-surface p-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-muted">
Canonical intelligence summary
</p>
<h2 class="mt-1 text-lg font-semibold text-base-content">
Health {summary().overall_health.grade} ·{' '}
{Math.round(summary().overall_health.score)}/100
</h2>
<p class="mt-1 text-sm text-muted">{summary().overall_health.prediction}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border border-border-subtle bg-base px-2.5 py-1 text-xs font-medium text-base-content">
Critical {summary().findings_count.critical}
</span>
<span class="rounded-full border border-border-subtle bg-base px-2.5 py-1 text-xs font-medium text-base-content">
Warning {summary().findings_count.warning}
</span>
<span class="rounded-full border border-border-subtle bg-base px-2.5 py-1 text-xs font-medium text-base-content">
Recent changes {summary().recent_changes_count}
</span>
</div>
</div>
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold text-base-content">Recent changes</h3>
<span class="text-xs text-muted">Canonical 24h timeline</span>
</div>
<Show
when={(summary().recent_changes ?? []).length > 0}
fallback={
<p class="mt-3 text-sm text-muted">
No canonical changes were recorded in the last 24 hours.
</p>
}
>
<ul class="mt-3 space-y-2">
<For each={summary().recent_changes ?? []}>
{(change) => (
<li class="rounded-md border border-border-subtle bg-base p-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-base-content">
{formatRecentChangeHeadline(change)}
</p>
<p class="mt-1 text-xs text-muted">
<span class="font-mono">{change.resourceId}</span>
<span class="mx-1.5">·</span>
{formatRelativeTime(change.observedAt, {
compact: true,
emptyText: 'just now',
})}
</p>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="rounded-full border border-border-subtle bg-surface px-2 py-0.5 text-[11px] font-medium text-muted">
{formatRecentChangeKind(change.kind)}
</span>
<span class="rounded-full border border-border-subtle bg-surface px-2 py-0.5 text-[11px] font-medium text-muted">
{change.sourceType}
</span>
<Show when={change.sourceAdapter}>
<span class="rounded-full border border-border-subtle bg-surface px-2 py-0.5 text-[11px] font-medium text-muted">
{change.sourceAdapter}
</span>
</Show>
</div>
</div>
<Show when={change.reason}>
<p class="mt-2 text-xs text-muted">{change.reason}</p>
</Show>
<Show when={(change.relatedResources ?? []).length > 0}>
<p class="mt-2 text-xs text-muted">
Related: {(change.relatedResources ?? []).slice(0, 3).join(', ')}
</p>
</Show>
</li>
)}
</For>
</ul>
</Show>
</div>
<div class="rounded-md border border-border-subtle bg-base p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold text-base-content">Learning signals</h3>
<span class="text-xs text-muted">
{summary().learning.resources_with_baselines} baselined
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-2 text-sm">
<div class="rounded-md bg-surface px-3 py-2">
<dt class="text-xs uppercase tracking-wide text-muted">Knowledge</dt>
<dd class="mt-1 font-semibold text-base-content">
{summary().learning.resources_with_knowledge}
</dd>
</div>
<div class="rounded-md bg-surface px-3 py-2">
<dt class="text-xs uppercase tracking-wide text-muted">Notes</dt>
<dd class="mt-1 font-semibold text-base-content">
{summary().learning.total_notes}
</dd>
</div>
<div class="rounded-md bg-surface px-3 py-2">
<dt class="text-xs uppercase tracking-wide text-muted">Patterns</dt>
<dd class="mt-1 font-semibold text-base-content">
{summary().learning.patterns_detected}
</dd>
</div>
<div class="rounded-md bg-surface px-3 py-2">
<dt class="text-xs uppercase tracking-wide text-muted">Correlations</dt>
<dd class="mt-1 font-semibold text-base-content">
{summary().learning.correlations_learned}
</dd>
</div>
<div class="rounded-md bg-surface px-3 py-2">
<dt class="text-xs uppercase tracking-wide text-muted">Incidents</dt>
<dd class="mt-1 font-semibold text-base-content">
{summary().learning.incidents_tracked}
</dd>
</div>
<div class="rounded-md bg-surface px-3 py-2">
<dt class="text-xs uppercase tracking-wide text-muted">Predictions</dt>
<dd class="mt-1 font-semibold text-base-content">
{summary().predictions_count}
</dd>
</div>
</dl>
</div>
</div>
</section>
)}
</Show>
{/* Summary Cards */}
<Show
when={
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-li
import { AIIntelligence } from '../AIIntelligence';
const { findingsPanelState, runHistoryState } = vi.hoisted(() => ({
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<string, unknown> | null,
},
intelligenceState: {
summary: null as
| {
timestamp: string;
overall_health: {
score: number;
grade: 'A' | 'B' | 'C' | 'D' | 'F';
trend: 'improving' | 'stable' | 'declining';
factors: Array<Record<string, unknown>>;
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(() => <AIIntelligence />);
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' });
@@ -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: [
@@ -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<CircuitBrea
null,
);
// ============================================
// Canonical Intelligence Summary
// ============================================
const [intelligenceSummary, setIntelligenceSummary] = createSignal<IntelligenceSummary | null>(
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(),
@@ -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<Record<string, unknown>>;
learning: IntelligenceLearningStats;
resources_at_risk?: Array<Record<string, unknown>>;
}
export interface ResourceIntelligence {
resource_id: string;
resource_name?: string;
resource_type?: string;
health: IntelligenceHealthScore;
recent_changes?: ResourceChange[];
note_count: number;
}