mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-04 13:16:01 +00:00
Surface canonical intelligence summary in frontend
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user