Pulse/frontend-modern/src/api/patrol.ts
rcourtman 0c3dcf353a feat: Implement multi-provider AI support
Backend:
- Add per-provider API key fields to AIConfig (AnthropicAPIKey, OpenAIAPIKey, DeepSeekAPIKey, OllamaBaseURL, OpenAIBaseURL)
- Add NewForProvider() and NewForModel() factory functions for multi-provider instantiation
- Update ListModels() to aggregate models from all configured providers with provider:model format
- Update Execute/ExecuteStream to dynamically create provider based on selected model
- Update TestConnection to use multi-provider aware provider creation
- Add helper functions: HasProvider(), GetConfiguredProviders(), GetAPIKeyForProvider(), GetBaseURLForProvider(), ParseModelString(), FormatModelString()

Frontend:
- Remove legacy single-provider UI (provider grid, single API key input, single base URL)
- Add accordion-style UI for configuring all providers independently
- Add model grouping by provider in selectors using optgroup
- Update AIChat model dropdown with grouped provider sections
- Add helper functions for parsing provider from model ID and grouping models

API:
- Add multi-provider fields to AISettingsResponse and AISettingsUpdateRequest
- Add /api/ai/models endpoint for dynamic model listing
- Update settings handlers for per-provider credential management
2025-12-11 16:00:45 +00:00

379 lines
12 KiB
TypeScript

