mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
227
frontend-modern/src/api/discovery.ts
Normal file
227
frontend-modern/src/api/discovery.ts
Normal 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' };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
189
frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx
Normal file
189
frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
1158
frontend-modern/src/components/AI/FindingsPanel.tsx
Normal file
1158
frontend-modern/src/components/AI/FindingsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
445
frontend-modern/src/components/Discovery/DiscoveryTab.tsx
Normal file
445
frontend-modern/src/components/Discovery/DiscoveryTab.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
182
frontend-modern/src/components/Storage/StorageConfigPanel.tsx
Normal file
182
frontend-modern/src/components/Storage/StorageConfigPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
368
frontend-modern/src/components/patrol/PatrolActivitySection.tsx
Normal file
368
frontend-modern/src/components/patrol/PatrolActivitySection.tsx
Normal 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;
|
||||
1
frontend-modern/src/components/patrol/index.ts
Normal file
1
frontend-modern/src/components/patrol/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PatrolActivitySection } from './PatrolActivitySection';
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
143
frontend-modern/src/types/discovery.ts
Normal file
143
frontend-modern/src/types/discovery.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user