diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index a4588b233..109804143 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -245,17 +245,17 @@ export class AIAPI { return apiFetchJSON(`${this.baseUrl}/ai/remediation/plan?plan_id=${planId}`) as Promise; } - static async approveRemediationPlan(planId: string): Promise<{ success: boolean }> { + static async approveRemediationPlan(planId: string): Promise<{ success: boolean; execution?: { id: string } }> { return apiFetchJSON(`${this.baseUrl}/ai/remediation/approve`, { method: 'POST', body: JSON.stringify({ plan_id: planId }), - }) as Promise<{ success: boolean }>; + }) as Promise<{ success: boolean; execution?: { id: string } }>; } - static async executeRemediationPlan(planId: string): Promise { + static async executeRemediationPlan(executionId: string): Promise { return apiFetchJSON(`${this.baseUrl}/ai/remediation/execute`, { method: 'POST', - body: JSON.stringify({ plan_id: planId }), + body: JSON.stringify({ execution_id: executionId }), }) as Promise; } @@ -270,6 +270,47 @@ export class AIAPI { static async getCircuitBreakerStatus(): Promise { return apiFetchJSON(`${this.baseUrl}/ai/circuit/status`) as Promise; } + + // ============================================ + // Investigation Fix Approvals + // ============================================ + + // Get pending approval requests (investigation fixes waiting for user approval) + static async getPendingApprovals(): Promise { + const response = await apiFetchJSON(`${this.baseUrl}/ai/approvals`) as { approvals: ApprovalRequest[] }; + return response.approvals || []; + } + + // Approve and execute an investigation fix + static async approveInvestigationFix(approvalId: string): Promise { + return apiFetchJSON(`${this.baseUrl}/ai/approvals/${approvalId}/approve`, { + method: 'POST', + }) as Promise; + } + + // Deny an investigation fix + static async denyInvestigationFix(approvalId: string, reason?: string): Promise { + return apiFetchJSON(`${this.baseUrl}/ai/approvals/${approvalId}/deny`, { + method: 'POST', + body: JSON.stringify({ reason: reason || 'User declined' }), + }) as Promise; + } + + // Get investigation details for a finding (includes proposed fix) + static async getInvestigation(findingId: string): Promise { + try { + return await apiFetchJSON(`${this.baseUrl}/ai/findings/${findingId}/investigation`) as InvestigationSession; + } catch { + return null; + } + } + + // Re-create an approval for an investigation fix (when original approval expired) + static async reapproveInvestigationFix(findingId: string): Promise<{ approval_id: string; message: string }> { + return apiFetchJSON(`${this.baseUrl}/ai/findings/${findingId}/reapprove`, { + method: 'POST', + }) as Promise<{ approval_id: string; message: string }>; + } } // ============================================ @@ -343,12 +384,41 @@ export interface RemediationStep { risk_level: 'low' | 'medium' | 'high'; } +export interface StepResult { + step: number; + success: boolean; + output?: string; + error?: string; + duration_ms: number; + run_at: string; +} + +export interface RemediationExecution { + id: string; + plan_id: string; + status: 'pending' | 'approved' | 'running' | 'completed' | 'failed' | 'rolled_back'; + approved_by?: string; + approved_at?: string; + started_at?: string; + completed_at?: string; + current_step: number; + step_results?: StepResult[]; + error?: string; + rollback_error?: string; +} + +// Legacy type for backwards compatibility export interface RemediationExecutionResult { execution_id: string; plan_id: string; status: 'success' | 'failed' | 'partial'; steps_completed: number; error?: string; + // Full execution details from backend + id?: string; + step_results?: StepResult[]; + started_at?: string; + completed_at?: string; } export interface CircuitBreakerStatus { @@ -358,3 +428,74 @@ export interface CircuitBreakerStatus { total_successes: number; total_failures: number; } + +// ============================================ +// Investigation Fix Approval Types +// ============================================ + +export type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'expired'; +export type RiskLevel = 'low' | 'medium' | 'high'; + +export interface ApprovalRequest { + id: string; + executionId?: string; + toolId: string; // "investigation_fix" for patrol findings + command: string; + targetType: string; + targetId: string; + targetName: string; + context: string; + riskLevel: RiskLevel; + status: ApprovalStatus; + requestedAt: string; + expiresAt: string; + decidedAt?: string; + decidedBy?: string; + denyReason?: string; +} + +export interface ApprovalExecutionResult { + approved: boolean; + executed: boolean; + success: boolean; + output: string; + exit_code: number; + error?: string; + finding_id: string; + message: string; +} + +// ============================================ +// Investigation Session Types +// ============================================ + +export type InvestigationStatus = 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention'; +export type InvestigationOutcome = 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix'; + +export interface ProposedFix { + id: string; + description: string; + commands?: string[]; + risk_level?: 'low' | 'medium' | 'high' | 'critical'; + destructive: boolean; + target_host?: string; + rationale?: string; +} + +export interface InvestigationSession { + id: string; + finding_id: string; + session_id: string; + status: InvestigationStatus; + started_at: string; + completed_at?: string; + turn_count: number; + outcome?: InvestigationOutcome; + tools_available?: string[]; + tools_used?: string[]; + evidence_ids?: string[]; + proposed_fix?: ProposedFix; + approval_id?: string; + summary?: string; + error?: string; +} diff --git a/frontend-modern/src/api/discovery.ts b/frontend-modern/src/api/discovery.ts new file mode 100644 index 000000000..2301685b7 --- /dev/null +++ b/frontend-modern/src/api/discovery.ts @@ -0,0 +1,227 @@ +import { apiFetch } from '@/utils/apiClient'; +import type { + ResourceType, + ResourceDiscovery, + DiscoveryListResponse, + DiscoveryProgress, + DiscoveryStatus, + TriggerDiscoveryRequest, + UpdateNotesRequest, +} from '../types/discovery'; + +const API_BASE = '/api/discovery'; + +/** + * List all discoveries + */ +export async function listDiscoveries(): Promise { + const response = await apiFetch(API_BASE); + if (!response.ok) { + throw new Error('Failed to list discoveries'); + } + return response.json(); +} + +/** + * List discoveries by resource type + */ +export async function listDiscoveriesByType( + resourceType: ResourceType +): Promise { + const response = await apiFetch(`${API_BASE}/type/${resourceType}`); + if (!response.ok) { + throw new Error(`Failed to list discoveries for type ${resourceType}`); + } + return response.json(); +} + +/** + * List discoveries by host + */ +export async function listDiscoveriesByHost(hostId: string): Promise { + const response = await apiFetch(`${API_BASE}/host/${encodeURIComponent(hostId)}`); + if (!response.ok) { + throw new Error(`Failed to list discoveries for host ${hostId}`); + } + return response.json(); +} + +/** + * Get a specific discovery + */ +export async function getDiscovery( + resourceType: ResourceType, + hostId: string, + resourceId: string +): Promise { + const response = await apiFetch( + `${API_BASE}/${resourceType}/${encodeURIComponent(hostId)}/${encodeURIComponent(resourceId)}` + ); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error('Failed to get discovery'); + } + return response.json(); +} + +/** + * Trigger discovery for a resource + */ +export async function triggerDiscovery( + resourceType: ResourceType, + hostId: string, + resourceId: string, + options?: TriggerDiscoveryRequest +): Promise { + const response = await apiFetch( + `${API_BASE}/${resourceType}/${encodeURIComponent(hostId)}/${encodeURIComponent(resourceId)}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(options || {}), + } + ); + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Discovery failed' })); + throw new Error(error.message || 'Discovery failed'); + } + return response.json(); +} + +/** + * Get discovery progress + */ +export async function getDiscoveryProgress( + resourceType: ResourceType, + hostId: string, + resourceId: string +): Promise { + const response = await apiFetch( + `${API_BASE}/${resourceType}/${encodeURIComponent(hostId)}/${encodeURIComponent(resourceId)}/progress` + ); + if (!response.ok) { + throw new Error('Failed to get discovery progress'); + } + return response.json(); +} + +/** + * Update user notes for a discovery + */ +export async function updateDiscoveryNotes( + resourceType: ResourceType, + hostId: string, + resourceId: string, + notes: UpdateNotesRequest +): Promise { + const response = await apiFetch( + `${API_BASE}/${resourceType}/${encodeURIComponent(hostId)}/${encodeURIComponent(resourceId)}/notes`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(notes), + } + ); + if (!response.ok) { + throw new Error('Failed to update notes'); + } + return response.json(); +} + +/** + * Delete a discovery + */ +export async function deleteDiscovery( + resourceType: ResourceType, + hostId: string, + resourceId: string +): Promise { + const response = await apiFetch( + `${API_BASE}/${resourceType}/${encodeURIComponent(hostId)}/${encodeURIComponent(resourceId)}`, + { + method: 'DELETE', + } + ); + if (!response.ok) { + throw new Error('Failed to delete discovery'); + } +} + +/** + * Get discovery service status + */ +export async function getDiscoveryStatus(): Promise { + const response = await apiFetch(`${API_BASE}/status`); + if (!response.ok) { + throw new Error('Failed to get discovery status'); + } + return response.json(); +} + +/** + * Helper to format the last updated time + */ +export function formatDiscoveryAge(updatedAt: string): string { + if (!updatedAt) return 'Unknown'; + + const updated = new Date(updatedAt); + const now = new Date(); + const diffMs = now.getTime() - updated.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return 'Just now'; + if (diffMins === 1) return '1 minute ago'; + if (diffMins < 60) return `${diffMins} minutes ago`; + if (diffHours === 1) return '1 hour ago'; + if (diffHours < 24) return `${diffHours} hours ago`; + if (diffDays === 1) return '1 day ago'; + return `${diffDays} days ago`; +} + +/** + * Helper to get a human-readable category name + */ +export function getCategoryDisplayName(category: string): string { + const names: Record = { + database: 'Database', + web_server: 'Web Server', + cache: 'Cache', + message_queue: 'Message Queue', + monitoring: 'Monitoring', + backup: 'Backup', + nvr: 'NVR', + storage: 'Storage', + container: 'Container', + virtualizer: 'Virtualizer', + network: 'Network', + security: 'Security', + media: 'Media', + home_automation: 'Home Automation', + unknown: 'Unknown', + }; + return names[category] || category; +} + +/** + * Helper to get confidence level description + */ +export function getConfidenceLevel(confidence: number): { + label: string; + color: string; +} { + if (confidence >= 0.9) { + return { label: 'High confidence', color: 'text-green-600 dark:text-green-400' }; + } + if (confidence >= 0.7) { + return { label: 'Medium confidence', color: 'text-amber-600 dark:text-amber-400' }; + } + return { label: 'Low confidence', color: 'text-gray-500 dark:text-gray-400' }; +} diff --git a/frontend-modern/src/api/monitoring.ts b/frontend-modern/src/api/monitoring.ts index 472ea257f..2ac60d36a 100644 --- a/frontend-modern/src/api/monitoring.ts +++ b/frontend-modern/src/api/monitoring.ts @@ -1,4 +1,4 @@ -import type { State, Performance, Stats, DockerHostCommand, HostLookupResponse } from '@/types/api'; +import type { State, Performance, Stats, DockerHostCommand, HostLookupResponse, StorageConfigEntry } from '@/types/api'; import { apiFetch, apiFetchJSON } from '@/utils/apiClient'; export class MonitoringAPI { @@ -21,6 +21,21 @@ export class MonitoringAPI { return response.blob(); } + static async getStorageConfig(params?: { + instance?: string; + node?: string; + storageId?: string; + }): Promise { + const query = new URLSearchParams(); + if (params?.instance) query.set('instance', params.instance); + if (params?.node) query.set('node', params.node); + if (params?.storageId) query.set('storage_id', params.storageId); + const qs = query.toString(); + const url = `${this.baseUrl}/storage/config${qs ? `?${qs}` : ''}`; + const resp = await apiFetchJSON(url) as { storages?: StorageConfigEntry[] }; + return resp?.storages ?? []; + } + static async deleteDockerHost( hostId: string, options: { hide?: boolean; force?: boolean } = {} @@ -692,4 +707,3 @@ export interface UpdateDockerContainerResponse { message?: string; note?: string; } - diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index 2d04f0d81..1517b991b 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -43,13 +43,13 @@ export interface Finding { export type InvestigationStatus = 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention'; export type InvestigationOutcome = 'resolved' | 'fix_queued' | 'fix_executed' | 'fix_failed' | 'needs_attention' | 'cannot_fix'; -export type PatrolAutonomyLevel = 'monitor' | 'approval' | 'full'; +export type PatrolAutonomyLevel = 'monitor' | 'approval' | 'assisted' | 'full'; export interface PatrolAutonomySettings { autonomy_level: PatrolAutonomyLevel; + full_mode_unlocked: boolean; // User has acknowledged Full mode risks investigation_budget: number; // Max turns per investigation (5-30) investigation_timeout_sec: number; // Max seconds per investigation (60-600) - critical_require_approval: boolean; // Critical findings always require approval } export interface Investigation { @@ -61,6 +61,9 @@ export interface Investigation { completed_at?: string; turn_count: number; outcome?: InvestigationOutcome; + tools_available?: string[]; + tools_used?: string[]; + evidence_ids?: string[]; summary?: string; error?: string; proposed_fix?: ProposedFix; @@ -112,6 +115,8 @@ export interface PatrolStatus { healthy: boolean; interval_ms: number; // Patrol interval in milliseconds fixed_count: number; // Number of issues auto-fixed by Patrol + blocked_reason?: string; + blocked_at?: string; license_required?: boolean; license_status?: LicenseStatus; upgrade_url?: string; @@ -278,3 +283,61 @@ export const investigationOutcomeLabels: Record = needs_attention: 'Needs Attention', cannot_fix: 'Cannot Auto-Fix', }; + +// ============================================================================= +// Patrol Run History APIs +// ============================================================================= + +export type PatrolRunStatus = 'healthy' | 'issues_found' | 'critical' | 'error'; + +export interface PatrolRunRecord { + id: string; + started_at: string; + completed_at: string; + duration_ms: number; + type: string; + trigger_reason?: string; + scope_resource_ids?: string[]; + scope_resource_types?: string[]; + scope_depth?: string; + scope_context?: string; + alert_id?: string; + finding_id?: string; + resources_checked: number; + nodes_checked: number; + guests_checked: number; + docker_checked: number; + storage_checked: number; + hosts_checked: number; + pbs_checked: number; + kubernetes_checked: number; + new_findings: number; + existing_findings: number; + resolved_findings: number; + auto_fix_count: number; + findings_summary: string; + finding_ids: string[]; + error_count: number; + status: PatrolRunStatus; + ai_analysis?: string; + input_tokens?: number; + output_tokens?: number; +} + +/** + * Get patrol run history + * @param limit Maximum number of runs to return (default: 30) + */ +export async function getPatrolRunHistory(limit: number = 30): Promise { + const runs = await apiFetchJSON(`/api/ai/patrol/runs?limit=${limit}`); + return runs || []; +} + +/** + * Trigger a manual patrol run + */ +export async function triggerPatrolRun(): Promise<{ success: boolean; message: string }> { + return apiFetchJSON('/api/ai/patrol/run', { + method: 'POST', + }); +} diff --git a/frontend-modern/src/components/AI/AIStatusIndicator.tsx b/frontend-modern/src/components/AI/AIStatusIndicator.tsx index c9b2f08f5..c4df937ef 100644 --- a/frontend-modern/src/components/AI/AIStatusIndicator.tsx +++ b/frontend-modern/src/components/AI/AIStatusIndicator.tsx @@ -73,6 +73,16 @@ export function AIStatusIndicator() { return !hasAnomalies() && (counts.medium > 0 || counts.low > 0); }); + const isBlocked = createMemo(() => { + const s = status(); + return !!s?.blocked_reason; + }); + + const hasErrors = createMemo(() => { + const s = status(); + return (s?.error_count ?? 0) > 0; + }); + const totalFindings = createMemo(() => { const s = status(); if (!s) return 0; @@ -85,6 +95,7 @@ export function AIStatusIndicator() { // Patrol status const s = status(); if (s?.enabled && s?.running) { + if (s.error_count && s.error_count > 0) parts.push(`${s.error_count} patrol errors`); if (s.summary.critical > 0) parts.push(`${s.summary.critical} critical findings`); if (s.summary.warning > 0) parts.push(`${s.summary.warning} warnings`); if (s.summary.watch > 0) parts.push(`${s.summary.watch} watching`); @@ -110,6 +121,9 @@ export function AIStatusIndicator() { } if (parts.length === 0) { + if (s?.blocked_reason) { + return `Pulse Patrol paused: ${s.blocked_reason}`; + } if (!s?.enabled) { // Show baseline info even when patrol disabled if (resourceCount > 0) { @@ -134,7 +148,8 @@ export function AIStatusIndicator() { const statusClass = createMemo(() => { - if (hasIssues() || hasAnomalies()) return 'ai-status--issues'; + if (hasIssues() || hasAnomalies() || hasErrors()) return 'ai-status--issues'; + if (isBlocked()) return 'ai-status--watch'; if (hasWatch() || hasMildAnomalies()) return 'ai-status--watch'; return 'ai-status--healthy'; }); diff --git a/frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx b/frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx new file mode 100644 index 000000000..a92df50db --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx @@ -0,0 +1,189 @@ +import { createSignal, createEffect, For, Show, onCleanup } from 'solid-js'; + +export interface MentionResource { + id: string; + name: string; + type: 'vm' | 'container' | 'node' | 'storage' | 'docker' | 'host'; + status?: string; + node?: string; +} + +interface MentionAutocompleteProps { + query: string; + resources: MentionResource[]; + position: { top: number; left: number }; + onSelect: (resource: MentionResource) => void; + onClose: () => void; + visible: boolean; +} + +export function MentionAutocomplete(props: MentionAutocompleteProps) { + const [selectedIndex, setSelectedIndex] = createSignal(0); + + // Filter resources based on query + const filteredResources = () => { + const q = props.query.toLowerCase(); + if (!q) return props.resources.slice(0, 10); // Show first 10 if no query + + return props.resources + .filter(r => r.name.toLowerCase().includes(q)) + .slice(0, 10); // Limit to 10 results + }; + + // Reset selection when query changes + createEffect(() => { + props.query; // Track query + setSelectedIndex(0); + }); + + // Handle keyboard navigation + const handleKeyDown = (e: KeyboardEvent) => { + if (!props.visible) return; + + const resources = filteredResources(); + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(i => Math.min(i + 1, resources.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(i => Math.max(i - 1, 0)); + break; + case 'Enter': + case 'Tab': + e.preventDefault(); + if (resources[selectedIndex()]) { + props.onSelect(resources[selectedIndex()]); + } + break; + case 'Escape': + e.preventDefault(); + props.onClose(); + break; + } + }; + + // Register keyboard listener when visible + createEffect(() => { + if (props.visible) { + document.addEventListener('keydown', handleKeyDown); + onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); + } + }); + + // Get icon for resource type + const getTypeIcon = (type: string) => { + switch (type) { + case 'vm': + return ( + + + + ); + case 'container': + return ( + + + + ); + case 'docker': + return ( + + + + ); + case 'node': + return ( + + + + ); + case 'host': + return ( + + + + ); + default: + return ( + + + + ); + } + }; + + // Get status color + const getStatusColor = (status?: string) => { + switch (status) { + case 'running': + return 'bg-green-500'; + case 'stopped': + return 'bg-red-500'; + case 'paused': + return 'bg-yellow-500'; + default: + return 'bg-gray-400'; + } + }; + + return ( + 0}> +
+
+ Resources +
+
+ + {(resource, index) => ( + + )} + +
+
+ ↑↓ + navigate + + select + esc + close +
+
+
+ ); +} diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index 9f9c08e09..b6a2c4a96 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -23,6 +23,7 @@ export const ToolExecutionBlock: Component = (props) => if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns'; if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks'; if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage'; + if (name === 'pulse_get_storage_config') return 'storage cfg'; if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource'; if (name.includes('finding')) return 'finding'; return name.replace(/^pulse_/, '').replace(/_/g, ' ').substring(0, 12); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 80061b355..62fe8400d 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -1,12 +1,14 @@ import { Component, Show, createSignal, onMount, onCleanup, For, createMemo, createEffect } from 'solid-js'; import { AIAPI } from '@/api/ai'; import { AIChatAPI, type ChatSession } from '@/api/aiChat'; +import { MonitoringAPI } from '@/api/monitoring'; import { notificationStore } from '@/stores/notifications'; import { aiChatStore } from '@/stores/aiChat'; import { logger } from '@/utils/logger'; import { useChat } from './hooks/useChat'; import { ChatMessages } from './ChatMessages'; import { ModelSelector } from './ModelSelector'; +import { MentionAutocomplete, type MentionResource } from './MentionAutocomplete'; import type { PendingApproval, PendingQuestion, ModelInfo } from './types'; const MODEL_LEGACY_STORAGE_KEY = 'pulse:ai_chat_model'; @@ -39,6 +41,15 @@ export const AIChat: Component = (props) => { const [controlLevel, setControlLevel] = createSignal<'read_only' | 'controlled' | 'autonomous'>('read_only'); const [showControlMenu, setShowControlMenu] = createSignal(false); const [controlSaving, setControlSaving] = createSignal(false); + const [discoveryEnabled, setDiscoveryEnabled] = createSignal(null); // null = loading + const [discoveryHintDismissed, setDiscoveryHintDismissed] = createSignal(false); + + // @ mention autocomplete state + const [mentionActive, setMentionActive] = createSignal(false); + const [mentionQuery, setMentionQuery] = createSignal(''); + const [mentionStartIndex, setMentionStartIndex] = createSignal(0); + const [mentionResources, setMentionResources] = createSignal([]); + let textareaRef: HTMLTextAreaElement | undefined; const loadModelSelections = (): Record => { try { @@ -179,6 +190,7 @@ export const AIChat: Component = (props) => { setDefaultModel(fallback); setChatOverrideModel(chatOverride); setControlLevel(resolvedControl); + setDiscoveryEnabled(settings.discovery_enabled ?? false); } catch (error) { logger.error('[AIChat] Failed to load AI settings:', error); } @@ -291,11 +303,91 @@ export const AIChat: Component = (props) => { setShowSessions(false); setShowControlMenu(false); } + // Close mention autocomplete when clicking outside + if (!target.closest('[data-mention-autocomplete]') && !target.closest('textarea')) { + setMentionActive(false); + } }; document.addEventListener('click', handleClickOutside); onCleanup(() => document.removeEventListener('click', handleClickOutside)); }); + // Fetch resources for @ mention autocomplete + onMount(async () => { + try { + const state = await MonitoringAPI.getState(); + const resources: MentionResource[] = []; + + // Add VMs + for (const vm of state.vms || []) { + resources.push({ + id: `vm:${vm.node}:${vm.vmid}`, + name: vm.name, + type: 'vm', + status: vm.status, + node: vm.node, + }); + } + + // Add LXC containers + for (const container of state.containers || []) { + resources.push({ + id: `lxc:${container.node}:${container.vmid}`, + name: container.name, + type: 'container', + status: container.status, + node: container.node, + }); + } + + // Add Docker hosts + for (const host of state.dockerHosts || []) { + resources.push({ + id: `host:${host.id}`, + name: host.displayName || host.hostname || host.id, + type: 'host', + status: host.status || 'online', + }); + // Add Docker containers + for (const container of host.containers || []) { + resources.push({ + id: `docker:${host.id}:${container.id}`, + name: container.name, + type: 'docker', + status: container.state, + node: host.hostname || host.id, + }); + } + } + + // Add nodes + for (const node of state.nodes || []) { + resources.push({ + id: `node:${node.instance}:${node.name}`, + name: node.name, + type: 'node', + status: node.status, + }); + } + + // Add standalone host agents + for (const host of state.hosts || []) { + resources.push({ + id: `host:${host.id}`, + name: host.displayName || host.hostname, + type: 'host', + status: host.status, + }); + } + + setMentionResources(resources); + console.log('[AIChat] Loaded', resources.length, 'resources for @ mention autocomplete'); + } catch (error) { + logger.error('[AIChat] Failed to fetch resources for autocomplete:', error); + console.error('[AIChat] Failed to fetch resources:', error); + } + }); + // Handle submit const handleSubmit = () => { if (chat.isLoading()) return; @@ -303,10 +395,72 @@ export const AIChat: Component = (props) => { if (!prompt) return; chat.sendMessage(prompt); setInput(''); + setMentionActive(false); }; - // Handle key down - submit when not loading + // Handle input change with @ mention detection + const handleInputChange = (e: InputEvent & { currentTarget: HTMLTextAreaElement }) => { + const value = e.currentTarget.value; + setInput(value); + + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = value.slice(0, cursorPos); + + // Find the last @ before cursor + const lastAtIndex = textBeforeCursor.lastIndexOf('@'); + + if (lastAtIndex !== -1) { + // Check if @ is at start or preceded by whitespace + const charBefore = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : ' '; + if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) { + const query = textBeforeCursor.slice(lastAtIndex + 1); + // Only activate if query doesn't contain spaces (still typing the mention) + if (!query.includes(' ')) { + setMentionActive(true); + setMentionQuery(query); + setMentionStartIndex(lastAtIndex); + return; + } + } + } + + setMentionActive(false); + }; + + // Handle mention selection + const handleMentionSelect = (resource: MentionResource) => { + const currentInput = input(); + const startIndex = mentionStartIndex(); + const cursorPos = textareaRef?.selectionStart || currentInput.length; + + // Replace @query with the resource name + const before = currentInput.slice(0, startIndex); + const after = currentInput.slice(cursorPos); + const newValue = `${before}@${resource.name} ${after}`; + + setInput(newValue); + setMentionActive(false); + + // Focus textarea and set cursor position after the inserted name + setTimeout(() => { + if (textareaRef) { + textareaRef.focus(); + const newCursorPos = startIndex + resource.name.length + 2; // +2 for @ and space + textareaRef.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + }; + + // Handle key down - submit when not loading, but let autocomplete handle keys when active const handleKeyDown = (e: KeyboardEvent) => { + // Let mention autocomplete handle navigation keys + if (mentionActive()) { + if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key)) { + // These are handled by MentionAutocomplete component + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); @@ -612,6 +766,30 @@ export const AIChat: Component = (props) => { + {/* Discovery hint - show when discovery is disabled */} + +
+
+ + + + + Discovery is off. + {' '}Enable it in Settings for more accurate answers about your infrastructure. + +
+ +
+
+ {/* Messages */} = (props) => { {/* Input */}
-
{ e.preventDefault(); handleSubmit(); }} class="flex gap-2"> -