diff --git a/aggregator-web/src/components/AgentHealth.tsx b/aggregator-web/src/components/AgentHealth.tsx new file mode 100644 index 0000000..3c3fff8 --- /dev/null +++ b/aggregator-web/src/components/AgentHealth.tsx @@ -0,0 +1,665 @@ +import React, { useState, useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + RefreshCw, + Activity, + Play, + HardDrive, + Cpu, + Container, + Package, + Shield, + Fingerprint, + CheckCircle, + AlertCircle, + XCircle, + Upload, +} from 'lucide-react'; +import { formatRelativeTime } from '@/lib/utils'; +import { agentApi, securityApi } from '@/lib/api'; +import toast from 'react-hot-toast'; +import { cn } from '@/lib/utils'; +import { AgentSubsystem } from '@/types'; +import { AgentUpdatesModal } from './AgentUpdatesModal'; + +interface AgentHealthProps { + agentId: string; +} + +// Map subsystem types to icons and display names +const subsystemConfig: Record = { + updates: { + icon: , + name: 'Package Update Scanner', + description: 'Scans for available package updates', + category: 'system', + }, + storage: { + icon: , + name: 'Disk Usage Reporter', + description: 'Reports disk usage metrics and storage availability', + category: 'storage', + }, + system: { + icon: , + name: 'System Metrics Scanner', + description: 'Reports CPU, memory, processes, and system uptime', + category: 'system', + }, + docker: { + icon: , + name: 'Docker Image Scanner', + description: 'Scans Docker containers for available image updates', + category: 'system', + }, +}; + +export function AgentHealth({ agentId }: AgentHealthProps) { + const [showUpdateModal, setShowUpdateModal] = useState(false); + const queryClient = useQueryClient(); + + // Fetch subsystems from API + const { data: subsystems = [], isLoading, refetch } = useQuery({ + queryKey: ['subsystems', agentId], + queryFn: async () => { + const data = await agentApi.getSubsystems(agentId); + return data; + }, + refetchInterval: 30000, // Refresh every 30 seconds + }); + + // Fetch agent data for Update Agent button + const { data: agent } = useQuery({ + queryKey: ['agent', agentId], + queryFn: () => agentApi.getAgent(agentId), + refetchInterval: 30000, + }); + + // Fetch security health status + const { data: securityOverview, isLoading: securityLoading } = useQuery({ + queryKey: ['security-overview'], + queryFn: async () => { + const data = await securityApi.getOverview(); + return data; + }, + refetchInterval: 60000, // Refresh every minute + }); + + // Helper function to get security status color and icon + const getSecurityStatusDisplay = (status: string) => { + switch (status) { + case 'healthy': + case 'operational': + return { + color: 'text-green-600 bg-green-100 border-green-200', + icon: + }; + case 'enforced': + return { + color: 'text-blue-600 bg-blue-100 border-blue-200', + icon: + }; + case 'degraded': + return { + color: 'text-amber-600 bg-amber-100 border-amber-200', + icon: + }; + case 'unhealthy': + case 'unavailable': + return { + color: 'text-red-600 bg-red-100 border-red-200', + icon: + }; + default: + return { + color: 'text-gray-600 bg-gray-100 border-gray-200', + icon: + }; + } + }; + + // Get security icon for subsystem type + const getSecurityIcon = (type: string) => { + switch (type) { + case 'ed25519_signing': + return ; + case 'nonce_validation': + return ; + case 'machine_binding': + return ; + case 'command_validation': + return ; + default: + return ; + } + }; + + // Get display name for security subsystem + const getSecurityDisplayName = (type: string) => { + switch (type) { + case 'ed25519_signing': + return 'Ed25519 Signing'; + case 'nonce_validation': + return 'Nonce Protection'; + case 'machine_binding': + return 'Machine Binding'; + case 'command_validation': + return 'Command Validation'; + default: + return type; + } + }; + + // Toggle subsystem enabled/disabled + const toggleSubsystemMutation = useMutation({ + mutationFn: async ({ subsystem, enabled }: { subsystem: string; enabled: boolean }) => { + if (enabled) { + return await agentApi.enableSubsystem(agentId, subsystem); + } else { + return await agentApi.disableSubsystem(agentId, subsystem); + } + }, + onSuccess: (_, variables) => { + toast.success(`${subsystemConfig[variables.subsystem]?.name || variables.subsystem} ${variables.enabled ? 'enabled' : 'disabled'}`); + queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); + }, + onError: (error: any, variables) => { + toast.error(`Failed to ${variables.enabled ? 'enable' : 'disable'} subsystem: ${error.response?.data?.error || error.message}`); + }, + }); + + // Update subsystem interval + const updateIntervalMutation = useMutation({ + mutationFn: async ({ subsystem, intervalMinutes }: { subsystem: string; intervalMinutes: number }) => { + return await agentApi.setSubsystemInterval(agentId, subsystem, intervalMinutes); + }, + onSuccess: (_, variables) => { + toast.success(`Interval updated to ${variables.intervalMinutes} minutes`); + queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); + }, + onError: (error: any) => { + toast.error(`Failed to update interval: ${error.response?.data?.error || error.message}`); + }, + }); + + // Toggle auto-run + const toggleAutoRunMutation = useMutation({ + mutationFn: async ({ subsystem, autoRun }: { subsystem: string; autoRun: boolean }) => { + return await agentApi.setSubsystemAutoRun(agentId, subsystem, autoRun); + }, + onSuccess: (_, variables) => { + toast.success(`Auto-run ${variables.autoRun ? 'enabled' : 'disabled'}`); + queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); + }, + onError: (error: any) => { + toast.error(`Failed to toggle auto-run: ${error.response?.data?.error || error.message}`); + }, + }); + + // Trigger manual scan + const triggerScanMutation = useMutation({ + mutationFn: async (subsystem: string) => { + return await agentApi.triggerSubsystem(agentId, subsystem); + }, + onSuccess: (_, subsystem) => { + toast.success(`${subsystemConfig[subsystem]?.name || subsystem} scan triggered`); + queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); + }, + onError: (error: any) => { + toast.error(`Failed to trigger scan: ${error.response?.data?.error || error.message}`); + }, + }); + + const handleToggleEnabled = (subsystem: string, currentEnabled: boolean) => { + toggleSubsystemMutation.mutate({ subsystem, enabled: !currentEnabled }); + }; + + const handleIntervalChange = (subsystem: string, intervalMinutes: number) => { + updateIntervalMutation.mutate({ subsystem, intervalMinutes }); + }; + + const handleToggleAutoRun = (subsystem: string, currentAutoRun: boolean) => { + toggleAutoRunMutation.mutate({ subsystem, autoRun: !currentAutoRun }); + }; + + const handleTriggerScan = (subsystem: string) => { + triggerScanMutation.mutate(subsystem); + }; + + // Get package manager badges based on OS type + const getPackageManagerBadges = (osType: string) => { + const os = osType.toLowerCase(); + const badges = []; + + if (os.includes('windows')) { + badges.push( + Windows, + Winget + ); + } else if (os.includes('fedora') || os.includes('rhel') || os.includes('centos')) { + badges.push( + DNF + ); + } else if (os.includes('debian') || os.includes('ubuntu') || os.includes('linux')) { + badges.push( + APT + ); + } + + // Docker is cross-platform + badges.push( + Docker + ); + + return badges; + }; + + const frequencyOptions = [ + { value: 5, label: '5 min' }, + { value: 15, label: '15 min' }, + { value: 30, label: '30 min' }, + { value: 60, label: '1 hour' }, + { value: 240, label: '4 hours' }, + { value: 720, label: '12 hours' }, + { value: 1440, label: '24 hours' }, + { value: 10080, label: '1 week' }, + { value: 20160, label: '2 weeks' }, + ]; + + + const enabledCount = useMemo(() => subsystems.filter(s => s.enabled).length, [subsystems]); + const autoRunCount = useMemo(() => subsystems.filter(s => s.auto_run && s.enabled).length, [subsystems]); + + return ( +
+ {/* Subsystems Section - Continuous Surface */} +
+
+
+