/**
* AI Patrol API client
* Provides access to background AI monitoring findings and status
*/
import { apiFetchJSON } from '@/utils/apiClient';
export type FindingSeverity = 'info' | 'watch' | 'warning' | 'critical';
export type FindingCategory = 'performance' | 'capacity' | 'reliability' | 'backup' | 'security' | 'general';
export interface Finding {
id: string;
severity: FindingSeverity;
category: FindingCategory;
resource_id: string;
resource_name: string;
resource_type: string; // node, vm, container, docker_host, docker_container, storage, pbs, host_raid
node?: string;
title: string;
description: string;
recommendation?: string;
evidence?: string;
detected_at: string;
last_seen_at: string;
resolved_at?: string;
auto_resolved: boolean;
acknowledged_at?: string;
snoozed_until?: string; // Finding hidden until this time
alert_id?: string;
// User feedback fields (LLM memory system)
dismissed_reason?: 'not_an_issue' | 'expected_behavior' | 'will_fix_later';
user_note?: string;
times_raised: number;
suppressed: boolean;
}
export interface FindingsSummary {
critical: number;
warning: number;
watch: number;
info: number;
}
export interface PatrolStatus {
running: boolean;
enabled: boolean;
last_patrol_at?: string;
last_deep_analysis_at?: string;
next_patrol_at?: string;
last_duration_ms: number;
resources_checked: number;
findings_count: number;
error_count: number;
healthy: boolean;
interval_ms: number; // Patrol interval in milliseconds
summary: FindingsSummary;
}
export interface PatrolRunRecord {
id: string;
started_at: string;
completed_at: string;
duration_ms: number;
type: string; // Always 'patrol' now (kept for backwards compat with old records)
resources_checked: number;
// Breakdown by resource type
nodes_checked: number;
guests_checked: number;
docker_checked: number;
storage_checked: number;
hosts_checked: number;
pbs_checked: number;
// Findings from this run
new_findings: number;
existing_findings: number;
resolved_findings: number;
findings_summary: string;
finding_ids: string[];
error_count: number;
status: 'healthy' | 'issues_found' | 'critical' | 'error';
// AI Analysis details
ai_analysis?: string; // The AI's raw response/analysis
input_tokens?: number; // Tokens sent to AI
output_tokens?: number; // Tokens received from AI
}
/**
* Get the current AI patrol status
*/
export async function getPatrolStatus(): Promise<PatrolStatus> {
const resp = await fetch('/api/ai/patrol/status', {
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get patrol status: ${resp.status}`);
}
return resp.json();
}
/**
* Get all active findings from the patrol service
* Optionally filter by resource ID
*/
export async function getFindings(resourceId?: string): Promise<Finding[]> {
const url = resourceId
? `/api/ai/patrol/findings?resource_id=${encodeURIComponent(resourceId)}`
: '/api/ai/patrol/findings';
const resp = await fetch(url, {
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get findings: ${resp.status}`);
}
return resp.json();
}
/**
* Trigger an immediate patrol run
*/
export async function forcePatrol(deep: boolean = false): Promise<{ success: boolean; message: string }> {
const url = deep ? '/api/ai/patrol/run?deep=true' : '/api/ai/patrol/run';
return apiFetchJSON(url, { method: 'POST' });
}
/**
* Get AI findings history including resolved findings
* @param startTime Optional ISO timestamp to filter findings from
*/
export async function getFindingsHistory(startTime?: string): Promise<Finding[]> {
const url = startTime
? `/api/ai/patrol/history?start_time=${encodeURIComponent(startTime)}`
: '/api/ai/patrol/history';
const resp = await fetch(url, {
method: 'GET',
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get findings history: ${resp.status}`);
}
return resp.json();
}
/**
* Get the history of patrol check runs
* @param limit Maximum number of records to return (default: 50, max: 100)
*/
export async function getPatrolRunHistory(limit?: number): Promise<PatrolRunRecord[]> {
const url = limit
? `/api/ai/patrol/runs?limit=${limit}`
: '/api/ai/patrol/runs';
const resp = await fetch(url, {
method: 'GET',
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get patrol run history: ${resp.status}`);
}
return resp.json();
}
/**
* Acknowledge a finding (marks as seen but keeps visible, like alert acknowledgement)
* Finding will auto-resolve when the underlying condition clears.
*/
export async function acknowledgeFinding(findingId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/acknowledge', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId }),
});
}
/**
* Snooze a finding for a specified duration
* @param findingId The ID of the finding to snooze
* @param durationHours Duration in hours (e.g., 1, 24, 168 for 7 days)
*/
export async function snoozeFinding(findingId: string, durationHours: number): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/snooze', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId, duration_hours: durationHours }),
});
}
/**
* Manually resolve a finding (mark as fixed)
* @param findingId The ID of the finding to resolve
*/
export async function resolveFinding(findingId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/resolve', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId }),
});
}
/**
* Dismiss a finding with a reason (LLM memory feature)
* The LLM will be told not to re-raise this issue in future patrols.
* @param findingId The ID of the finding to dismiss
* @param reason One of: "not_an_issue", "expected_behavior", "will_fix_later"
* @param note Optional freeform explanation
*/
export async function dismissFinding(
findingId: string,
reason: 'not_an_issue' | 'expected_behavior' | 'will_fix_later',
note?: string
): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/dismiss', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId, reason, note }),
});
}
/**
* Permanently suppress a finding type (LLM memory feature)
* The LLM will never re-raise this type of finding for this resource.
* @param findingId The ID of the finding to suppress
*/
export async function suppressFinding(findingId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/suppress', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId }),
});
}
/**
* Severity color mapping for UI
*/
export const severityColors: Record<FindingSeverity, { bg: string; text: string; border: string }> = {
critical: { bg: 'rgba(220, 38, 38, 0.15)', text: '#ef4444', border: 'rgba(220, 38, 38, 0.3)' },
warning: { bg: 'rgba(234, 179, 8, 0.15)', text: '#eab308', border: 'rgba(234, 179, 8, 0.3)' },
watch: { bg: 'rgba(59, 130, 246, 0.15)', text: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' },
info: { bg: 'rgba(107, 114, 128, 0.15)', text: '#9ca3af', border: 'rgba(107, 114, 128, 0.3)' },
};
/**
* Category labels for UI
*/
export const categoryLabels: Record<FindingCategory, string> = {
performance: 'Performance',
capacity: 'Capacity',
reliability: 'Reliability',
backup: 'Backup',
security: 'Security',
general: 'General',
};
/**
* Format a timestamp for display
*/
export function formatTimestamp(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Event types from patrol stream
*/
export interface PatrolStreamEvent {
type: 'start' | 'content' | 'thinking' | 'phase' | 'complete' | 'error';
content?: string;
phase?: string;
tokens?: number;
}
/**
* Subscribe to live patrol stream via SSE
* Returns an unsubscribe function
*/
export function subscribeToPatrolStream(
onEvent: (event: PatrolStreamEvent) => void,
onError?: (error: Error) => void
): () => void {
const eventSource = new EventSource('/api/ai/patrol/stream', { withCredentials: true });
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as PatrolStreamEvent;
onEvent(data);
} catch (e) {
console.error('Failed to parse patrol stream event:', e);
}
};
eventSource.onerror = () => {
if (onError) {
onError(new Error('Patrol stream connection error'));
}
eventSource.close();
};
// Return unsubscribe function
return () => {
eventSource.close();
};
}
// === Suppression Rules ===
export interface SuppressionRule {
id: string;
resource_id?: string; // Empty means "any resource"
resource_name?: string; // Human-readable name
category?: FindingCategory; // Empty means "any category"
description: string; // User's reason
created_at: string;
created_from: 'finding' | 'manual';
finding_id?: string; // Original finding ID if created from dismissal
}
/**
* Get all suppression rules (both manual and from dismissed findings)
*/
export async function getSuppressionRules(): Promise<SuppressionRule[]> {
const resp = await fetch('/api/ai/patrol/suppressions', {
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get suppression rules: ${resp.status}`);
}
return resp.json();
}
/**
* Create a new manual suppression rule
* @param resourceId Resource ID (empty for "any resource")
* @param resourceName Human-readable name for display
* @param category Category (empty for "any category")
* @param description User's reason for the rule
*/
export async function addSuppressionRule(
resourceId: string,
resourceName: string,
category: FindingCategory | '',
description: string
): Promise<{ success: boolean; message: string; rule: SuppressionRule }> {
return apiFetchJSON('/api/ai/patrol/suppressions', {
method: 'POST',
body: JSON.stringify({
resource_id: resourceId,
resource_name: resourceName,
category: category,
description: description,
}),
});
}
/**
* Delete a suppression rule
* @param ruleId The ID of the rule to delete
*/
export async function deleteSuppressionRule(ruleId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON(`/api/ai/patrol/suppressions/${encodeURIComponent(ruleId)}`, {
method: 'DELETE',
});
}
/**
* Get all dismissed/suppressed findings
*/
export async function getDismissedFindings(): Promise<Finding[]> {
const resp = await fetch('/api/ai/patrol/dismissed', {
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get dismissed findings: ${resp.status}`);
}
return resp.json();
}