Update frontend for AI assistant and discovery features

AI Chat Improvements:
- MentionAutocomplete for @-mentioning resources
- Better tool execution display
- Enhanced chat interface

New Components:
- FindingsPanel for AI findings display
- DiscoveryTab for infrastructure discovery
- PatrolActivitySection for patrol monitoring
- StorageConfigPanel for storage management

API Updates:
- Discovery API integration
- Enhanced AI chat API
- Patrol API improvements
- Monitoring API updates

UI/UX:
- Better AI status indicator
- Improved investigation drawer
- Enhanced settings page
- Better guest drawer integration

Types:
- New discovery types
- Enhanced AI types
- API type improvements

Removed deprecated UnifiedFindingsPanel in favor of new FindingsPanel.
This commit is contained in:
rcourtman
2026-01-28 16:53:15 +00:00
parent 13a6f7750c
commit 6184418704
25 changed files with 4042 additions and 739 deletions

View File

@@ -245,17 +245,17 @@ export class AIAPI {
return apiFetchJSON(`${this.baseUrl}/ai/remediation/plan?plan_id=${planId}`) as Promise<RemediationPlan>;
}
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<RemediationExecutionResult> {
static async executeRemediationPlan(executionId: string): Promise<RemediationExecutionResult> {
return apiFetchJSON(`${this.baseUrl}/ai/remediation/execute`, {
method: 'POST',
body: JSON.stringify({ plan_id: planId }),
body: JSON.stringify({ execution_id: executionId }),
}) as Promise<RemediationExecutionResult>;
}
@@ -270,6 +270,47 @@ export class AIAPI {
static async getCircuitBreakerStatus(): Promise<CircuitBreakerStatus> {
return apiFetchJSON(`${this.baseUrl}/ai/circuit/status`) as Promise<CircuitBreakerStatus>;
}
// ============================================
// Investigation Fix Approvals
// ============================================
// Get pending approval requests (investigation fixes waiting for user approval)
static async getPendingApprovals(): Promise<ApprovalRequest[]> {
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<ApprovalExecutionResult> {
return apiFetchJSON(`${this.baseUrl}/ai/approvals/${approvalId}/approve`, {
method: 'POST',
}) as Promise<ApprovalExecutionResult>;
}
// Deny an investigation fix
static async denyInvestigationFix(approvalId: string, reason?: string): Promise<ApprovalRequest> {
return apiFetchJSON(`${this.baseUrl}/ai/approvals/${approvalId}/deny`, {
method: 'POST',
body: JSON.stringify({ reason: reason || 'User declined' }),
}) as Promise<ApprovalRequest>;
}
// Get investigation details for a finding (includes proposed fix)
static async getInvestigation(findingId: string): Promise<InvestigationSession | null> {
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;
}

View File

@@ -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<DiscoveryListResponse> {
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<DiscoveryListResponse> {
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<DiscoveryListResponse> {
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<ResourceDiscovery | null> {
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<ResourceDiscovery> {
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<DiscoveryProgress> {
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<ResourceDiscovery> {
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<void> {
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<DiscoveryStatus> {
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<string, string> = {
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' };
}

View File

@@ -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<StorageConfigEntry[]> {
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;
}

View File

@@ -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<InvestigationOutcome, string> =
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<PatrolRunRecord[]> {
const runs = await apiFetchJSON<PatrolRunRecord[]>(`/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',
});
}

View File

@@ -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';
});

View File

@@ -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 (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
);
case 'container':
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
);
case 'docker':
return (
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.119a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288z" />
</svg>
);
case 'node':
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
);
case 'host':
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
);
default:
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
);
}
};
// 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 (
<Show when={props.visible && filteredResources().length > 0}>
<div
class="absolute z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg overflow-hidden min-w-[280px] max-w-[400px]"
style={{
bottom: `${props.position.top}px`,
left: `${props.position.left}px`,
}}
>
<div class="px-3 py-2 border-b border-slate-200 dark:border-slate-700 text-xs font-medium text-slate-500 dark:text-slate-400">
Resources
</div>
<div class="max-h-[240px] overflow-y-auto">
<For each={filteredResources()}>
{(resource, index) => (
<button
type="button"
class={`w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors ${
index() === selectedIndex() ? 'bg-slate-100 dark:bg-slate-700' : ''
}`}
onClick={() => props.onSelect(resource)}
onMouseEnter={() => setSelectedIndex(index())}
>
<span class="text-slate-500 dark:text-slate-400">
{getTypeIcon(resource.type)}
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-slate-900 dark:text-slate-100 truncate">
{resource.name}
</span>
<Show when={resource.status}>
<span class={`w-2 h-2 rounded-full ${getStatusColor(resource.status)}`} />
</Show>
</div>
<div class="text-xs text-slate-500 dark:text-slate-400">
{resource.type}
<Show when={resource.node}>
{' · '}{resource.node}
</Show>
</div>
</div>
</button>
)}
</For>
</div>
<div class="px-3 py-1.5 border-t border-slate-200 dark:border-slate-700 text-xs text-slate-400 dark:text-slate-500 flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 rounded text-[10px]"></span>
navigate
<span class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 rounded text-[10px]"></span>
select
<span class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 rounded text-[10px]">esc</span>
close
</div>
</div>
</Show>
);
}

View File

@@ -23,6 +23,7 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (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);

View File

@@ -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<AIChatProps> = (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<boolean | null>(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<MentionResource[]>([]);
let textareaRef: HTMLTextAreaElement | undefined;
const loadModelSelections = (): Record<string, string> => {
try {
@@ -179,6 +190,7 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (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<AIChatProps> = (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<AIChatProps> = (props) => {
</div>
</Show>
{/* Discovery hint - show when discovery is disabled */}
<Show when={discoveryEnabled() === false && !discoveryHintDismissed()}>
<div class="px-4 py-2 border-b border-cyan-200 dark:border-cyan-800 bg-cyan-50 dark:bg-cyan-900/20 flex items-center justify-between gap-3 text-[11px] text-cyan-700 dark:text-cyan-200">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-cyan-500 dark:text-cyan-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
<span class="font-medium">Discovery is off.</span>
{' '}Enable it in Settings for more accurate answers about your infrastructure.
</span>
</div>
<button
onClick={() => setDiscoveryHintDismissed(true)}
class="p-1 rounded hover:bg-cyan-100 dark:hover:bg-cyan-800/50 text-cyan-500 dark:text-cyan-400"
title="Dismiss"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Show>
{/* Messages */}
<ChatMessages
messages={chat.messages()}
@@ -673,15 +851,28 @@ export const AIChat: Component<AIChatProps> = (props) => {
{/* Input */}
<div class="border-t border-slate-200 dark:border-slate-700 p-4 bg-white dark:bg-slate-900">
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="flex gap-2">
<textarea
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your infrastructure..."
rows={2}
class="flex-1 px-4 py-3 text-sm rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="flex gap-2 relative">
<div class="flex-1 relative">
<textarea
ref={textareaRef}
value={input()}
onInput={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Ask about your infrastructure... (type @ to mention a resource)"
rows={2}
class="w-full px-4 py-3 text-sm rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
<div data-mention-autocomplete>
<MentionAutocomplete
query={mentionQuery()}
resources={mentionResources()}
position={{ top: 60, left: 0 }}
onSelect={handleMentionSelect}
onClose={() => setMentionActive(false)}
visible={mentionActive()}
/>
</div>
</div>
<div class="flex gap-1.5 self-end">
<Show
when={!chat.isLoading()}
@@ -714,7 +905,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
<p class="text-[10px] text-slate-400 dark:text-slate-500 mt-2 text-center">
{chat.isLoading()
? 'Generating... click Stop to interrupt'
: 'Press Enter to send · Shift+Enter for new line'}
: 'Press Enter to send · Shift+Enter for new line · Type @ to mention a resource'}
</p>
</div>
</Show>

File diff suppressed because it is too large Load Diff

View File

@@ -66,6 +66,20 @@ export const InvestigationDrawer: Component<InvestigationDrawerProps> = (props)
const [reinvestigating, setReinvestigating] = createSignal(false);
const [approvingFix, setApprovingFix] = createSignal(false);
const [denyingFix, setDenyingFix] = createSignal(false);
const toolsAvailable = () => investigation()?.tools_available ?? [];
const toolsUsed = () => investigation()?.tools_used ?? [];
const evidenceIDs = () => investigation()?.evidence_ids ?? [];
const handleCopyEvidence = async () => {
if (!evidenceIDs().length) return;
const payload = evidenceIDs().join('\n');
try {
await navigator.clipboard.writeText(payload);
notificationStore.success('Evidence IDs copied');
} catch (_err) {
notificationStore.error('Failed to copy evidence IDs');
}
};
// Load investigation data when drawer opens
createEffect(async () => {
@@ -304,6 +318,61 @@ export const InvestigationDrawer: Component<InvestigationDrawerProps> = (props)
</div>
</Show>
{/* Tools & Evidence */}
<Show when={toolsUsed().length || toolsAvailable().length || evidenceIDs().length}>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Tools & Evidence</h4>
<Show when={toolsUsed().length}>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Tools used</div>
<div class="flex flex-wrap gap-1 mb-3">
<For each={toolsUsed()}>
{(tool) => (
<span class="px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 text-[10px] font-medium">
{tool}
</span>
)}
</For>
</div>
</Show>
<Show when={toolsAvailable().length}>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Tools available</div>
<div class="flex flex-wrap gap-1 mb-3 max-h-20 overflow-y-auto">
<For each={toolsAvailable()}>
{(tool) => (
<span class="px-1.5 py-0.5 rounded bg-gray-50 text-gray-600 dark:bg-gray-800/70 dark:text-gray-300 text-[10px]">
{tool}
</span>
)}
</For>
</div>
</Show>
<Show when={evidenceIDs().length}>
<div class="flex items-center justify-between mb-1">
<div class="text-xs text-gray-500 dark:text-gray-400">Evidence IDs</div>
<button
type="button"
onClick={handleCopyEvidence}
class="text-[10px] px-2 py-0.5 rounded border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
Copy
</button>
</div>
<div class="flex flex-wrap gap-1">
<For each={evidenceIDs()}>
{(id) => (
<span class="px-1.5 py-0.5 rounded bg-gray-900 text-green-300 text-[10px] font-mono">
{id}
</span>
)}
</For>
</div>
</Show>
</div>
</Show>
{/* Proposed Fix */}
<Show when={investigation()?.proposed_fix}>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">

View File

@@ -1,657 +0,0 @@
/**
* UnifiedFindingsPanel (Phase 7 - Task 7.3.3)
*
* Combined alert + AI findings view that shows:
* - Source indicator (threshold vs AI)
* - Severity-based sorting
* - Quick actions (dismiss, snooze, acknowledge)
* - Correlation links
*/
import { Component, createSignal, createEffect, Show, For, createMemo } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { Card } from '@/components/shared/Card';
import { aiIntelligenceStore, type UnifiedFinding } from '@/stores/aiIntelligence';
import { notificationStore } from '@/stores/notifications';
import { InvestigationDrawer } from './InvestigationDrawer';
import { investigationStatusLabels, investigationOutcomeLabels, type InvestigationStatus } from '@/api/patrol';
import { AIAPI, type RemediationPlan } from '@/api/ai';
// Severity priority for sorting (lower number = higher priority)
const severityOrder: Record<string, number> = {
critical: 0,
warning: 1,
watch: 2,
info: 3,
};
// Source display names
const sourceLabels: Record<string, string> = {
'threshold': 'Alert',
'ai-patrol': 'Pulse Patrol',
'anomaly': 'Anomaly',
'ai-chat': 'Pulse Assistant',
'correlation': 'Correlation',
'forecast': 'Forecast',
};
// Severity badge colors
const severityColors: Record<string, string> = {
critical: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
info: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
watch: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
};
// Source badge colors
const sourceColors: Record<string, string> = {
'threshold': 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
'ai-patrol': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
'anomaly': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
'ai-chat': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
'correlation': 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
'forecast': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
};
// Investigation status badge colors
const investigationStatusColors: Record<InvestigationStatus, string> = {
pending: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
running: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
completed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
needs_attention: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
interface UnifiedFindingsPanelProps {
resourceId?: string;
showResolved?: boolean;
maxItems?: number;
onFindingClick?: (finding: UnifiedFinding) => void;
filterOverride?: 'all' | 'active' | 'resolved';
showControls?: boolean;
}
export const UnifiedFindingsPanel: Component<UnifiedFindingsPanelProps> = (props) => {
const navigate = useNavigate();
const [filter, setFilter] = createSignal<'all' | 'active' | 'resolved'>(props.filterOverride ?? 'active');
const [sortBy, setSortBy] = createSignal<'severity' | 'time'>('severity');
const [expandedId, setExpandedId] = createSignal<string | null>(null);
const [actionLoading, setActionLoading] = createSignal<string | null>(null);
// Investigation drawer state
const [investigationDrawerOpen, setInvestigationDrawerOpen] = createSignal(false);
const [selectedFindingForInvestigation, setSelectedFindingForInvestigation] = createSignal<UnifiedFinding | null>(null);
// Remediation plans state
const [remediationPlans, setRemediationPlans] = createSignal<RemediationPlan[]>([]);
const openInvestigationDrawer = (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
setSelectedFindingForInvestigation(finding);
setInvestigationDrawerOpen(true);
};
// Map of finding_id -> remediation plan
const plansByFindingId = createMemo(() => {
const map = new Map<string, RemediationPlan>();
for (const plan of remediationPlans()) {
map.set(plan.finding_id, plan);
}
return map;
});
// Load findings and remediation plans on mount
createEffect(() => {
aiIntelligenceStore.loadFindings();
AIAPI.getRemediationPlans()
.then((response: { plans: RemediationPlan[] }) => setRemediationPlans(response.plans))
.catch(() => {}); // Silently ignore errors
});
createEffect(() => {
if (props.filterOverride) {
setFilter(props.filterOverride);
}
});
// Filter and sort findings
const filteredFindings = createMemo(() => {
let findings = [...aiIntelligenceStore.findings];
// Filter by resource if specified
if (props.resourceId) {
findings = findings.filter(f => f.resourceId === props.resourceId);
}
// Filter by status
if (filter() === 'active') {
findings = findings.filter(f => f.status === 'active');
} else if (filter() === 'resolved') {
findings = findings.filter(f => f.status === 'resolved' || f.status === 'dismissed');
}
// Sort
findings.sort((a, b) => {
if (sortBy() === 'severity') {
// Sort by urgency: critical (0) first, then warning (1), watch (2), info (3)
const aPriority = severityOrder[a.severity] ?? 4;
const bPriority = severityOrder[b.severity] ?? 4;
if (aPriority !== bPriority) return aPriority - bPriority;
}
// Secondary sort by time (most recent first)
return new Date(b.detectedAt).getTime() - new Date(a.detectedAt).getTime();
});
// Limit items
if (props.maxItems && props.maxItems > 0) {
findings = findings.slice(0, props.maxItems);
}
return findings;
});
const isThresholdFinding = (finding: UnifiedFinding) =>
finding.source === 'threshold' || Boolean(finding.isThreshold || finding.alertId);
const handleAcknowledge = async (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
if (isThresholdFinding(finding)) {
navigate('/alerts/overview');
return;
}
setActionLoading(finding.id);
const ok = await aiIntelligenceStore.acknowledgeFinding(finding.id);
setActionLoading(null);
if (ok) {
notificationStore.success('Finding acknowledged');
} else {
notificationStore.error('Failed to acknowledge finding');
}
};
const handleDismiss = async (finding: UnifiedFinding, reason: 'not_an_issue' | 'expected_behavior' | 'will_fix_later', e: Event) => {
e.stopPropagation();
if (isThresholdFinding(finding)) {
navigate('/alerts/overview');
return;
}
const note = window.prompt('Add an optional note (for learning context):', '') ?? '';
setActionLoading(finding.id);
const ok = await aiIntelligenceStore.dismissFinding(finding.id, reason, note);
setActionLoading(null);
if (ok) {
notificationStore.success('Finding dismissed');
} else {
notificationStore.error('Failed to dismiss finding');
}
};
const handleSnooze = async (finding: UnifiedFinding, durationHours: number, e: Event) => {
e.stopPropagation();
if (isThresholdFinding(finding)) {
navigate('/alerts/overview');
return;
}
setActionLoading(finding.id);
const ok = await aiIntelligenceStore.snoozeFinding(finding.id, durationHours);
setActionLoading(null);
if (ok) {
notificationStore.success(`Finding snoozed for ${durationHours}h`);
} else {
notificationStore.error('Failed to snooze finding');
}
};
const formatTime = (isoString: string) => {
const date = new Date(isoString);
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();
};
// Get meaningful resolution reason based on finding type
const getResolutionReason = (finding: UnifiedFinding): string => {
const resolvedTime = finding.resolvedAt ? formatTime(finding.resolvedAt) : '';
// For threshold alerts, provide specific reasons based on alert type
if (finding.isThreshold || finding.source === 'threshold') {
const alertType = finding.alertType || '';
switch (alertType) {
case 'powered-off':
return `Guest came online ${resolvedTime}`;
case 'host-offline':
return `Host came online ${resolvedTime}`;
case 'cpu':
return `CPU returned to normal ${resolvedTime}`;
case 'memory':
return `Memory returned to normal ${resolvedTime}`;
case 'disk':
return `Disk usage returned to normal ${resolvedTime}`;
case 'network':
return `Network recovered ${resolvedTime}`;
default:
return `Condition cleared ${resolvedTime}`;
}
}
// For AI patrol findings
if (finding.source === 'ai-patrol') {
return `Issue no longer detected ${resolvedTime}`;
}
// Generic fallback
return `Resolved ${resolvedTime}`;
};
return (
<Card padding="none" class="overflow-hidden">
{/* Header */}
<div class="bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-900/30 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-medium text-gray-900 dark:text-gray-100">Unified Findings</span>
<Show when={filteredFindings().length > 0}>
<span class="px-2 py-0.5 text-xs font-medium bg-blue-200 dark:bg-blue-700 text-blue-800 dark:text-blue-200 rounded-full">
{filteredFindings().length}
</span>
</Show>
</div>
<div class="flex items-center gap-2">
{/* Filter tabs */}
<Show when={props.showControls !== false}>
<div class="flex text-xs">
<button
type="button"
onClick={() => setFilter('active')}
class={`px-2 py-1 rounded-l border ${filter() === 'active'
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Active
</button>
<button
type="button"
onClick={() => setFilter('all')}
class={`px-2 py-1 border-t border-b ${filter() === 'all'
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
All
</button>
<button
type="button"
onClick={() => setFilter('resolved')}
class={`px-2 py-1 rounded-r border ${filter() === 'resolved'
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Resolved
</button>
</div>
</Show>
{/* Sort dropdown */}
<select
value={sortBy()}
onChange={(e) => setSortBy(e.currentTarget.value as 'severity' | 'time')}
class="text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
>
<option value="severity">By Severity</option>
<option value="time">By Time</option>
</select>
</div>
</div>
</div>
{/* Content */}
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<Show when={aiIntelligenceStore.findingsLoading}>
<div class="p-4 text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
Loading findings...
</div>
</Show>
<Show when={aiIntelligenceStore.findingsError && !aiIntelligenceStore.findingsLoading}>
<div class="p-4 text-sm text-red-600 dark:text-red-400">
{aiIntelligenceStore.findingsError}
</div>
</Show>
<Show when={!aiIntelligenceStore.findingsLoading && filteredFindings().length === 0}>
<div class="p-4 text-sm text-gray-500 dark:text-gray-400 text-center">
No findings to display
</div>
</Show>
<For each={filteredFindings()}>
{(finding) => (
<div
class={`p-3 cursor-pointer transition-colors ${
finding.status === 'active'
? 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
: 'opacity-60 bg-gray-50/50 dark:bg-gray-800/30 hover:opacity-80'
}`}
onClick={() => {
if (expandedId() === finding.id) {
setExpandedId(null);
} else {
setExpandedId(finding.id);
}
props.onFindingClick?.(finding);
}}
>
{/* Finding header */}
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
{/* Status badge for non-active findings */}
<Show when={finding.status !== 'active'}>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
finding.status === 'resolved'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-300'
}`}>
{finding.status === 'resolved' ? 'Resolved' : 'Dismissed'}
</span>
</Show>
{/* Source badge */}
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${sourceColors[finding.source] || sourceColors['ai-patrol']}`}>
{sourceLabels[finding.source] || finding.source}
</span>
{/* Severity badge */}
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded uppercase ${severityColors[finding.severity]}`}>
{finding.severity}
</span>
{/* Investigation status badge */}
<Show when={finding.investigationStatus}>
<span
class={`px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 ${investigationStatusColors[finding.investigationStatus!]}`}
title={`Investigation: ${investigationStatusLabels[finding.investigationStatus!]}`}
>
<Show when={finding.investigationStatus === 'running'}>
<span class="h-2 w-2 border border-current border-t-transparent rounded-full animate-spin" />
</Show>
{investigationStatusLabels[finding.investigationStatus!]}
</span>
</Show>
{/* Investigation outcome badge */}
<Show when={finding.investigationOutcome && finding.investigationStatus === 'completed'}>
<span class="px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{investigationOutcomeLabels[finding.investigationOutcome!]}
</span>
</Show>
{/* Title */}
<span class={`font-medium text-sm truncate ${
finding.status === 'active'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-400'
}`}>
{finding.title}
</span>
</div>
{/* Resource info */}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{finding.resourceName} ({finding.resourceType}) - {formatTime(finding.detectedAt)}
<Show when={finding.status === 'resolved' && finding.resolvedAt}>
<span class="ml-2 text-green-600 dark:text-green-400">
{getResolutionReason(finding)}
</span>
</Show>
<Show when={finding.dismissedReason}>
<span class="ml-2 text-gray-400 dark:text-gray-500">
({finding.dismissedReason?.replace(/_/g, ' ')})
</span>
</Show>
</div>
</div>
{/* Actions */}
<div class="flex items-center gap-1 shrink-0">
<Show when={finding.status === 'active' && !isThresholdFinding(finding)}>
<button
type="button"
onClick={(e) => handleAcknowledge(finding, e)}
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Acknowledge"
disabled={actionLoading() === finding.id}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4" />
</svg>
</button>
<button
type="button"
onClick={(e) => handleSnooze(finding, 24, e)}
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Snooze 24h"
disabled={actionLoading() === finding.id}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
type="button"
onClick={(e) => handleDismiss(finding, 'will_fix_later', e)}
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Dismiss (Will fix later)"
disabled={actionLoading() === finding.id}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
{/* Investigation button - show for AI findings with investigation data */}
<Show when={!isThresholdFinding(finding) && finding.investigationSessionId}>
<button
type="button"
onClick={(e) => openInvestigationDrawer(finding, e)}
class="p-1 text-purple-500 hover:text-purple-700 dark:hover:text-purple-300"
title="View investigation"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</button>
</Show>
{/* Expand indicator */}
<svg
class={`w-4 h-4 text-gray-400 transition-transform ${expandedId() === finding.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Expanded content */}
<Show when={expandedId() === finding.id}>
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">
{finding.description}
</p>
<Show when={finding.recommendation}>
<p class="text-sm text-gray-700 dark:text-gray-300 mt-2">
<span class="font-medium">Recommendation:</span> {finding.recommendation}
</p>
</Show>
<Show when={finding.status === 'active' && isThresholdFinding(finding)}>
<div class="mt-3">
<button
type="button"
onClick={() => navigate('/alerts/overview')}
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
Manage in Alerts
</button>
</div>
</Show>
<Show when={finding.status === 'active' && !isThresholdFinding(finding)}>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<button
type="button"
onClick={(e) => handleAcknowledge(finding, e)}
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
disabled={actionLoading() === finding.id}
>
Acknowledge
</button>
<button
type="button"
onClick={(e) => handleSnooze(finding, 1, e)}
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
disabled={actionLoading() === finding.id}
>
Snooze 1h
</button>
<button
type="button"
onClick={(e) => handleSnooze(finding, 24, e)}
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
disabled={actionLoading() === finding.id}
>
Snooze 24h
</button>
<button
type="button"
onClick={(e) => handleSnooze(finding, 168, e)}
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
disabled={actionLoading() === finding.id}
>
Snooze 7d
</button>
<button
type="button"
onClick={(e) => handleDismiss(finding, 'not_an_issue', e)}
class="px-2 py-1 rounded border border-red-200 text-red-700 dark:border-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30"
disabled={actionLoading() === finding.id}
>
Dismiss: Not an issue
</button>
<button
type="button"
onClick={(e) => handleDismiss(finding, 'expected_behavior', e)}
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
disabled={actionLoading() === finding.id}
>
Dismiss: Expected
</button>
<button
type="button"
onClick={(e) => handleDismiss(finding, 'will_fix_later', e)}
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
disabled={actionLoading() === finding.id}
>
Dismiss: Later
</button>
</div>
</Show>
<Show when={finding.correlatedFindingIds && finding.correlatedFindingIds.length > 0}>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Related findings: {finding.correlatedFindingIds?.length}
</div>
</Show>
{/* Remediation Plan - only shown for active findings */}
<Show when={finding.status === 'active' && plansByFindingId().get(finding.id)}>
{(plan) => (
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Remediation Plan</span>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
plan().risk_level === 'high' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' :
plan().risk_level === 'medium' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' :
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
{plan().risk_level} risk
</span>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
plan().status === 'completed' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' :
plan().status === 'approved' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' :
plan().status === 'executing' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' :
plan().status === 'failed' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' :
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}>
{plan().status}
</span>
</div>
<div class="space-y-2">
<For each={plan().steps}>
{(step) => (
<div class="flex items-start gap-2 text-sm">
<span class="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700 text-xs font-medium text-gray-600 dark:text-gray-400">
{step.order}
</span>
<span class="text-gray-700 dark:text-gray-300">{step.action}</span>
</div>
)}
</For>
</div>
</div>
)}
</Show>
{/* Investigation link */}
<Show when={finding.investigationSessionId && !isThresholdFinding(finding)}>
<div class="mt-2">
<button
type="button"
onClick={(e) => openInvestigationDrawer(finding, e)}
class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
View investigation details
<Show when={finding.investigationStatus === 'running'}>
<span class="ml-1 h-2 w-2 border border-purple-400 border-t-transparent rounded-full animate-spin" />
</Show>
</button>
</div>
</Show>
</div>
</Show>
</div>
)}
</For>
</div>
{/* Investigation Drawer */}
<InvestigationDrawer
open={investigationDrawerOpen()}
onClose={() => {
setInvestigationDrawerOpen(false);
setSelectedFindingForInvestigation(null);
}}
findingId={selectedFindingForInvestigation()?.id}
findingTitle={selectedFindingForInvestigation()?.title}
findingSeverity={selectedFindingForInvestigation()?.severity as 'critical' | 'warning' | 'watch' | 'info'}
resourceName={selectedFindingForInvestigation()?.resourceName}
resourceType={selectedFindingForInvestigation()?.resourceType}
onReinvestigate={() => {
// Reload findings after re-investigation is triggered
aiIntelligenceStore.loadFindings();
}}
/>
</Card>
);
};
export default UnifiedFindingsPanel;

View File

@@ -4,6 +4,8 @@ import { formatBytes, formatUptime } from '@/utils/format';
import { DiskList } from './DiskList';
import { UnifiedHistoryChart } from '../shared/UnifiedHistoryChart';
import { HistoryTimeRange, ResourceType } from '@/api/charts';
import { DiscoveryTab } from '../Discovery/DiscoveryTab';
import type { ResourceType as DiscoveryResourceType } from '@/types/discovery';
type Guest = VM | Container;
@@ -95,7 +97,12 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
return { type, id };
};
const [activeTab, setActiveTab] = createSignal<'overview' | 'history'>('overview');
const [activeTab, setActiveTab] = createSignal<'overview' | 'history' | 'discovery'>('overview');
// Get discovery resource type for the guest
const discoveryResourceType = (): DiscoveryResourceType => {
return isVM(props.guest) ? 'vm' : 'lxc';
};
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>('24h');
return (
@@ -126,6 +133,18 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
<button
onClick={() => setActiveTab('discovery')}
class={`pb-2 text-sm font-medium transition-colors relative ${activeTab() === 'discovery'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
Discovery
{activeTab() === 'discovery' && (
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
</div>
<Show when={activeTab() === 'overview'}>
@@ -341,6 +360,15 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
/>
</div>
</Show>
<Show when={activeTab() === 'discovery'}>
<DiscoveryTab
resourceType={discoveryResourceType()}
hostId={props.guest.node}
resourceId={String(props.guest.vmid)}
hostname={props.guest.name}
/>
</Show>
</div>
);
};

View File

@@ -0,0 +1,445 @@
import { Component, Show, For, createSignal, createResource, onCleanup, createEffect } from 'solid-js';
import type { ResourceType, DiscoveryProgress } from '../../types/discovery';
import {
getDiscovery,
triggerDiscovery,
updateDiscoveryNotes,
formatDiscoveryAge,
getCategoryDisplayName,
getConfidenceLevel,
} from '../../api/discovery';
import { eventBus } from '../../stores/events';
interface DiscoveryTabProps {
resourceType: ResourceType;
hostId: string;
resourceId: string;
hostname: string;
}
// Construct the resource ID in the same format the backend uses
const makeResourceId = (type: ResourceType, hostId: string, resourceId: string) => {
return `${type}:${hostId}:${resourceId}`;
};
export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
const [isScanning, setIsScanning] = createSignal(false);
const [editingNotes, setEditingNotes] = createSignal(false);
const [notesText, setNotesText] = createSignal('');
const [saveError, setSaveError] = createSignal<string | null>(null);
const [scanProgress, setScanProgress] = createSignal<DiscoveryProgress | null>(null);
// Fetch discovery data
const [discovery, { refetch, mutate }] = createResource(
() => ({ type: props.resourceType, host: props.hostId, id: props.resourceId }),
async (params) => {
try {
return await getDiscovery(params.type, params.host, params.id);
} catch {
return null;
}
}
);
// Handle triggering a new discovery
const handleTriggerDiscovery = async (force = false) => {
setIsScanning(true);
setScanProgress(null);
try {
// triggerDiscovery returns the discovery data directly
const result = await triggerDiscovery(props.resourceType, props.hostId, props.resourceId, {
force,
hostname: props.hostname,
});
// Use mutate to directly update the resource with the returned data
// This provides immediate UI feedback without needing a refetch
mutate(result);
} catch (err) {
console.error('Discovery failed:', err);
} finally {
setIsScanning(false);
setScanProgress(null);
}
};
// Handle saving notes
const handleSaveNotes = async () => {
setSaveError(null);
try {
await updateDiscoveryNotes(props.resourceType, props.hostId, props.resourceId, {
user_notes: notesText(),
});
setEditingNotes(false);
await refetch();
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save notes');
}
};
// Start editing notes
const startEditingNotes = () => {
const currentNotes = discovery()?.user_notes || '';
setNotesText(currentNotes);
setEditingNotes(true);
};
// Subscribe to WebSocket progress updates
const resourceId = () => makeResourceId(props.resourceType, props.hostId, props.resourceId);
createEffect(() => {
const unsubscribe = eventBus.on('ai_discovery_progress', (progress) => {
// Only update if this progress is for our resource
if (progress && progress.resource_id === resourceId()) {
setScanProgress(progress);
// If scan completed or failed, refresh the data and clear scanning state
if (progress.status === 'completed' || progress.status === 'failed') {
setIsScanning(false);
// Fetch the updated discovery data
// Use a small delay to ensure the backend has persisted the data
setTimeout(async () => {
try {
const result = await getDiscovery(props.resourceType, props.hostId, props.resourceId);
if (result) {
mutate(result);
}
} catch (err) {
console.error('Failed to fetch discovery after completion:', err);
}
setScanProgress(null);
}, 500);
}
}
});
onCleanup(() => {
unsubscribe();
});
});
const confidenceInfo = () => {
const d = discovery();
if (!d) return null;
return getConfidenceLevel(d.confidence);
};
return (
<div class="space-y-4">
{/* Loading state */}
<Show when={discovery.loading}>
<div class="flex items-center justify-center py-8">
<div class="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading discovery...</span>
</div>
</Show>
{/* Scan Progress Bar */}
<Show when={scanProgress() && isScanning()}>
<div class="rounded border border-blue-200 bg-blue-50 p-3 shadow-sm dark:border-blue-800 dark:bg-blue-900/30">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">
{scanProgress()?.current_step || 'Scanning...'}
</span>
</div>
<span class="text-xs text-blue-600 dark:text-blue-400">
{Math.round(scanProgress()?.percent_complete || 0)}%
</span>
</div>
<div class="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2 overflow-hidden">
<div
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${scanProgress()?.percent_complete || 0}%` }}
></div>
</div>
<Show when={scanProgress()?.current_command}>
<div class="mt-2 text-xs text-blue-600 dark:text-blue-400">
Running: <code class="font-mono">{scanProgress()?.current_command}</code>
</div>
</Show>
<Show when={scanProgress()?.elapsed_ms}>
<div class="mt-1 text-xs text-blue-500 dark:text-blue-500">
Elapsed: {((scanProgress()?.elapsed_ms || 0) / 1000).toFixed(1)}s
</div>
</Show>
</div>
</Show>
{/* No discovery yet */}
<Show when={!discovery.loading && !discovery()}>
<div class="text-center py-8">
<div class="text-gray-500 dark:text-gray-400 mb-4">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-sm">No discovery data yet</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Run a discovery scan to identify services and configurations
</p>
</div>
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning()}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isScanning() ? (
<span class="flex items-center">
<span class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
Scanning...
</span>
) : (
'Run Discovery'
)}
</button>
</div>
</Show>
{/* Discovery data */}
<Show when={discovery()}>
{(d) => (
<div class="space-y-4">
{/* Service Header */}
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="flex items-start justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{d().service_name || 'Unknown Service'}
</h3>
<Show when={d().service_version}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Version {d().service_version}
</p>
</Show>
</div>
<Show when={d().category && d().category !== 'unknown'}>
<span class="inline-block rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{getCategoryDisplayName(d().category)}
</span>
</Show>
</div>
<Show when={confidenceInfo()}>
<p class={`text-xs mt-2 ${confidenceInfo()!.color}`}>
{confidenceInfo()!.label} ({Math.round(d().confidence * 100)}%)
</p>
</Show>
</div>
{/* CLI Access */}
<Show when={d().cli_access}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">
CLI Access
</div>
<code class="block bg-gray-100 dark:bg-gray-800 rounded px-2 py-1.5 text-xs text-gray-800 dark:text-gray-200 font-mono overflow-x-auto">
{d().cli_access}
</code>
</div>
</Show>
{/* Configuration, Data & Log Paths */}
<Show when={d().config_paths?.length > 0 || d().data_paths?.length > 0 || d().log_paths?.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<Show when={d().config_paths?.length > 0}>
<div class="mb-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-1">
Config Paths
</div>
<div class="space-y-1">
<For each={d().config_paths}>
{(path) => (
<code class="block text-xs text-gray-600 dark:text-gray-300 font-mono">
{path}
</code>
)}
</For>
</div>
</div>
</Show>
<Show when={d().data_paths?.length > 0}>
<div class="mb-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-1">
Data Paths
</div>
<div class="space-y-1">
<For each={d().data_paths}>
{(path) => (
<code class="block text-xs text-gray-600 dark:text-gray-300 font-mono">
{path}
</code>
)}
</For>
</div>
</div>
</Show>
<Show when={d().log_paths?.length > 0}>
<div>
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-1">
Log Paths
</div>
<div class="space-y-1">
<For each={d().log_paths}>
{(path) => (
<code class="block text-xs text-gray-600 dark:text-gray-300 font-mono">
{path}
</code>
)}
</For>
</div>
</div>
</Show>
</div>
</Show>
{/* Ports */}
<Show when={d().ports?.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">
Listening Ports
</div>
<div class="flex flex-wrap gap-1">
<For each={d().ports}>
{(port) => (
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{port.port}/{port.protocol}
<Show when={port.process}>
<span class="text-gray-500 dark:text-gray-400 ml-1">({port.process})</span>
</Show>
</span>
)}
</For>
</div>
</div>
</Show>
{/* Key Facts */}
<Show when={d().facts?.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">
Discovered Facts
</div>
<div class="space-y-1.5">
<For each={d().facts.slice(0, 8)}>
{(fact) => (
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{fact.key}</span>
<span class="font-medium text-gray-800 dark:text-gray-200 truncate ml-2 max-w-[60%]" title={fact.value}>
{fact.value}
</span>
</div>
)}
</For>
</div>
</div>
</Show>
{/* User Notes */}
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="flex items-center justify-between mb-2">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Your Notes
</div>
<Show when={!editingNotes()}>
<button
onClick={startEditingNotes}
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{d().user_notes ? 'Edit' : 'Add notes'}
</button>
</Show>
</div>
<Show
when={editingNotes()}
fallback={
<Show
when={d().user_notes}
fallback={
<p class="text-xs text-gray-400 dark:text-gray-500 italic">
No notes yet. Add notes to document important information.
</p>
}
>
<p class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
{d().user_notes}
</p>
</Show>
}
>
<div class="space-y-2">
<textarea
value={notesText()}
onInput={(e) => setNotesText(e.currentTarget.value)}
placeholder="Add notes about this resource (API tokens, passwords, important info)..."
class="w-full h-24 px-2 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<Show when={saveError()}>
<p class="text-xs text-red-600 dark:text-red-400">{saveError()}</p>
</Show>
<div class="flex gap-2">
<button
onClick={handleSaveNotes}
class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
>
Save
</button>
<button
onClick={() => setEditingNotes(false)}
class="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
</div>
</Show>
</div>
{/* AI Reasoning (collapsible) */}
<Show when={d().ai_reasoning}>
<details class="rounded border border-gray-200 bg-white/70 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<summary class="p-3 text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50">
AI Reasoning
</summary>
<div class="px-3 pb-3">
<p class="text-xs text-gray-600 dark:text-gray-300">
{d().ai_reasoning}
</p>
</div>
</details>
</Show>
{/* Footer with Update button */}
<div class="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400">
Last updated: {formatDiscoveryAge(d().updated_at)}
</span>
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning()}
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
>
<Show
when={isScanning()}
fallback={
<>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update Discovery
</>
}
>
<span class="animate-spin h-3.5 w-3.5 border-2 border-gray-500 border-t-transparent rounded-full"></span>
Scanning...
</Show>
</button>
</div>
</div>
)}
</Show>
</div>
);
};
export default DiscoveryTab;

View File

@@ -136,6 +136,7 @@ export const AISettings: Component = () => {
// UI state for collapsible sections - START COLLAPSED for compact view
const [showAdvancedModels, setShowAdvancedModels] = createSignal(false);
const [showPatrolSettings, setShowPatrolSettings] = createSignal(false);
const [showDiscoverySettings, setShowDiscoverySettings] = createSignal(false);
const [showChatMaintenance, setShowChatMaintenance] = createSignal(false);
@@ -167,6 +168,9 @@ export const AISettings: Component = () => {
// Infrastructure control settings
controlLevel: 'read_only' as ControlLevel,
protectedGuests: '' as string, // Comma-separated VMIDs/names
// Discovery settings
discoveryEnabled: false,
discoveryIntervalHours: 0, // 0 = manual only
});
const resetForm = (data: AISettingsType | null) => {
@@ -196,6 +200,8 @@ export const AISettings: Component = () => {
requestTimeoutSeconds: 300,
controlLevel: 'read_only',
protectedGuests: '',
discoveryEnabled: false,
discoveryIntervalHours: 0,
});
return;
}
@@ -228,6 +234,8 @@ export const AISettings: Component = () => {
requestTimeoutSeconds: data.request_timeout_seconds ?? 300,
controlLevel: normalizeControlLevel(data.control_level),
protectedGuests: Array.isArray(data.protected_guests) ? data.protected_guests.join(', ') : '',
discoveryEnabled: data.discovery_enabled ?? false,
discoveryIntervalHours: data.discovery_interval_hours ?? 0,
});
// Auto-expand providers that are configured
@@ -265,8 +273,10 @@ export const AISettings: Component = () => {
const sessions = await AIChatAPI.listSessions();
setChatSessions(sessions);
const current = selectedSessionId();
if (!current || !sessions.some((session) => session.id === current)) {
setSelectedSessionId(sessions[0]?.id || '');
if (!Array.isArray(sessions) || sessions.length === 0) {
setSelectedSessionId('');
} else if (!current || !sessions.some((session) => session.id === current)) {
setSelectedSessionId(sessions[0].id);
}
} catch (error) {
logger.error('[AISettings] Failed to load chat sessions:', error);
@@ -584,6 +594,14 @@ export const AISettings: Component = () => {
payload.protected_guests = newProtected;
}
// Discovery settings
if (form.discoveryEnabled !== (settings()?.discovery_enabled ?? false)) {
payload.discovery_enabled = form.discoveryEnabled;
}
if (form.discoveryIntervalHours !== (settings()?.discovery_interval_hours ?? 0)) {
payload.discovery_interval_hours = form.discoveryIntervalHours;
}
const updated = await AIAPI.updateSettings(payload);
setSettings(updated);
resetForm(updated);
@@ -1524,6 +1542,81 @@ export const AISettings: Component = () => {
</Show>
</div>
{/* Discovery Settings - Collapsible */}
<div class="rounded-lg border border-cyan-200 dark:border-cyan-800 overflow-hidden">
<button
type="button"
class="w-full px-3 py-2 flex items-center justify-between bg-cyan-50 dark:bg-cyan-900/20 hover:bg-cyan-100 dark:hover:bg-cyan-900/30 transition-colors text-left"
onClick={() => setShowDiscoverySettings(!showDiscoverySettings())}
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Discovery Settings</span>
{/* Summary badges */}
<Show when={form.discoveryEnabled}>
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-cyan-100 dark:bg-cyan-800 text-cyan-700 dark:text-cyan-300 rounded">
{form.discoveryIntervalHours > 0 ? `${form.discoveryIntervalHours}h` : 'Manual'}
</span>
</Show>
<Show when={!form.discoveryEnabled}>
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Off</span>
</Show>
</div>
<svg class={`w-4 h-4 text-gray-500 transition-transform ${showDiscoverySettings() ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={showDiscoverySettings()}>
<div class="px-3 py-3 bg-white dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 space-y-3">
{/* Discovery Enabled Toggle */}
<div class="flex items-center justify-between gap-2">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
Enable Discovery
<HelpIcon inline={{ title: "What is Discovery?", description: "Discovery scans your VMs, containers, and Docker hosts to identify what services are running (databases, web servers, etc.), their versions, and how to access them. This information helps Pulse AI give you accurate troubleshooting commands and understand your infrastructure." }} size="xs" />
</label>
<Toggle
checked={form.discoveryEnabled}
onChange={(event) => setForm('discoveryEnabled', event.currentTarget.checked)}
disabled={saving()}
/>
</div>
{/* Discovery Interval - Only when enabled */}
<Show when={form.discoveryEnabled}>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-32 flex-shrink-0">Scan Interval</label>
<select
class="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
value={form.discoveryIntervalHours}
onChange={(e) => setForm('discoveryIntervalHours', parseInt(e.currentTarget.value, 10))}
disabled={saving()}
>
<option value={0}>Manual only</option>
<option value={6}>Every 6 hours</option>
<option value={12}>Every 12 hours</option>
<option value={24}>Every 24 hours</option>
<option value={48}>Every 2 days</option>
<option value={168}>Every 7 days</option>
</select>
</div>
<p class="text-[10px] text-gray-500 dark:text-gray-400 ml-32 pl-3">
{form.discoveryIntervalHours === 0
? 'Discovery runs only when you click "Update Discovery" on a resource'
: 'Discovery will automatically re-scan resources at this interval'}
</p>
</div>
</Show>
<p class="text-[10px] text-gray-500 dark:text-gray-400">
💡 Without Discovery, Pulse AI won't know what's running where. With it enabled, AI can suggest correct commands like "docker exec mydb psql..." instead of generic advice.
</p>
</div>
</Show>
</div>
{/* Usage Cost Controls - Compact */}
<div class="flex items-center gap-3 p-3 rounded-lg border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20">
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -8,6 +8,7 @@ import { StatusDot } from '@/components/shared/StatusDot';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
import { StorageFilter } from './StorageFilter';
import { StorageConfigPanel } from './StorageConfigPanel';
import { DiskList } from './DiskList';
import { ZFSHealthMap } from './ZFSHealthMap';
import { EnhancedStorageBar } from './EnhancedStorageBar';
@@ -624,6 +625,9 @@ const Storage: Component = () => {
searchInputRef={(el) => (searchInputRef = el)}
columnVisibility={columnVisibility}
/>
<Show when={connected()}>
<StorageConfigPanel nodeFilter={selectedNode()} searchTerm={searchTerm()} />
</Show>
</Show>
{/* Show simple search for disks */}

View File

@@ -0,0 +1,182 @@
import { Component, Show, For, createEffect, createMemo, createSignal } from 'solid-js';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { Card } from '@/components/shared/Card';
import { MonitoringAPI } from '@/api/monitoring';
import type { StorageConfigEntry } from '@/types/api';
interface StorageConfigPanelProps {
nodeFilter?: string | null;
searchTerm?: string;
}
export const StorageConfigPanel: Component<StorageConfigPanelProps> = (props) => {
const [items, setItems] = createSignal<StorageConfigEntry[]>([]);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [onlyNodeRelevant, setOnlyNodeRelevant] = usePersistentSignal<boolean>('storageConfigOnlyNode', false);
const fetchConfig = async () => {
setLoading(true);
setError(null);
try {
const storages = await MonitoringAPI.getStorageConfig({
node: props.nodeFilter ?? undefined,
});
setItems(storages);
} catch (err) {
setError((err as Error).message || 'Failed to load storage config');
} finally {
setLoading(false);
}
};
createEffect(() => {
fetchConfig();
});
const filtered = createMemo(() => {
const term = (props.searchTerm || '').trim().toLowerCase();
return items().filter((item) => {
if (onlyNodeRelevant() && props.nodeFilter) {
if (!item.nodes || item.nodes.length === 0) {
return false;
}
const match = item.nodes.some((node) => node.toLowerCase() === props.nodeFilter!.toLowerCase());
if (!match) {
return false;
}
}
if (!term) {
return true;
}
const haystack = [
item.id,
item.name,
item.instance,
item.type,
item.content,
item.path,
(item.nodes || []).join(','),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
});
const formatNodes = (nodes?: string[]) => {
if (!nodes || nodes.length === 0) {
return 'all nodes';
}
return nodes.join(', ');
};
return (
<Card padding="sm" class="mb-4">
<div class="flex items-center justify-between mb-2">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Storage Configuration</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
Cluster storage.cfg entries (enabled/active, nodes, path)
</p>
</div>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<Show when={props.nodeFilter}>
<button
type="button"
onClick={() => setOnlyNodeRelevant((prev) => !prev)}
class={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[10px] transition-colors ${
onlyNodeRelevant()
? 'border-blue-300 text-blue-700 bg-blue-50 dark:border-blue-700 dark:text-blue-300 dark:bg-blue-900/20'
: 'border-gray-200 text-gray-600 dark:border-gray-700 dark:text-gray-300'
}`}
>
{onlyNodeRelevant() ? 'Only selected node' : 'All nodes'}
</button>
</Show>
<Show when={loading()}>
<span class="inline-flex items-center gap-1">
<span class="h-1.5 w-1.5 rounded-full bg-blue-400 animate-pulse" />
Loading
</span>
</Show>
</div>
</div>
<Show when={error()}>
<div class="text-xs text-red-600 dark:text-red-400 mb-2">{error()}</div>
</Show>
<Show when={!loading() && filtered().length === 0}>
<div class="text-xs text-gray-500 dark:text-gray-400">No storage configuration entries found.</div>
</Show>
<Show when={filtered().length > 0}>
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
`}</style>
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th class="py-1.5 text-left font-medium uppercase tracking-wide">Storage</th>
<th class="py-1.5 text-left font-medium uppercase tracking-wide">Instance</th>
<th class="py-1.5 text-left font-medium uppercase tracking-wide">Nodes</th>
<th class="py-1.5 text-left font-medium uppercase tracking-wide">Path</th>
<th class="py-1.5 text-left font-medium uppercase tracking-wide">Flags</th>
</tr>
</thead>
<tbody>
<For each={filtered()}>
{(item) => (
<tr class="border-b border-gray-100 dark:border-gray-800/70">
<td class="py-2 pr-3">
<div class="flex items-center gap-2">
<div class="font-medium text-gray-900 dark:text-gray-100">{item.name}</div>
<Show when={!item.nodes || item.nodes.length === 0}>
<span class="px-1.5 py-0.5 rounded text-[10px] bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
global
</span>
</Show>
</div>
<div class="text-[10px] text-gray-500 dark:text-gray-400">{item.type || 'unknown'} / {item.content || 'any'}</div>
</td>
<td class="py-2 pr-3 text-gray-700 dark:text-gray-300">
{item.instance || '-'}
</td>
<td class="py-2 pr-3 text-gray-700 dark:text-gray-300">
{formatNodes(item.nodes)}
</td>
<td class="py-2 pr-3 font-mono text-[10px] text-gray-600 dark:text-gray-300">
{item.path || '-'}
</td>
<td class="py-2 text-gray-700 dark:text-gray-300">
<div class="flex flex-wrap gap-1">
<span class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
item.enabled ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}`}>
{item.enabled ? 'enabled' : 'disabled'}
</span>
<span class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
item.active ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}`}>
{item.active ? 'active' : 'inactive'}
</span>
<Show when={item.shared}>
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
shared
</span>
</Show>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</Card>
);
};

View File

@@ -0,0 +1,368 @@
/**
* PatrolActivitySection Component
*
* Displays patrol activity in a sysadmin-friendly format.
* Clear, scannable text stats instead of abstract visualizations.
*/
import { createResource, createMemo, Show, Component, For } from 'solid-js';
import { getPatrolRunHistory, type PatrolRunRecord } from '@/api/patrol';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import AlertCircleIcon from 'lucide-solid/icons/alert-circle';
import TrendingDownIcon from 'lucide-solid/icons/trending-down';
import TrendingUpIcon from 'lucide-solid/icons/trending-up';
import MinusIcon from 'lucide-solid/icons/minus';
import ClockIcon from 'lucide-solid/icons/clock';
import ActivityIcon from 'lucide-solid/icons/activity';
import WrenchIcon from 'lucide-solid/icons/wrench';
interface PatrolActivitySectionProps {
enabled?: boolean;
refreshTrigger?: number;
}
export const PatrolActivitySection: Component<PatrolActivitySectionProps> = (props) => {
const formatRelativeTime = (date: Date): string => {
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`;
return `${diffDays}d ago`;
};
const formatTrigger = (reason?: string) => {
switch (reason) {
case 'scheduled':
return 'Scheduled';
case 'manual':
return 'Manual';
case 'startup':
return 'Startup';
case 'alert_fired':
return 'Alert fired';
case 'alert_cleared':
return 'Alert cleared';
case 'anomaly':
return 'Anomaly';
case 'user_action':
return 'User action';
case 'config_changed':
return 'Config change';
default:
return reason ? reason.replace(/_/g, ' ') : '';
}
};
const formatScope = (run?: PatrolRunRecord) => {
if (!run) return '';
const idCount = run.scope_resource_ids?.length ?? 0;
if (idCount > 0) {
return `Scoped to ${idCount} resource${idCount === 1 ? '' : 's'}`;
}
const types = run.scope_resource_types ?? [];
if (types.length > 0) {
return `Scoped to ${types.join(', ')}`;
}
if (run.type === 'scoped') {
return 'Scoped';
}
return '';
};
const formatContext = (run?: PatrolRunRecord) => {
if (!run?.scope_context) return '';
const trimmed = run.scope_context.trim();
if (!trimmed) return '';
return trimmed.length > 48 ? `${trimmed.slice(0, 45)}` : trimmed;
};
const formatDuration = (ms?: number) => {
if (!ms || ms <= 0) return '';
if (ms < 1000) return `${ms}ms`;
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
return `${minutes}m`;
};
const shortId = (id?: string) => {
if (!id) return '';
return id.length > 8 ? id.slice(0, 8) : id;
};
// Fetch patrol run history
const [runs] = createResource(
() => props.refreshTrigger ?? 0,
async (_trigger): Promise<PatrolRunRecord[]> => {
try {
return await getPatrolRunHistory(100); // Get more for weekly stats
} catch (err) {
console.error('Failed to fetch patrol run history:', err);
return [];
}
}
);
// Calculate stats
const stats = createMemo(() => {
const allRuns = runs() || [];
if (allRuns.length === 0) {
return null;
}
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
// Today's runs
const todayRuns = allRuns.filter(r => new Date(r.started_at) >= todayStart);
const runsToday = todayRuns.length;
const newFindingsToday = todayRuns.reduce((sum, r) => sum + (r.new_findings || 0), 0);
// This week's runs
const thisWeekRuns = allRuns.filter(r => new Date(r.started_at) >= weekAgo);
const lastWeekRuns = allRuns.filter(r => {
const date = new Date(r.started_at);
return date >= twoWeeksAgo && date < weekAgo;
});
// Weekly trend calculation
const thisWeekFindings = thisWeekRuns.reduce((sum, r) => sum + (r.new_findings || 0) + (r.existing_findings || 0), 0);
const lastWeekFindings = lastWeekRuns.reduce((sum, r) => sum + (r.new_findings || 0) + (r.existing_findings || 0), 0);
let weeklyTrend: 'improving' | 'stable' | 'worsening' = 'stable';
let weeklyTrendPercent = 0;
if (lastWeekFindings > 0) {
const change = ((thisWeekFindings - lastWeekFindings) / lastWeekFindings) * 100;
weeklyTrendPercent = Math.abs(Math.round(change));
if (change < -10) {
weeklyTrend = 'improving';
} else if (change > 10) {
weeklyTrend = 'worsening';
}
} else if (thisWeekFindings > 0) {
weeklyTrend = 'worsening';
}
// Auto-resolved this week
const autoResolvedThisWeek = thisWeekRuns.reduce((sum, r) => sum + (r.resolved_findings || 0), 0);
const autoFixedThisWeek = thisWeekRuns.reduce((sum, r) => sum + (r.auto_fix_count || 0), 0);
// Last run info
const lastRun = allRuns[0];
const lastRunTime = lastRun ? new Date(lastRun.started_at) : null;
const lastRunStatus = lastRun?.status || 'unknown';
const lastRunHadErrors = lastRun?.error_count > 0;
const lastRunTrigger = lastRun?.trigger_reason;
const lastRunMetaParts = [
formatTrigger(lastRunTrigger),
formatScope(lastRun),
formatContext(lastRun),
].filter(Boolean);
const lastRunMeta = lastRunMetaParts.join(' • ');
// Format relative time
return {
runsToday,
newFindingsToday,
lastRunTime: lastRunTime ? formatRelativeTime(lastRunTime) : null,
lastRunStatus,
lastRunHadErrors,
lastRunMeta,
weeklyTrend,
weeklyTrendPercent,
autoResolvedThisWeek,
autoFixedThisWeek,
totalRuns: allRuns.length,
};
});
const recentRuns = createMemo(() => {
const allRuns = runs() || [];
return allRuns.slice(0, 5);
});
const isHealthy = () => {
const s = stats();
return s && !s.lastRunHadErrors && s.lastRunStatus !== 'error';
};
return (
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
{/* Loading State */}
<Show when={runs.loading}>
<div class="animate-pulse flex items-center gap-4">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-40" />
</div>
</Show>
{/* Empty State */}
<Show when={!runs.loading && (!runs() || runs()!.length === 0)}>
<div class="text-sm text-gray-500 dark:text-gray-400">
No patrol runs yet. Patrol will start monitoring automatically.
</div>
</Show>
{/* Stats Display */}
<Show when={!runs.loading && stats()}>
{(s) => (
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
{/* Status */}
<div class="flex items-center gap-2">
<Show
when={isHealthy()}
fallback={
<>
<AlertCircleIcon class="w-4 h-4 text-amber-500" />
<span class="text-amber-600 dark:text-amber-400 font-medium">Issues detected</span>
</>
}
>
<CheckCircleIcon class="w-4 h-4 text-green-500" />
<span class="text-green-600 dark:text-green-400 font-medium">Running normally</span>
</Show>
</div>
<div class="hidden sm:block h-4 w-px bg-gray-200 dark:bg-gray-700" />
{/* Last Run */}
<Show when={s().lastRunTime}>
<div class="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<ClockIcon class="w-3.5 h-3.5" />
<span>
Last run: {s().lastRunTime}
<Show when={s().lastRunMeta}>
<span class="text-gray-500 dark:text-gray-500"> {s().lastRunMeta}</span>
</Show>
</span>
</div>
</Show>
<div class="hidden sm:block h-4 w-px bg-gray-200 dark:bg-gray-700" />
{/* Today's Activity */}
<div class="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<ActivityIcon class="w-3.5 h-3.5" />
<span>
Today: {s().runsToday} {s().runsToday === 1 ? 'run' : 'runs'}
<Show when={s().newFindingsToday > 0}>
<span class="text-amber-600 dark:text-amber-400">
, {s().newFindingsToday} new {s().newFindingsToday === 1 ? 'finding' : 'findings'}
</span>
</Show>
</span>
</div>
<div class="hidden lg:block h-4 w-px bg-gray-200 dark:bg-gray-700" />
{/* Weekly Trend */}
<div class="hidden lg:flex items-center gap-1.5">
<Show when={s().weeklyTrend === 'improving'}>
<TrendingDownIcon class="w-3.5 h-3.5 text-green-500" />
<span class="text-green-600 dark:text-green-400">
{s().weeklyTrendPercent}% fewer findings this week
</span>
</Show>
<Show when={s().weeklyTrend === 'worsening'}>
<TrendingUpIcon class="w-3.5 h-3.5 text-red-500" />
<span class="text-red-600 dark:text-red-400">
{s().weeklyTrendPercent}% more findings this week
</span>
</Show>
<Show when={s().weeklyTrend === 'stable'}>
<MinusIcon class="w-3.5 h-3.5 text-gray-400" />
<span class="text-gray-500 dark:text-gray-400">
Findings stable this week
</span>
</Show>
</div>
{/* Auto-resolved */}
<Show when={s().autoResolvedThisWeek > 0 || s().autoFixedThisWeek > 0}>
<div class="hidden lg:block h-4 w-px bg-gray-200 dark:bg-gray-700" />
<div class="hidden lg:flex items-center gap-1.5 text-green-600 dark:text-green-400">
<WrenchIcon class="w-3.5 h-3.5" />
<span>
<Show when={s().autoResolvedThisWeek > 0}>
{s().autoResolvedThisWeek} resolved
</Show>
<Show when={s().autoResolvedThisWeek > 0 && s().autoFixedThisWeek > 0}>, </Show>
<Show when={s().autoFixedThisWeek > 0}>
{s().autoFixedThisWeek} auto-fixed
</Show>
{' '}this week
</span>
</div>
</Show>
</div>
)}
</Show>
<Show when={!runs.loading && recentRuns().length > 0}>
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Recent runs</div>
<div class="space-y-1">
<For each={recentRuns()}>
{(run) => {
const runMeta = [
formatTrigger(run.trigger_reason),
formatScope(run),
formatContext(run),
].filter(Boolean).join(' • ');
const duration = formatDuration(run.duration_ms);
return (
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-700 dark:text-gray-300">
{formatRelativeTime(new Date(run.started_at))}
</span>
<span class={`px-1.5 py-0.5 rounded ${
run.status === 'critical'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: run.status === 'issues_found'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: run.status === 'error'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
{run.status.replace(/_/g, ' ')}
</span>
<Show when={runMeta}>
<span>{runMeta}</span>
</Show>
<Show when={duration}>
<span> {duration}</span>
</Show>
<Show when={run.resources_checked}>
<span> {run.resources_checked} resources</span>
</Show>
<Show when={run.error_count && run.error_count > 0}>
<span class="text-red-600 dark:text-red-400"> {run.error_count} error{run.error_count === 1 ? '' : 's'}</span>
</Show>
<Show when={run.alert_id}>
<span class="text-amber-600 dark:text-amber-400"> Alert {shortId(run.alert_id)}</span>
</Show>
<Show when={run.finding_id}>
<span class="text-blue-600 dark:text-blue-400"> Finding {shortId(run.finding_id)}</span>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
</div>
);
};
export default PatrolActivitySection;

View File

@@ -0,0 +1 @@
export { PatrolActivitySection } from './PatrolActivitySection';

View File

@@ -6,13 +6,16 @@
import { createSignal, createEffect, onMount, onCleanup, createMemo, createResource, For, Show } from 'solid-js';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import { UnifiedFindingsPanel } from '@/components/AI/UnifiedFindingsPanel';
import { FindingsPanel } from '@/components/AI/FindingsPanel';
import {
getPatrolStatus,
getPatrolAutonomySettings,
updatePatrolAutonomySettings,
triggerPatrolRun,
getPatrolRunHistory,
type PatrolStatus,
type PatrolAutonomyLevel,
type PatrolRunRecord,
} from '@/api/patrol';
import { apiFetchJSON } from '@/utils/apiClient';
@@ -36,6 +39,7 @@ import BrainCircuitIcon from 'lucide-solid/icons/brain-circuit';
import ActivityIcon from 'lucide-solid/icons/activity';
import ShieldAlertIcon from 'lucide-solid/icons/shield-alert';
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
import PlayIcon from 'lucide-solid/icons/play';
import CircleHelpIcon from 'lucide-solid/icons/circle-help';
import XIcon from 'lucide-solid/icons/x';
import FlaskConicalIcon from 'lucide-solid/icons/flask-conical';
@@ -44,6 +48,7 @@ import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import SettingsIcon from 'lucide-solid/icons/settings';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
import { TogglePrimitive, Toggle } from '@/components/shared/Toggle';
import { PatrolActivitySection } from '@/components/patrol';
const INFO_BANNER_DISMISSED_KEY = 'patrol-info-banner-dismissed';
@@ -60,20 +65,25 @@ const SCHEDULE_PRESETS = [
{ value: 1440, label: '24 hours' },
];
type PatrolTab = 'findings' | 'activity' | 'history';
export function AIIntelligence() {
const [activeTab, setActiveTab] = createSignal<PatrolTab>('findings');
const [isRefreshing, setIsRefreshing] = createSignal(false);
const [autonomyLevel, setAutonomyLevel] = createSignal<PatrolAutonomyLevel>('monitor');
const [isUpdatingAutonomy, setIsUpdatingAutonomy] = createSignal(false);
const [showInfoBanner, setShowInfoBanner] = createSignal(
localStorage.getItem(INFO_BANNER_DISMISSED_KEY) !== 'true'
);
// Trigger to refresh patrol activity visualizations
const [activityRefreshTrigger, setActivityRefreshTrigger] = createSignal(0);
// Advanced autonomy settings
const [investigationBudget, setInvestigationBudget] = createSignal(15);
const [investigationTimeout, setInvestigationTimeout] = createSignal(300);
const [criticalRequireApproval, setCriticalRequireApproval] = createSignal(true);
const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false);
const [isSavingAdvanced, setIsSavingAdvanced] = createSignal(false);
const [fullModeUnlocked, setFullModeUnlocked] = createSignal(false);
let advancedSettingsRef: HTMLDivElement | undefined;
// Close popover when clicking outside
@@ -103,6 +113,51 @@ export function AIIntelligence() {
const [patrolEnabledLocal, setPatrolEnabledLocal] = createSignal<boolean>(true);
const [isUpdatingSettings, setIsUpdatingSettings] = createSignal(false);
const [isTogglingPatrol, setIsTogglingPatrol] = createSignal(false);
const [isTriggeringPatrol, setIsTriggeringPatrol] = createSignal(false);
const [selectedRun, setSelectedRun] = createSignal<PatrolRunRecord | null>(null);
const [showRunAnalysis, setShowRunAnalysis] = createSignal(false);
const scopeContext = createMemo(() => splitScopeContext(selectedRun()?.scope_context));
const runTokenUsage = createMemo(() => formatTokenUsage(selectedRun()));
const selectedRunFindings = createMemo(() => {
aiIntelligenceStore.findingsSignal();
const run = selectedRun();
if (!run || !run.finding_ids || run.finding_ids.length === 0) {
return [];
}
const idSet = new Set(run.finding_ids);
return aiIntelligenceStore.findings.filter((finding) => idSet.has(finding.id));
});
const scopeDrift = createMemo(() => {
const run = selectedRun();
if (!run) return null;
const scopeIds = run.scope_resource_ids ?? [];
const scopeTypes = run.scope_resource_types ?? [];
if (scopeIds.length === 0 && scopeTypes.length === 0) {
return null;
}
const findings = selectedRunFindings();
if (findings.length === 0) {
return null;
}
const hasIdScope = scopeIds.length > 0;
const hasTypeScope = scopeTypes.length > 0;
const outOfScope = findings.filter((finding) => {
const idMatch = hasIdScope ? scopeIds.includes(finding.resourceId) : false;
const typeMatch = hasTypeScope ? scopeTypes.includes(finding.resourceType) : false;
return !(idMatch || typeMatch);
});
if (outOfScope.length === 0) {
return null;
}
const examples = outOfScope
.map((finding) => finding.resourceName || finding.resourceId)
.filter(Boolean)
.slice(0, 3);
return {
count: outOfScope.length,
examples,
};
});
const scheduleOptions = createMemo(() => {
const current = patrolInterval();
@@ -170,6 +225,19 @@ export function AIIntelligence() {
}
}
async function handleRunPatrol() {
if (isTriggeringPatrol() || !canTriggerPatrol()) return;
setIsTriggeringPatrol(true);
try {
await triggerPatrolRun();
await loadAllData();
} catch (err) {
console.error('Failed to trigger patrol run:', err);
} finally {
setIsTriggeringPatrol(false);
}
}
// Update patrol model
async function handleModelChange(modelId: string) {
if (isUpdatingSettings()) return;
@@ -240,6 +308,82 @@ export function AIIntelligence() {
}
}
function formatTriggerReason(reason?: string): string {
switch (reason) {
case 'scheduled':
return 'Scheduled';
case 'manual':
return 'Manual';
case 'startup':
return 'Startup';
case 'alert_fired':
return 'Alert fired';
case 'alert_cleared':
return 'Alert cleared';
case 'anomaly':
return 'Anomaly';
case 'user_action':
return 'User action';
case 'config_changed':
return 'Config change';
default:
return reason ? reason.replace(/_/g, ' ') : 'Unknown';
}
}
function formatScope(run?: PatrolRunRecord | null): string {
if (!run) return '';
const idCount = run.scope_resource_ids?.length ?? 0;
if (idCount > 0) return `Scoped to ${idCount} resource${idCount === 1 ? '' : 's'}`;
const types = run.scope_resource_types ?? [];
if (types.length > 0) return `Scoped to ${types.join(', ')}`;
if (run.type === 'scoped') return 'Scoped';
return '';
}
function splitScopeContext(context?: string): { base: string; discovery: string } {
if (!context) {
return { base: '', discovery: '' };
}
const parts = context.split(' | ').map(part => part.trim()).filter(Boolean);
let discovery = '';
const baseParts: string[] = [];
for (const part of parts) {
if (!discovery && part.toLowerCase().startsWith('discovery:')) {
discovery = part.replace(/^discovery:\s*/i, '').trim();
} else {
baseParts.push(part);
}
}
return {
base: baseParts.join(' | ').trim(),
discovery,
};
}
function formatTokenUsage(run?: PatrolRunRecord | null): string {
if (!run) return '';
const input = run.input_tokens || 0;
const output = run.output_tokens || 0;
if (!input && !output) return '';
return `${input} in / ${output} out`;
}
function formatDurationMs(ms?: number): string {
if (!ms || ms <= 0) return '';
if (ms < 1000) return `${ms}ms`;
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
return `${minutes}m`;
}
function truncateText(text?: string, maxLen: number = 500): string {
if (!text) return '';
if (text.length <= maxLen) return text;
return `${text.slice(0, maxLen - 1)}`;
}
// Fetch patrol status to check license
const [patrolStatus, { refetch: refetchPatrolStatus }] = createResource<PatrolStatus | null>(async () => {
try {
@@ -249,35 +393,73 @@ export function AIIntelligence() {
}
});
const [patrolRunHistory] = createResource(
() => activityRefreshTrigger(),
async () => {
try {
return await getPatrolRunHistory(30);
} catch (err) {
console.error('Failed to load patrol run history:', err);
return [];
}
}
);
const licenseRequired = createMemo(() => patrolStatus()?.license_required ?? false);
const upgradeUrl = createMemo(() => patrolStatus()?.upgrade_url || 'https://pulserelay.pro/');
const blockedReason = createMemo(() => patrolStatus()?.blocked_reason?.trim() ?? '');
const blockedAt = createMemo(() => patrolStatus()?.blocked_at);
const showBlockedBanner = createMemo(() => patrolEnabledLocal() && !!blockedReason());
const errorCount = createMemo(() => patrolStatus()?.error_count ?? 0);
const showErrorBanner = createMemo(() => !showBlockedBanner() && errorCount() > 0);
const canTriggerPatrol = createMemo(() => patrolEnabledLocal() && !showBlockedBanner());
const triggerPatrolDisabledReason = createMemo(() => {
if (!patrolEnabledLocal()) return 'Patrol is disabled';
if (showBlockedBanner()) return blockedReason() || 'Patrol is paused';
return '';
});
const selectedRunFindingIds = createMemo(() => {
const run = selectedRun();
if (!run || !run.finding_ids || run.finding_ids.length === 0) return null;
return run.finding_ids;
});
createEffect(() => {
selectedRun();
setShowRunAnalysis(false);
});
// Load autonomy settings
async function loadAutonomySettings() {
try {
const settings = await getPatrolAutonomySettings();
setAutonomyLevel(settings.autonomy_level);
setFullModeUnlocked(settings.full_mode_unlocked);
setInvestigationBudget(settings.investigation_budget);
setInvestigationTimeout(settings.investigation_timeout_sec);
setCriticalRequireApproval(settings.critical_require_approval);
} catch (err) {
console.error('Failed to load autonomy settings:', err);
}
}
// Update autonomy level
// Update autonomy level (optimistic UI)
async function handleAutonomyChange(level: PatrolAutonomyLevel) {
if (isUpdatingAutonomy()) return;
const previousLevel = autonomyLevel();
setAutonomyLevel(level); // Optimistic update
setIsUpdatingAutonomy(true);
try {
const currentSettings = await getPatrolAutonomySettings();
await updatePatrolAutonomySettings({
...currentSettings,
autonomy_level: level,
});
setAutonomyLevel(level);
} catch (err) {
console.error('Failed to update autonomy:', err);
setAutonomyLevel(previousLevel); // Rollback on error
} finally {
setIsUpdatingAutonomy(false);
}
@@ -287,12 +469,17 @@ export function AIIntelligence() {
async function saveAdvancedSettings() {
setIsSavingAdvanced(true);
try {
await updatePatrolAutonomySettings({
const result = await updatePatrolAutonomySettings({
autonomy_level: autonomyLevel(),
full_mode_unlocked: fullModeUnlocked(),
investigation_budget: investigationBudget(),
investigation_timeout_sec: investigationTimeout(),
critical_require_approval: criticalRequireApproval(),
});
// Update local state from server response (handles auto-downgrade)
if (result.settings) {
setAutonomyLevel(result.settings.autonomy_level);
setFullModeUnlocked(result.settings.full_mode_unlocked);
}
setShowAdvancedSettings(false);
} catch (err) {
console.error('Failed to save advanced settings:', err);
@@ -321,21 +508,29 @@ export function AIIntelligence() {
aiIntelligenceStore.loadCircuitBreakerStatus(),
refetchPatrolStatus(),
]);
// Trigger refresh of patrol activity visualizations
setActivityRefreshTrigger(prev => prev + 1);
} finally {
setIsRefreshing(false);
}
}
const summaryStats = () => {
const findings = aiIntelligenceStore.findings;
const activeFindings = findings.filter(f => f.status === 'active');
const allFindings = aiIntelligenceStore.findings;
// Only count Patrol findings (exclude threshold alerts)
const patrolFindings = allFindings.filter(f =>
f.source !== 'threshold' && !f.isThreshold && !f.alertId
);
const activeFindings = patrolFindings.filter(f => f.status === 'active');
const resolvedFindings = patrolFindings.filter(f => f.status === 'resolved');
const criticalCount = activeFindings.filter(f => f.severity === 'critical').length;
const warningCount = activeFindings.filter(f => f.severity === 'warning').length;
const watchCount = activeFindings.filter(f => f.severity === 'watch').length;
const infoCount = activeFindings.filter(f => f.severity === 'info').length;
const investigatingCount = findings.filter(f => f.investigationStatus === 'running').length;
const investigatingCount = patrolFindings.filter(f => f.investigationStatus === 'running').length;
const totalActive = activeFindings.length;
const fixedCount = resolvedFindings.length;
return {
criticalFindings: criticalCount,
@@ -344,6 +539,8 @@ export function AIIntelligence() {
infoFindings: infoCount,
investigatingCount,
totalActive,
fixedCount,
hasAnyPatrolFindings: patrolFindings.length > 0,
};
};
@@ -375,6 +572,17 @@ export function AIIntelligence() {
</div>
</Show>
{/* Run Patrol Button */}
<button
onClick={() => handleRunPatrol()}
disabled={isTriggeringPatrol() || !canTriggerPatrol()}
title={triggerPatrolDisabledReason()}
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-600 disabled:text-gray-500 rounded-md transition-colors"
>
<PlayIcon class={`w-4 h-4 ${isTriggeringPatrol() ? 'animate-pulse' : ''}`} />
{isTriggeringPatrol() ? 'Running…' : 'Run Patrol'}
</button>
{/* Refresh Button */}
<button
onClick={() => loadAllData()}
@@ -456,25 +664,34 @@ export function AIIntelligence() {
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500 dark:text-gray-400">Mode:</span>
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
<For each={(['monitor', 'approval', 'full'] as PatrolAutonomyLevel[])}>
{(level) => (
<button
onClick={() => handleAutonomyChange(level)}
disabled={isUpdatingAutonomy() || !patrolEnabledLocal()}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
autonomyLevel() === level
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
} ${isUpdatingAutonomy() || !patrolEnabledLocal() ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{level === 'monitor' ? 'Monitor' : level === 'approval' ? 'Approval' : 'Auto'}
</button>
)}
<For each={(['monitor', 'approval', 'assisted', 'full'] as PatrolAutonomyLevel[])}>
{(level) => {
const isFullLocked = () => level === 'full' && !fullModeUnlocked();
const isDisabled = () => !patrolEnabledLocal() || isFullLocked();
return (
<button
onClick={() => handleAutonomyChange(level)}
disabled={isDisabled()}
title={isFullLocked() ? 'Enable in Advanced Settings (⚙️) first' : undefined}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
autonomyLevel() === level
? level === 'full'
? 'bg-red-500 dark:bg-red-600 text-white shadow-sm'
: 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: isFullLocked()
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
} ${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{level === 'monitor' ? 'Monitor' : level === 'approval' ? 'Approval' : level === 'assisted' ? 'Assisted' : 'Full'}
</button>
);
}}
</For>
</div>
<div class="relative group">
<CircleHelpIcon class="w-4 h-4 text-gray-400 dark:text-gray-500 cursor-help" />
<div class="absolute left-0 top-6 z-50 hidden group-hover:block w-64 p-3 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 text-xs">
<div class="absolute left-0 top-6 z-50 hidden group-hover:block w-72 p-3 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 text-xs">
<div class="space-y-2">
<div>
<span class="font-semibold text-gray-900 dark:text-white">Monitor</span>
@@ -482,19 +699,22 @@ export function AIIntelligence() {
</div>
<div>
<span class="font-semibold text-gray-900 dark:text-white">Approval</span>
<p class="text-gray-600 dark:text-gray-400">Patrol investigates findings. Fixes require your approval.</p>
<p class="text-gray-600 dark:text-gray-400">Patrol investigates findings. All fixes require your approval.</p>
</div>
<div>
<span class="font-semibold text-gray-900 dark:text-white">Auto</span>
<p class="text-gray-600 dark:text-gray-400">Patrol investigates and applies safe fixes. Critical fixes still need approval.</p>
<span class="font-semibold text-gray-900 dark:text-white">Assisted</span>
<p class="text-gray-600 dark:text-gray-400">Auto-fix warnings. Critical findings still need approval.</p>
</div>
<div>
<span class="font-semibold text-red-600 dark:text-red-400">Full</span>
<p class="text-gray-600 dark:text-gray-400">Auto-fix everything, including critical. Must be enabled in settings first.</p>
</div>
</div>
</div>
</div>
{/* Advanced Settings Gear */}
<Show when={autonomyLevel() !== 'monitor'}>
<div class="relative" ref={advancedSettingsRef}>
<div class="relative" ref={advancedSettingsRef}>
<button
onClick={() => setShowAdvancedSettings(!showAdvancedSettings())}
disabled={!patrolEnabledLocal()}
@@ -560,26 +780,34 @@ export function AIIntelligence() {
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Max time per investigation (1-30 min)</p>
</div>
{/* Critical Require Approval */}
<div class="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
<div>
<label class="text-xs text-gray-700 dark:text-gray-300">Critical requires approval</label>
<p class="text-[10px] text-gray-500 dark:text-gray-400">Always ask before fixing critical issues</p>
{/* Full Mode Unlock */}
<div class="pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-xs font-medium text-red-600 dark:text-red-400">Enable Full Mode</label>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
I understand that Full mode will auto-fix ALL findings including critical issues, without asking for approval.
</p>
</div>
<Toggle
checked={fullModeUnlocked()}
onChange={(e) => setFullModeUnlocked(e.currentTarget.checked)}
/>
</div>
<Toggle
checked={criticalRequireApproval()}
onChange={(e) => setCriticalRequireApproval(e.currentTarget.checked)}
disabled={isSavingAdvanced()}
/>
<Show when={fullModeUnlocked()}>
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<ShieldAlertIcon class="w-3 h-3 flex-shrink-0" />
Full mode is available. Click Save to apply.
</p>
</Show>
<Show when={!fullModeUnlocked() && autonomyLevel() === 'full'}>
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<ShieldAlertIcon class="w-3 h-3 flex-shrink-0" />
Saving will downgrade to Assisted mode.
</p>
</Show>
</div>
<Show when={!criticalRequireApproval()}>
<p class="text-[10px] text-amber-600 dark:text-amber-400 flex items-center gap-1">
<ShieldAlertIcon class="w-3 h-3" />
Critical fixes will execute without approval
</p>
</Show>
{/* Save Button */}
<button
onClick={saveAdvancedSettings}
@@ -595,12 +823,53 @@ export function AIIntelligence() {
</div>
</Show>
</div>
</Show>
</div>
</div>
</div>
<Show when={licenseRequired()}>
<Show when={showErrorBanner()}>
<div class="flex-shrink-0 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 p-1.5 bg-red-100 dark:bg-red-900/40 rounded-lg">
<ShieldAlertIcon class="w-4 h-4 text-red-600 dark:text-red-400" />
</div>
<div>
<p class="text-sm font-semibold text-red-900 dark:text-red-100">
Patrol hit errors in the last run
</p>
<p class="text-xs text-red-700 dark:text-red-300">
{errorCount()} error{errorCount() === 1 ? '' : 's'} reported. Check your AI provider settings and try again.
</p>
<Show when={patrolStatus()?.last_patrol_at}>
<p class="text-[10px] text-red-700/80 dark:text-red-300/80">
Last run {formatRelativeTime(patrolStatus()?.last_patrol_at)}
</p>
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<a
href="/settings/system-ai"
class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900/40 border border-red-200 dark:border-red-700 rounded-lg hover:bg-red-200/70 dark:hover:bg-red-900/60 transition-colors"
>
<SettingsIcon class="w-3.5 h-3.5" />
Open AI Settings
</a>
<button
type="button"
onClick={() => loadAllData()}
class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
<RefreshCwIcon class="w-3.5 h-3.5" />
Refresh
</button>
</div>
</div>
</div>
</Show>
<Show when={licenseRequired() && !showBlockedBanner()}>
<div class="flex-shrink-0 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-start gap-3">
@@ -609,10 +878,10 @@ export function AIIntelligence() {
</div>
<div>
<p class="text-sm font-semibold text-blue-900 dark:text-blue-100">
Unlock LLM-backed Patrol with Pulse Pro
Pulse Patrol requires Pulse Pro
</p>
<p class="text-xs text-blue-700 dark:text-blue-300">
Heuristic Patrol remains available. Upgrade to enable AI analysis, investigations, and auto-fix.
Upgrade to enable AI analysis, investigations, and auto-fix.
</p>
</div>
</div>
@@ -634,6 +903,51 @@ export function AIIntelligence() {
</div>
</Show>
<Show when={showBlockedBanner()}>
<div class="flex-shrink-0 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 p-1.5 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<ShieldAlertIcon class="w-4 h-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p class="text-sm font-semibold text-amber-900 dark:text-amber-100">
Patrol paused
</p>
<p class="text-xs text-amber-700 dark:text-amber-300">
{blockedReason()}
</p>
<Show when={blockedAt()}>
<p class="text-[10px] text-amber-700/80 dark:text-amber-300/80">
Blocked {formatRelativeTime(blockedAt())}
</p>
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<a
href="/settings/system-ai"
class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 border border-amber-200 dark:border-amber-700 rounded-lg hover:bg-amber-200/70 dark:hover:bg-amber-900/60 transition-colors"
>
<SettingsIcon class="w-3.5 h-3.5" />
Open AI Settings
</a>
<Show when={licenseRequired()}>
<a
href={upgradeUrl()}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-700 rounded-lg transition-colors"
>
<SparklesIcon class="w-3.5 h-3.5" />
Upgrade
</a>
</Show>
</div>
</div>
</div>
</Show>
{/* Info Banner */}
{showInfoBanner() && (
<div class="flex-shrink-0 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 px-4 py-3">
@@ -652,12 +966,12 @@ export function AIIntelligence() {
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">
<strong>How it works:</strong> Pulse constantly monitors your infrastructure. When alert thresholds
are crossed, findings are created automatically. In <strong>Approval</strong> or <strong>Auto</strong> mode,
are crossed, findings are created automatically. In <strong>Approval</strong>, <strong>Assisted</strong>, or <strong>Full</strong> mode,
Pulse Patrol investigates these findings - querying nodes, checking logs, and running diagnostics to
identify root causes. It then suggests fixes (Approval) or applies them automatically (Auto).
identify root causes. It then suggests fixes (Approval), applies safe fixes (Assisted), or applies all fixes (Full).
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
This is experimental. Critical and destructive actions always require approval.
This is experimental. In Assisted mode, critical findings still require approval. Full mode (requires unlock in ) auto-fixes everything.
</p>
</div>
<button
@@ -790,12 +1104,12 @@ export function AIIntelligence() {
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3">
<div class="flex items-center gap-2">
<div class={`p-1.5 rounded ${
(patrolStatus()?.fixed_count || 0) > 0
summaryStats().fixedCount > 0
? 'bg-green-100 dark:bg-green-900/30'
: 'bg-gray-100 dark:bg-gray-700'
}`}>
<CheckCircleIcon class={`w-4 h-4 ${
(patrolStatus()?.fixed_count || 0) > 0
summaryStats().fixedCount > 0
? 'text-green-600 dark:text-green-400'
: 'text-gray-400 dark:text-gray-500'
}`} />
@@ -803,18 +1117,278 @@ export function AIIntelligence() {
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Fixed</p>
<p class={`text-lg font-bold ${
(patrolStatus()?.fixed_count || 0) > 0
summaryStats().fixedCount > 0
? 'text-green-600 dark:text-green-400'
: 'text-gray-400 dark:text-gray-500'
}`}>
{patrolStatus()?.fixed_count || 0}
{summaryStats().fixedCount}
</p>
</div>
</div>
</div>
</div>
<UnifiedFindingsPanel />
{/* Tab Bar */}
<div class="flex items-center gap-1 border-b border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => setActiveTab('findings')}
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab() === 'findings'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
Findings
<Show when={summaryStats().totalActive > 0}>
<span class={`ml-1.5 px-1.5 py-0.5 text-xs rounded-full ${
summaryStats().criticalFindings > 0
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
}`}>
{summaryStats().totalActive}
</span>
</Show>
</button>
<button
type="button"
onClick={() => setActiveTab('activity')}
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab() === 'activity'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
Activity
</button>
<button
type="button"
onClick={() => setActiveTab('history')}
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab() === 'history'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
Run History
<Show when={(patrolRunHistory() || []).length > 0}>
<span class="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{(patrolRunHistory() || []).length}
</span>
</Show>
</button>
</div>
{/* Tab Content */}
<Show when={activeTab() === 'findings'}>
<Show when={selectedRun()}>
{(run) => (
<div class="flex items-center justify-between px-3 py-2 rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-xs text-blue-700 dark:text-blue-300">
<span>
Filtered to run {formatRelativeTime(run().started_at)} ({formatTriggerReason(run().trigger_reason)})
</span>
<button
type="button"
onClick={() => setSelectedRun(null)}
class="font-medium hover:underline"
>
Clear filter
</button>
</div>
)}
</Show>
<FindingsPanel
nextPatrolAt={patrolStatus()?.next_patrol_at}
lastPatrolAt={patrolStatus()?.last_patrol_at}
patrolIntervalMs={patrolStatus()?.interval_ms}
filterOverride={selectedRunFindingIds() ? 'all' : undefined}
filterFindingIds={selectedRunFindingIds() ?? undefined}
scopeResourceIds={selectedRun()?.scope_resource_ids}
scopeResourceTypes={selectedRun()?.scope_resource_types}
showScopeWarnings={Boolean(selectedRunFindingIds()?.length)}
/>
</Show>
<Show when={activeTab() === 'activity'}>
<Show
when={summaryStats().hasAnyPatrolFindings || patrolStatus()?.last_patrol_at}
fallback={
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8 text-center">
<ActivityIcon class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p class="text-sm text-gray-500 dark:text-gray-400">
No patrol activity yet. Run a patrol to see activity data.
</p>
</div>
}
>
<PatrolActivitySection
enabled={patrolEnabledLocal()}
refreshTrigger={activityRefreshTrigger()}
/>
</Show>
</Show>
<Show when={activeTab() === 'history'}>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Patrol Run History</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">
Select a run to filter findings to that snapshot
</p>
</div>
<Show when={selectedRun()}>
<button
type="button"
onClick={() => setSelectedRun(null)}
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
Clear filter
</button>
</Show>
</div>
<Show when={patrolRunHistory.loading}>
<div class="text-xs text-gray-500 dark:text-gray-400">Loading run history</div>
</Show>
<Show when={!patrolRunHistory.loading && (patrolRunHistory() || []).length === 0}>
<div class="text-center py-8">
<RefreshCwIcon class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p class="text-sm text-gray-500 dark:text-gray-400">
No patrol runs yet. Trigger a run to populate history.
</p>
</div>
</Show>
<Show when={!patrolRunHistory.loading && (patrolRunHistory() || []).length > 0}>
<div class="space-y-2">
<For each={patrolRunHistory() || []}>
{(run) => {
const scopeSummary = formatScope(run);
const duration = formatDurationMs(run.duration_ms);
const isSelected = () => selectedRun()?.id === run.id;
return (
<button
type="button"
onClick={() => {
setSelectedRun(isSelected() ? null : run);
if (!isSelected()) setActiveTab('findings');
}}
class={`w-full text-left px-3 py-2 rounded-md border transition-colors ${
isSelected()
? 'border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40'
}`}
>
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-900 dark:text-gray-100 font-medium">
{formatRelativeTime(run.started_at)}
</span>
<span class={`px-1.5 py-0.5 rounded ${
run.status === 'critical'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: run.status === 'issues_found'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: run.status === 'error'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
{run.status.replace(/_/g, ' ')}
</span>
<span>{formatTriggerReason(run.trigger_reason)}</span>
<Show when={scopeSummary}>
<span> {scopeSummary}</span>
</Show>
<Show when={duration}>
<span> {duration}</span>
</Show>
<Show when={run.resources_checked}>
<span> {run.resources_checked} resources</span>
</Show>
<Show when={run.new_findings}>
<span> {run.new_findings} new</span>
</Show>
</div>
</button>
);
}}
</For>
</div>
</Show>
<Show when={selectedRun()}>
{(run) => (
<div class="mt-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/40 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Run details</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{formatRelativeTime(run().started_at)} {formatTriggerReason(run().trigger_reason)}
</p>
</div>
<button
type="button"
onClick={() => setSelectedRun(null)}
class="text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
Close
</button>
</div>
<div class="mt-3 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-gray-600 dark:text-gray-400">
<div><span class="font-medium text-gray-700 dark:text-gray-300">Status:</span> {run().status.replace(/_/g, ' ')}</div>
<div><span class="font-medium text-gray-700 dark:text-gray-300">Duration:</span> {formatDurationMs(run().duration_ms) || '—'}</div>
<div><span class="font-medium text-gray-700 dark:text-gray-300">Resources:</span> {run().resources_checked || 0}</div>
<div><span class="font-medium text-gray-700 dark:text-gray-300">Findings:</span> {run().new_findings || 0} new</div>
<Show when={run().input_tokens || run().output_tokens}>
<div class="col-span-2">
<span class="font-medium text-gray-700 dark:text-gray-300">Tokens:</span>{' '}
{run().input_tokens || 0} in / {run().output_tokens || 0} out
</div>
</Show>
</div>
<Show when={run().ai_analysis}>
<div class="mt-3">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">AI analysis</span>
<button
type="button"
onClick={() => setShowRunAnalysis(!showRunAnalysis())}
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
{showRunAnalysis() ? 'Hide' : 'Show'}
</button>
</div>
<Show when={showRunAnalysis()}>
<pre class="mt-2 p-2 rounded bg-white dark:bg-gray-900 text-[11px] text-gray-700 dark:text-gray-200 whitespace-pre-wrap max-h-48 overflow-auto">
{run().ai_analysis}
</pre>
</Show>
<Show when={!showRunAnalysis()}>
<div class="mt-2 text-[11px] text-gray-500 dark:text-gray-400">
{truncateText(run().ai_analysis, 200)}
</div>
</Show>
</div>
</Show>
<div class="mt-3">
<button
type="button"
onClick={() => setActiveTab('findings')}
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
View findings from this run
</button>
</div>
</div>
)}
</Show>
</div>
</Show>
</div>
</div>
</div>

View File

@@ -2269,6 +2269,7 @@ function OverviewTab(props: {
hasAIAlertsFeature: () => boolean;
licenseLoading: () => boolean;
}) {
const location = useLocation();
// Loading states for buttons
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
const [incidentTimelines, setIncidentTimelines] = createSignal<Record<string, Incident | null>>({});
@@ -2279,6 +2280,7 @@ function OverviewTab(props: {
const [incidentEventFilters, setIncidentEventFilters] = createSignal<Set<string>>(
new Set(INCIDENT_EVENT_TYPES),
);
const [lastHashScrolled, setLastHashScrolled] = createSignal<string | null>(null);
const loadIncidentTimeline = async (alertId: string, startedAt?: string) => {
setIncidentLoading((prev) => ({ ...prev, [alertId]: true }));
@@ -2367,6 +2369,30 @@ function OverviewTab(props: {
const [bulkAckProcessing, setBulkAckProcessing] = createSignal(false);
const scrollToAlertHash = () => {
const hash = location.hash;
if (!hash || !hash.startsWith('#alert-')) {
setLastHashScrolled(null);
return;
}
if (hash === lastHashScrolled()) {
return;
}
const target = document.getElementById(hash.slice(1));
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
setLastHashScrolled(hash);
};
createEffect(() => {
location.hash;
filteredAlerts().length;
props.showAcknowledged();
requestAnimationFrame(scrollToAlertHash);
});
return (
<div class="space-y-4 sm:space-y-6">
{/* Stats Cards - only show cards not duplicated in sub-tabs */}
@@ -2535,6 +2561,7 @@ function OverviewTab(props: {
<For each={filteredAlerts()}>
{(alert) => (
<div
id={`alert-${alert.id}`}
class={`border rounded-lg p-3 sm:p-4 transition-all ${processingAlerts().has(alert.id) ? 'opacity-50' : ''
} ${alert.acknowledged
? 'opacity-60 border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/20'

View File

@@ -1,4 +1,5 @@
// Event bus for cross-component communication
import type { DiscoveryProgress } from '../types/discovery';
// Event types
export type EventType =
@@ -6,6 +7,7 @@ export type EventType =
| 'refresh_nodes'
| 'discovery_updated'
| 'discovery_status'
| 'ai_discovery_progress'
| 'theme_changed'
| 'websocket_reconnected';
@@ -51,6 +53,7 @@ export type EventDataMap = {
refresh_nodes: void;
discovery_updated: DiscoveryUpdatedData;
discovery_status: DiscoveryStatusData;
ai_discovery_progress: DiscoveryProgress;
theme_changed: string; // 'light' or 'dark'
websocket_reconnected: void; // Emitted when WebSocket successfully reconnects
};

View File

@@ -813,6 +813,9 @@ export function createWebSocketStore(url: string) {
scanning: false,
timestamp: message.data?.timestamp,
});
} else if ((message as {type: string}).type === 'ai_discovery_progress') {
// AI-powered discovery progress update
eventBus.emit('ai_discovery_progress', (message as {data: unknown}).data as import('../types/discovery').DiscoveryProgress);
} else if (message.type === 'settingsUpdate') {
// Settings have been updated (e.g., theme change)
if (message.data?.theme) {

View File

@@ -51,6 +51,10 @@ export interface AISettings {
// Infrastructure control settings
control_level?: 'read_only' | 'controlled' | 'autonomous';
protected_guests?: string[];
// AI Discovery settings
discovery_enabled?: boolean;
discovery_interval_hours?: number;
}
export interface AISettingsUpdateRequest {
@@ -94,6 +98,10 @@ export interface AISettingsUpdateRequest {
// Infrastructure control settings
control_level?: 'read_only' | 'controlled' | 'autonomous';
protected_guests?: string[];
// AI Discovery settings
discovery_enabled?: boolean;
discovery_interval_hours?: number;
}

View File

@@ -599,6 +599,19 @@ export interface Storage {
zfsPool?: ZFSPool;
}
export interface StorageConfigEntry {
id: string;
name: string;
instance?: string;
type?: string;
content?: string;
nodes?: string[];
path?: string;
shared: boolean;
enabled: boolean;
active: boolean;
}
export interface CephCluster {
id: string;
instance: string;

View File

@@ -0,0 +1,143 @@
// Discovery types for AI-powered infrastructure discovery
export type ResourceType = 'vm' | 'lxc' | 'docker' | 'k8s' | 'host' | 'docker_vm' | 'docker_lxc';
export type ServiceCategory =
| 'database'
| 'web_server'
| 'cache'
| 'message_queue'
| 'monitoring'
| 'backup'
| 'nvr'
| 'storage'
| 'container'
| 'virtualizer'
| 'network'
| 'security'
| 'media'
| 'home_automation'
| 'unknown';
export type FactCategory =
| 'version'
| 'config'
| 'service'
| 'port'
| 'hardware'
| 'network'
| 'storage'
| 'dependency'
| 'security';
export interface DiscoveryFact {
category: FactCategory;
key: string;
value: string;
source: string;
confidence: number;
discovered_at: string;
}
export interface PortInfo {
port: number;
protocol: string;
process: string;
address: string;
}
export interface ResourceDiscovery {
id: string;
resource_type: ResourceType;
resource_id: string;
host_id: string;
hostname: string;
service_type: string;
service_name: string;
service_version: string;
category: ServiceCategory;
cli_access: string;
facts: DiscoveryFact[];
config_paths: string[];
data_paths: string[];
log_paths: string[];
ports: PortInfo[];
user_notes: string;
user_secrets: Record<string, string>;
confidence: number;
ai_reasoning: string;
discovered_at: string;
updated_at: string;
scan_duration: number;
raw_command_output?: Record<string, string>;
// Fingerprint tracking for just-in-time discovery
fingerprint?: string; // Hash when discovery was done
fingerprinted_at?: string; // When fingerprint was captured
fingerprint_schema_version?: number; // Schema version when fingerprint was captured
}
export interface DiscoverySummary {
id: string;
resource_type: ResourceType;
resource_id: string;
host_id: string;
hostname: string;
service_type: string;
service_name: string;
service_version: string;
category: ServiceCategory;
confidence: number;
has_user_notes: boolean;
updated_at: string;
fingerprint?: string; // Current fingerprint
needs_discovery?: boolean; // True if fingerprint changed
}
export interface DiscoveryProgress {
resource_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'not_started';
current_step?: string; // Empty when idle
current_command?: string; // Current command being executed
total_steps?: number; // 0 when idle
completed_steps?: number; // 0 when idle
elapsed_ms?: number; // Milliseconds since scan started
percent_complete?: number; // 0-100 percentage
started_at?: string; // Empty when not_started
error?: string;
updated_at?: string;
}
export interface DiscoveryListResponse {
discoveries: DiscoverySummary[];
total: number;
}
export interface DiscoveryStatus {
running: boolean;
last_run: string;
interval: string;
cache_size: number;
ai_analyzer_set: boolean;
scanner_set: boolean;
store_set: boolean;
// Fingerprint-based discovery stats
max_discovery_age?: string;
fingerprint_count?: number;
last_fingerprint_scan?: string;
changed_count?: number; // Containers with changed fingerprints
stale_count?: number; // Discoveries > 30 days old
}
export interface TriggerDiscoveryRequest {
force?: boolean;
hostname?: string;
}
export interface UpdateNotesRequest {
user_notes: string;
user_secrets?: Record<string, string>;
}
export interface UpdateSettingsRequest {
max_discovery_age_days?: number; // Days before rediscovery (default 30)
}