Subsystems

+

+ {enabledCount} enabled • {autoRunCount} auto-running • {subsystems.length} total +

+
+ +
+ + {isLoading ? ( +
+ + Loading subsystems... +
+ ) : subsystems.length === 0 ? ( +
+ +

No subsystems found

+

+ Subsystems will be created automatically when the agent checks in. +

+
+ ) : ( +
+ + + + + + + + + + + + + + + {subsystems.map((subsystem: AgentSubsystem) => { + const config = subsystemConfig[subsystem.subsystem] || { + icon: , + name: subsystem.subsystem, + description: 'Custom subsystem', + category: 'system', + }; + + return ( + + {/* Subsystem Name */} + + + {/* Category */} + + + {/* Enabled Toggle */} + + + {/* Auto-Run Toggle */} + + + {/* Interval Selector */} + + + {/* Last Run */} + + + {/* Next Run */} + + + {/* Actions */} + + + ); + })} + +
SubsystemCategoryEnabledAuto-RunIntervalLast RunNext RunActions
+
+ {config.icon} +
+
{config.name}
+
+ {subsystem.subsystem === 'updates' ? ( +
+ Scans for available package updates +
+ {getPackageManagerBadges(agent?.os_type || '')} +
+
+ ) : ( + config.description + )} +
+
+
+
{config.category} + + + + + {subsystem.enabled ? ( + + ) : ( + - + )} + + {subsystem.last_run_at ? formatRelativeTime(subsystem.last_run_at) : '-'} + + {subsystem.next_run_at && subsystem.auto_run ? formatRelativeTime(subsystem.next_run_at) : '-'} + + +
+
+ )} +
+ + {/* Security Health Section - Continuous Surface */} +
+
+
+ +

Security Health

+
+ +
+ + {securityLoading ? ( +
+ + Loading security status... +
+ ) : securityOverview ? ( +
+ {/* Overall Status - Compact */} +
+
+
+
+

Overall Status

+

+ {securityOverview.overall_status === 'healthy' ? 'All systems nominal' : + securityOverview.overall_status === 'degraded' ? `${securityOverview.alerts.length} issue(s)` : + 'Critical issues'} +

+
+
+
+ {securityOverview.overall_status === 'healthy' && } + {securityOverview.overall_status === 'degraded' && } + {securityOverview.overall_status === 'unhealthy' && } + {securityOverview.overall_status.toUpperCase()} +
+
+ + {/* Security Grid - 2x2 Layout */} +
+ {Object.entries(securityOverview.subsystems).map(([key, subsystem]) => { + const statusColors = { + healthy: 'bg-green-100 text-green-700 border-green-200', + enforced: 'bg-blue-100 text-blue-700 border-blue-200', + degraded: 'bg-amber-100 text-amber-700 border-amber-200', + unhealthy: 'bg-red-100 text-red-700 border-red-200' + }; + + return ( +
+
+
+
+
+ {getSecurityIcon(key)} +
+
+

+ {getSecurityDisplayName(key)} +

+

+ {key === 'command_validation' ? + `${subsystem.metrics?.total_pending_commands || 0} pending` : + key === 'ed25519_signing' ? + 'Key valid' : + key === 'machine_binding' ? + `${subsystem.checks?.recent_violations || 0} violations` : + key === 'nonce_validation' ? + `${subsystem.checks?.validation_failures || 0} blocked` : + subsystem.status} +

+
+
+
+ {subsystem.status === 'healthy' && } + {subsystem.status === 'enforced' && } + {subsystem.status === 'degraded' && } + {subsystem.status === 'unhealthy' && } +
+
+
+
+ ); + })} +
+ + {/* Detailed Info Panel */} +
+ {Object.entries(securityOverview.subsystems).map(([key, subsystem]) => { + const checks = subsystem.checks || {}; + + return ( +
+
+

+ {key === 'nonce_validation' ? + `Nonces: ${subsystem.metrics?.total_pending_commands || 0} | Max: ${checks.max_age_minutes || 5}m | Failures: ${checks.validation_failures || 0}` : + key === 'machine_binding' ? + `Bound: ${checks.bound_agents || 'N/A'} | Violations: ${checks.recent_violations || 0} | Method: Hardware` : + key === 'ed25519_signing' ? + `Key: ${checks.public_key_fingerprint?.substring(0, 16) || 'N/A'}... | Algo: ${checks.algorithm || 'Ed25519'}` : + key === 'command_validation' ? + `Processed: ${subsystem.metrics?.commands_last_hour || 0}/hr | Pending: ${subsystem.metrics?.total_pending_commands || 0}` : + `Status: ${subsystem.status}`} +

+
+
+ ); + })} +
+ + {/* Security Alerts & Recommendations */} + {(securityOverview.alerts.length > 0 || securityOverview.recommendations.length > 0) && ( +
+ {securityOverview.alerts.length > 0 && ( +
+
+ +

Alerts ({securityOverview.alerts.length})

+
+
    + {securityOverview.alerts.slice(0, 1).map((alert, index) => ( +
  • • {alert}
  • + ))} + {securityOverview.alerts.length > 1 && ( +
  • +{securityOverview.alerts.length - 1} more
  • + )} +
+
+ )} + + {securityOverview.recommendations.length > 0 && ( +
+
+ +

Recs ({securityOverview.recommendations.length})

+
+
    + {securityOverview.recommendations.slice(0, 1).map((rec, index) => ( +
  • • {rec}
  • + ))} + {securityOverview.recommendations.length > 1 && ( +
  • +{securityOverview.recommendations.length - 1} more
  • + )} +
+
+ )} +
+ )} + + {/* Stats Row */} +
+
+

{Object.keys(securityOverview.subsystems).length}

+

Systems

+
+
+

+ {Object.values(securityOverview.subsystems).filter(s => s.status === 'healthy' || s.status === 'enforced').length} +

+

Healthy

+
+
+

{securityOverview.alerts.length}

+

Alerts

+
+
+

+ {new Date(securityOverview.timestamp).toLocaleTimeString()} +

+

Updated

+
+
+
+ ) : ( +
+ +

Unable to load security status

+
+ )} +
+ + {/* Agent Updates Modal */} + { + setShowUpdateModal(false); + }} + selectedAgentIds={[agentId]} // Single agent for this scanner view + onAgentsUpdated={() => { + // Refresh agent and subsystems data after update + queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); + queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] }); + }} + /> +
+ ); +} \ No newline at end of file