feat: granular subsystem commands with parallel scanner execution
Split monolithic scan_updates into individual subsystems (updates/storage/system/docker). Scanners now run in parallel via goroutines - cuts scan time roughly in half, maybe more. Agent changes: - Orchestrator pattern for scanner management - New scanners: storage (disk metrics), system (cpu/mem/processes) - New commands: scan_storage, scan_system, scan_docker - Wrapped existing scanners (APT/DNF/Docker/Windows/Winget) with common interface - Version bump to 0.1.20 Server changes: - Migration 015: agent_subsystems table with trigger for auto-init - Subsystem CRUD: enable/disable, interval (5min-24hr), auto-run toggle - API routes: /api/v1/agents/:id/subsystems/* (9 endpoints) - Stats tracking per subsystem Web UI changes: - ChatTimeline shows subsystem-specific labels and icons - AgentScanners got interactive toggles, interval dropdowns, manual trigger buttons - TypeScript types added for subsystems Backward compatible with legacy scan_updates - for now. Bugs probably exist somewhere.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
MonitorPlay,
|
||||
RefreshCw,
|
||||
@@ -13,162 +13,136 @@ import {
|
||||
Database,
|
||||
Shield,
|
||||
Search,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Container,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AgentSubsystem } from '@/types';
|
||||
|
||||
interface AgentScannersProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface ScannerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
enabled: boolean;
|
||||
frequency: number; // minutes
|
||||
last_run?: string;
|
||||
next_run?: string;
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
category: 'storage' | 'security' | 'system' | 'network';
|
||||
}
|
||||
|
||||
interface ScannerResponse {
|
||||
scanner_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
next_run?: string;
|
||||
}
|
||||
// Map subsystem types to icons and display names
|
||||
const subsystemConfig: Record<string, { icon: React.ReactNode; name: string; description: string; category: string }> = {
|
||||
updates: {
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
name: 'Package Update Scanner',
|
||||
description: 'Scans for available package updates (APT, DNF, Windows Update, etc.)',
|
||||
category: 'system',
|
||||
},
|
||||
storage: {
|
||||
icon: <HardDrive className="h-4 w-4" />,
|
||||
name: 'Disk Usage Reporter',
|
||||
description: 'Reports disk usage metrics and storage availability',
|
||||
category: 'storage',
|
||||
},
|
||||
system: {
|
||||
icon: <Cpu className="h-4 w-4" />,
|
||||
name: 'System Metrics Scanner',
|
||||
description: 'Reports CPU, memory, processes, and system uptime',
|
||||
category: 'system',
|
||||
},
|
||||
docker: {
|
||||
icon: <Container className="h-4 w-4" />,
|
||||
name: 'Docker Image Scanner',
|
||||
description: 'Scans Docker containers for available image updates',
|
||||
category: 'system',
|
||||
},
|
||||
};
|
||||
|
||||
export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
// Mock agent health monitoring configs - in real implementation, these would come from the backend
|
||||
const [scanners, setScanners] = useState<ScannerConfig[]>([
|
||||
{
|
||||
id: 'disk-reporter',
|
||||
name: 'Disk Usage Reporter',
|
||||
description: 'Agent reports disk usage metrics to server',
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
enabled: true,
|
||||
frequency: 15, // 15 minutes
|
||||
last_run: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago
|
||||
status: 'completed',
|
||||
category: 'storage',
|
||||
},
|
||||
{
|
||||
id: 'docker-check',
|
||||
name: 'Docker Check-in',
|
||||
description: 'Agent checks for Docker container status',
|
||||
icon: <Search className="h-4 w-4" />,
|
||||
enabled: true,
|
||||
frequency: 60, // 1 hour
|
||||
last_run: new Date(Date.now() - 45 * 60 * 1000).toISOString(), // 45 minutes ago
|
||||
status: 'completed',
|
||||
category: 'system',
|
||||
},
|
||||
{
|
||||
id: 'security-check',
|
||||
name: 'Security Check-in (Coming Soon)',
|
||||
description: 'CVE scanning & security advisory checks - not yet implemented',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
enabled: false,
|
||||
frequency: 240, // 4 hours
|
||||
status: 'idle',
|
||||
category: 'security',
|
||||
},
|
||||
{
|
||||
id: 'agent-heartbeat',
|
||||
name: 'Agent Heartbeat',
|
||||
description: 'Agent check-in interval and health reporting',
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
enabled: true,
|
||||
frequency: 30, // 30 minutes
|
||||
last_run: new Date(Date.now() - 5 * 60 * 1000).toISOString(), // 5 minutes ago
|
||||
status: 'running',
|
||||
category: 'system',
|
||||
},
|
||||
]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Toggle scanner mutation
|
||||
const toggleScannerMutation = useMutation({
|
||||
mutationFn: async ({ scannerId, enabled, frequency }: { scannerId: string; enabled: boolean; frequency: number }) => {
|
||||
const response = await agentApi.toggleScanner(agentId, scannerId, enabled, frequency);
|
||||
return response;
|
||||
// Fetch subsystems from API
|
||||
const { data: subsystems = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ['subsystems', agentId],
|
||||
queryFn: async () => {
|
||||
const data = await agentApi.getSubsystems(agentId);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data: ScannerResponse, variables) => {
|
||||
toast.success(`Scanner ${variables.enabled ? 'enabled' : 'disabled'} successfully`);
|
||||
// Update local state
|
||||
setScanners(prev => prev.map(scanner =>
|
||||
scanner.id === variables.scannerId
|
||||
? {
|
||||
...scanner,
|
||||
enabled: variables.enabled,
|
||||
frequency: variables.frequency,
|
||||
status: variables.enabled ? 'idle' : 'disabled' as any,
|
||||
next_run: data.next_run
|
||||
}
|
||||
: scanner
|
||||
));
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
// 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: (data, 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'} scanner: ${error.message || 'Unknown error'}`);
|
||||
toast.error(`Failed to ${variables.enabled ? 'enable' : 'disable'} subsystem: ${error.response?.data?.error || error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Run scanner mutation
|
||||
const runScannerMutation = useMutation({
|
||||
mutationFn: async (scannerId: string) => {
|
||||
const response = await agentApi.runScanner(agentId, scannerId);
|
||||
return response;
|
||||
// Update subsystem interval
|
||||
const updateIntervalMutation = useMutation({
|
||||
mutationFn: async ({ subsystem, intervalMinutes }: { subsystem: string; intervalMinutes: number }) => {
|
||||
return await agentApi.setSubsystemInterval(agentId, subsystem, intervalMinutes);
|
||||
},
|
||||
onSuccess: (data: ScannerResponse, scannerId) => {
|
||||
toast.success('Scanner execution initiated');
|
||||
// Update local state
|
||||
setScanners(prev => prev.map(scanner =>
|
||||
scanner.id === scannerId
|
||||
? { ...scanner, status: 'running', last_run: new Date().toISOString() }
|
||||
: scanner
|
||||
));
|
||||
onSuccess: (data, variables) => {
|
||||
toast.success(`Interval updated to ${variables.intervalMinutes} minutes`);
|
||||
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to run scanner: ${error.message || 'Unknown error'}`);
|
||||
toast.error(`Failed to update interval: ${error.response?.data?.error || error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleScanner = (scannerId: string, enabled: boolean, frequency: number) => {
|
||||
toggleScannerMutation.mutate({ scannerId, enabled, frequency });
|
||||
// Toggle auto-run
|
||||
const toggleAutoRunMutation = useMutation({
|
||||
mutationFn: async ({ subsystem, autoRun }: { subsystem: string; autoRun: boolean }) => {
|
||||
return await agentApi.setSubsystemAutoRun(agentId, subsystem, autoRun);
|
||||
},
|
||||
onSuccess: (data, 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: (data, 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 handleRunScanner = (scannerId: string) => {
|
||||
runScannerMutation.mutate(scannerId);
|
||||
const handleIntervalChange = (subsystem: string, intervalMinutes: number) => {
|
||||
updateIntervalMutation.mutate({ subsystem, intervalMinutes });
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (scannerId: string, frequency: number) => {
|
||||
const scanner = scanners.find(s => s.id === scannerId);
|
||||
if (scanner) {
|
||||
handleToggleScanner(scannerId, scanner.enabled, frequency);
|
||||
}
|
||||
const handleToggleAutoRun = (subsystem: string, currentAutoRun: boolean) => {
|
||||
toggleAutoRunMutation.mutate({ subsystem, autoRun: !currentAutoRun });
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <RefreshCw className="h-3 w-3 animate-spin text-blue-500" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-3 w-3 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-3 w-3 text-red-500" />;
|
||||
default:
|
||||
return <Clock className="h-3 w-3 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getFrequencyLabel = (frequency: number) => {
|
||||
if (frequency < 60) return `${frequency}m`;
|
||||
if (frequency < 1440) return `${frequency / 60}h`;
|
||||
return `${frequency / 1440}d`;
|
||||
const handleTriggerScan = (subsystem: string) => {
|
||||
triggerScanMutation.mutate(subsystem);
|
||||
};
|
||||
|
||||
const frequencyOptions = [
|
||||
@@ -181,9 +155,14 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
{ value: 1440, label: '24 hours' },
|
||||
];
|
||||
|
||||
const enabledCount = scanners.filter(s => s.enabled).length;
|
||||
const runningCount = scanners.filter(s => s.status === 'running').length;
|
||||
const failedCount = scanners.filter(s => s.status === 'failed').length;
|
||||
const getFrequencyLabel = (frequency: number) => {
|
||||
if (frequency < 60) return `${frequency}m`;
|
||||
if (frequency < 1440) return `${frequency / 60}h`;
|
||||
return `${frequency / 1440}d`;
|
||||
};
|
||||
|
||||
const enabledCount = subsystems.filter(s => s.enabled).length;
|
||||
const autoRunCount = subsystems.filter(s => s.auto_run && s.enabled).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -192,114 +171,176 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div>
|
||||
<span className="text-gray-600">Active:</span>
|
||||
<span className="ml-2 font-medium text-green-600">{enabledCount}/{scanners.length}</span>
|
||||
<span className="text-gray-600">Enabled:</span>
|
||||
<span className="ml-2 font-medium text-green-600">{enabledCount}/{subsystems.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Running:</span>
|
||||
<span className="ml-2 font-medium text-blue-600">{runningCount}</span>
|
||||
<span className="text-gray-600">Auto-Run:</span>
|
||||
<span className="ml-2 font-medium text-blue-600">{autoRunCount}</span>
|
||||
</div>
|
||||
{failedCount > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-600">Failed:</span>
|
||||
<span className="ml-2 font-medium text-red-600">{failedCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', isLoading && 'animate-spin')} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Health Monitoring Table */}
|
||||
{/* Subsystem Configuration Table */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">Agent Check-in Configuration</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900">Subsystem Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 pr-4 font-medium text-gray-700">Check Type</th>
|
||||
<th className="text-left py-2 pr-4 font-medium text-gray-700">Category</th>
|
||||
<th className="text-center py-2 pr-4 font-medium text-gray-700">Status</th>
|
||||
<th className="text-center py-2 pr-4 font-medium text-gray-700">Enabled</th>
|
||||
<th className="text-right py-2 pr-4 font-medium text-gray-700">Check Interval</th>
|
||||
<th className="text-right py-2 pr-4 font-medium text-gray-700">Last Check</th>
|
||||
<th className="text-center py-2 font-medium text-gray-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{scanners.map((scanner) => (
|
||||
<tr key={scanner.id} className="hover:bg-gray-50">
|
||||
{/* Scanner Name */}
|
||||
<td className="py-2 pr-4 text-gray-900">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-600">{scanner.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium">{scanner.name}</div>
|
||||
<div className="text-xs text-gray-500">{scanner.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Category */}
|
||||
<td className="py-2 pr-4 text-gray-600 capitalize text-xs">{scanner.category}</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="py-2 pr-4 text-center">
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
{getStatusIcon(scanner.status)}
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
scanner.status === 'running' ? 'text-blue-600' :
|
||||
scanner.status === 'completed' ? 'text-green-600' :
|
||||
scanner.status === 'failed' ? 'text-red-600' : 'text-gray-500'
|
||||
)}>
|
||||
{scanner.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Enabled Toggle */}
|
||||
<td className="py-2 pr-4 text-center">
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-1 rounded',
|
||||
scanner.enabled
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-600 bg-gray-50'
|
||||
)}>
|
||||
{scanner.enabled ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Frequency */}
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{scanner.enabled ? (
|
||||
<span className="text-xs text-gray-600">{getFrequencyLabel(scanner.frequency)}</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Last Run */}
|
||||
<td className="py-2 pr-4 text-right text-xs text-gray-600">
|
||||
{scanner.last_run ? formatRelativeTime(scanner.last_run) : '-'}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-2 text-center">
|
||||
<span className="text-xs text-gray-400">Auto</span>
|
||||
</td>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-600">Loading subsystems...</span>
|
||||
</div>
|
||||
) : subsystems.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No subsystems found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Subsystems will be created automatically when the agent checks in.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 pr-4 font-medium text-gray-700">Subsystem</th>
|
||||
<th className="text-left py-2 pr-4 font-medium text-gray-700">Category</th>
|
||||
<th className="text-center py-2 pr-4 font-medium text-gray-700">Enabled</th>
|
||||
<th className="text-center py-2 pr-4 font-medium text-gray-700">Auto-Run</th>
|
||||
<th className="text-center py-2 pr-4 font-medium text-gray-700">Interval</th>
|
||||
<th className="text-right py-2 pr-4 font-medium text-gray-700">Last Run</th>
|
||||
<th className="text-right py-2 pr-4 font-medium text-gray-700">Next Run</th>
|
||||
<th className="text-center py-2 font-medium text-gray-700">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{subsystems.map((subsystem: AgentSubsystem) => {
|
||||
const config = subsystemConfig[subsystem.subsystem] || {
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
name: subsystem.subsystem,
|
||||
description: 'Custom subsystem',
|
||||
category: 'system',
|
||||
};
|
||||
|
||||
return (
|
||||
<tr key={subsystem.id} className="hover:bg-gray-50">
|
||||
{/* Subsystem Name */}
|
||||
<td className="py-3 pr-4 text-gray-900">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-600">{config.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium">{config.name}</div>
|
||||
<div className="text-xs text-gray-500">{config.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Category */}
|
||||
<td className="py-3 pr-4 text-gray-600 capitalize text-xs">{config.category}</td>
|
||||
|
||||
{/* Enabled Toggle */}
|
||||
<td className="py-3 pr-4 text-center">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(subsystem.subsystem, subsystem.enabled)}
|
||||
disabled={toggleSubsystemMutation.isPending}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded text-xs font-medium transition-colors',
|
||||
subsystem.enabled
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{subsystem.enabled ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Auto-Run Toggle */}
|
||||
<td className="py-3 pr-4 text-center">
|
||||
<button
|
||||
onClick={() => handleToggleAutoRun(subsystem.subsystem, subsystem.auto_run)}
|
||||
disabled={!subsystem.enabled || toggleAutoRunMutation.isPending}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded text-xs font-medium transition-colors',
|
||||
!subsystem.enabled ? 'bg-gray-50 text-gray-400 cursor-not-allowed' :
|
||||
subsystem.auto_run
|
||||
? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{subsystem.auto_run ? 'AUTO' : 'MANUAL'}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Interval Selector */}
|
||||
<td className="py-3 pr-4 text-center">
|
||||
{subsystem.enabled ? (
|
||||
<select
|
||||
value={subsystem.interval_minutes}
|
||||
onChange={(e) => handleIntervalChange(subsystem.subsystem, parseInt(e.target.value))}
|
||||
disabled={updateIntervalMutation.isPending}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{frequencyOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Last Run */}
|
||||
<td className="py-3 pr-4 text-right text-xs text-gray-600">
|
||||
{subsystem.last_run_at ? formatRelativeTime(subsystem.last_run_at) : '-'}
|
||||
</td>
|
||||
|
||||
{/* Next Run */}
|
||||
<td className="py-3 pr-4 text-right text-xs text-gray-600">
|
||||
{subsystem.next_run_at && subsystem.auto_run ? formatRelativeTime(subsystem.next_run_at) : '-'}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleTriggerScan(subsystem.subsystem)}
|
||||
disabled={!subsystem.enabled || triggerScanMutation.isPending}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded text-xs font-medium transition-colors inline-flex items-center space-x-1',
|
||||
!subsystem.enabled
|
||||
? 'bg-gray-50 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
)}
|
||||
title="Trigger manual scan"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
<span>Scan</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compact note */}
|
||||
<div className="text-xs text-gray-500">
|
||||
Agent check-ins report system state to the server on scheduled intervals. The agent initiates all communication - the server never "scans" your machine.
|
||||
Subsystems report specific metrics to the server on scheduled intervals. Enable auto-run to schedule automatic scans, or trigger manual scans as needed.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
Activity,
|
||||
Copy,
|
||||
Hash,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Container,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { logApi } from '@/lib/api';
|
||||
@@ -243,6 +246,12 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
||||
switch (action) {
|
||||
case 'scan_updates':
|
||||
return <Search className="h-4 w-4" />;
|
||||
case 'scan_storage':
|
||||
return <HardDrive className="h-4 w-4" />;
|
||||
case 'scan_system':
|
||||
return <Cpu className="h-4 w-4" />;
|
||||
case 'scan_docker':
|
||||
return <Container className="h-4 w-4" />;
|
||||
case 'dry_run_update':
|
||||
return <Terminal className="h-4 w-4" />;
|
||||
case 'confirm_dependencies':
|
||||
@@ -444,17 +453,47 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
||||
let sentence = '';
|
||||
const isInProgress = result === 'running' || result === 'pending' || result === 'sent';
|
||||
|
||||
|
||||
|
||||
if (entry.type === 'command') {
|
||||
if (action === 'scan updates') {
|
||||
if (isInProgress) {
|
||||
sentence = `Scan initiated for '${subject}'`;
|
||||
sentence = `Package Update Scanner initiated`;
|
||||
} else if (statusType === 'success') {
|
||||
sentence = `Scan completed for '${subject}'`;
|
||||
sentence = `Package Update Scanner completed`;
|
||||
} else if (statusType === 'failed') {
|
||||
sentence = `Scan failed for '${subject}'`;
|
||||
sentence = `Package Update Scanner failed`;
|
||||
} else {
|
||||
sentence = `Scan results for '${subject}'`;
|
||||
sentence = `Package Update Scanner results`;
|
||||
}
|
||||
} else if (action === 'scan storage') {
|
||||
if (isInProgress) {
|
||||
sentence = `Disk Usage Reporter initiated`;
|
||||
} else if (statusType === 'success') {
|
||||
sentence = `Disk Usage Reporter completed`;
|
||||
} else if (statusType === 'failed') {
|
||||
sentence = `Disk Usage Reporter failed`;
|
||||
} else {
|
||||
sentence = `Disk Usage Reporter results`;
|
||||
}
|
||||
} else if (action === 'scan system') {
|
||||
if (isInProgress) {
|
||||
sentence = `System Metrics Scanner initiated`;
|
||||
} else if (statusType === 'success') {
|
||||
sentence = `System Metrics Scanner completed`;
|
||||
} else if (statusType === 'failed') {
|
||||
sentence = `System Metrics Scanner failed`;
|
||||
} else {
|
||||
sentence = `System Metrics Scanner results`;
|
||||
}
|
||||
} else if (action === 'scan docker') {
|
||||
if (isInProgress) {
|
||||
sentence = `Docker Image Scanner initiated`;
|
||||
} else if (statusType === 'success') {
|
||||
sentence = `Docker Image Scanner completed`;
|
||||
} else if (statusType === 'failed') {
|
||||
sentence = `Docker Image Scanner failed`;
|
||||
} else {
|
||||
sentence = `Docker Image Scanner results`;
|
||||
}
|
||||
} else if (action === 'dry run update') {
|
||||
if (isInProgress) {
|
||||
@@ -763,8 +802,20 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
||||
{entry.stdout && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
||||
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-3 flex items-center">
|
||||
<Package className="h-3 w-3 mr-1.5" />
|
||||
{entry.action === 'scan_updates' ? 'Analysis Results' : 'Operation Details'}
|
||||
{entry.action === 'scan_storage' ? (
|
||||
<HardDrive className="h-3 w-3 mr-1.5" />
|
||||
) : entry.action === 'scan_system' ? (
|
||||
<Cpu className="h-3 w-3 mr-1.5" />
|
||||
) : entry.action === 'scan_docker' ? (
|
||||
<Container className="h-3 w-3 mr-1.5" />
|
||||
) : (
|
||||
<Package className="h-3 w-3 mr-1.5" />
|
||||
)}
|
||||
{entry.action === 'scan_updates' ? 'Package Analysis Results' :
|
||||
entry.action === 'scan_storage' ? 'Disk Usage Report' :
|
||||
entry.action === 'scan_system' ? 'System Metrics Report' :
|
||||
entry.action === 'scan_docker' ? 'Docker Image Analysis' :
|
||||
'Operation Details'}
|
||||
</h4>
|
||||
<div className="space-y-2 text-xs">
|
||||
{(() => {
|
||||
@@ -837,6 +888,71 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (entry.action === 'scan_storage') {
|
||||
// Parse storage/disk usage information
|
||||
// Look for disk metrics in the stdout
|
||||
const diskLines = stdout.split('\n');
|
||||
diskLines.forEach(line => {
|
||||
// Match patterns like "Mount: /dev/sda1" or "Usage: 85%"
|
||||
const mountMatch = line.match(/(?:Mount|Filesystem|Path):\s*([^\s]+)/i);
|
||||
const usageMatch = line.match(/(?:Usage|Used):\s*(\d+\.?\d*%?)/i);
|
||||
const sizeMatch = line.match(/(?:Size|Total):\s*([^\s]+)/i);
|
||||
const availMatch = line.match(/(?:Available|Free):\s*([^\s]+)/i);
|
||||
|
||||
if (mountMatch || usageMatch || sizeMatch || availMatch) {
|
||||
if (mountMatch) details.push({ label: "Mount Point", value: mountMatch[1] });
|
||||
if (usageMatch) details.push({ label: "Usage", value: usageMatch[1] });
|
||||
if (sizeMatch) details.push({ label: "Total Size", value: sizeMatch[1] });
|
||||
if (availMatch) details.push({ label: "Available", value: availMatch[1] });
|
||||
}
|
||||
});
|
||||
} else if (entry.action === 'scan_system') {
|
||||
// Parse system metrics (CPU, memory, processes, uptime)
|
||||
const cpuMatch = stdout.match(/(?:CPU|Processor):\s*([^\n]+)/i);
|
||||
if (cpuMatch) {
|
||||
details.push({ label: "CPU", value: cpuMatch[1].trim() });
|
||||
}
|
||||
|
||||
const memoryMatch = stdout.match(/(?:Memory|RAM):\s*([^\n]+)/i);
|
||||
if (memoryMatch) {
|
||||
details.push({ label: "Memory", value: memoryMatch[1].trim() });
|
||||
}
|
||||
|
||||
const processMatch = stdout.match(/(?:Processes|Process Count):\s*(\d+)/i);
|
||||
if (processMatch) {
|
||||
details.push({ label: "Running Processes", value: processMatch[1] });
|
||||
}
|
||||
|
||||
const uptimeMatch = stdout.match(/(?:Uptime|Up Time):\s*([^\n]+)/i);
|
||||
if (uptimeMatch) {
|
||||
details.push({ label: "System Uptime", value: uptimeMatch[1].trim() });
|
||||
}
|
||||
|
||||
const loadMatch = stdout.match(/(?:Load Average|Load):\s*([^\n]+)/i);
|
||||
if (loadMatch) {
|
||||
details.push({ label: "Load Average", value: loadMatch[1].trim() });
|
||||
}
|
||||
} else if (entry.action === 'scan_docker') {
|
||||
// Parse Docker image/container information
|
||||
const containerCountMatch = stdout.match(/(?:Containers|Container Count):\s*(\d+)/i);
|
||||
if (containerCountMatch) {
|
||||
details.push({ label: "Containers", value: containerCountMatch[1] });
|
||||
}
|
||||
|
||||
const imageCountMatch = stdout.match(/(?:Images|Image Count):\s*(\d+)/i);
|
||||
if (imageCountMatch) {
|
||||
details.push({ label: "Images", value: imageCountMatch[1] });
|
||||
}
|
||||
|
||||
const updateCountMatch = stdout.match(/(?:Updates Available|Updatable Images):\s*(\d+)/i);
|
||||
if (updateCountMatch) {
|
||||
details.push({ label: "Updates Available", value: updateCountMatch[1] });
|
||||
}
|
||||
|
||||
const runningMatch = stdout.match(/(?:Running Containers):\s*(\d+)/i);
|
||||
if (runningMatch) {
|
||||
details.push({ label: "Running", value: runningMatch[1] });
|
||||
}
|
||||
}
|
||||
|
||||
// Extract "Packages installed" info
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
RateLimitConfig,
|
||||
RateLimitStats,
|
||||
RateLimitUsage,
|
||||
RateLimitSummary
|
||||
RateLimitSummary,
|
||||
AgentSubsystem,
|
||||
SubsystemConfig,
|
||||
SubsystemStats
|
||||
} from '@/types';
|
||||
|
||||
// Base URL for API - use nginx proxy
|
||||
@@ -111,6 +114,52 @@ export const agentApi = {
|
||||
unregisterAgent: async (id: string): Promise<void> => {
|
||||
await api.delete(`/agents/${id}`);
|
||||
},
|
||||
|
||||
// Subsystem Management
|
||||
getSubsystems: async (agentId: string): Promise<AgentSubsystem[]> => {
|
||||
const response = await api.get(`/agents/${agentId}/subsystems`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSubsystem: async (agentId: string, subsystem: string): Promise<AgentSubsystem> => {
|
||||
const response = await api.get(`/agents/${agentId}/subsystems/${subsystem}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateSubsystem: async (agentId: string, subsystem: string, config: SubsystemConfig): Promise<{ message: string }> => {
|
||||
const response = await api.patch(`/agents/${agentId}/subsystems/${subsystem}`, config);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
enableSubsystem: async (agentId: string, subsystem: string): Promise<{ message: string }> => {
|
||||
const response = await api.post(`/agents/${agentId}/subsystems/${subsystem}/enable`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
disableSubsystem: async (agentId: string, subsystem: string): Promise<{ message: string }> => {
|
||||
const response = await api.post(`/agents/${agentId}/subsystems/${subsystem}/disable`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
triggerSubsystem: async (agentId: string, subsystem: string): Promise<{ message: string; command_id: string }> => {
|
||||
const response = await api.post(`/agents/${agentId}/subsystems/${subsystem}/trigger`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSubsystemStats: async (agentId: string, subsystem: string): Promise<SubsystemStats> => {
|
||||
const response = await api.get(`/agents/${agentId}/subsystems/${subsystem}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
setSubsystemAutoRun: async (agentId: string, subsystem: string, autoRun: boolean): Promise<{ message: string }> => {
|
||||
const response = await api.post(`/agents/${agentId}/subsystems/${subsystem}/auto-run`, { auto_run: autoRun });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
setSubsystemInterval: async (agentId: string, subsystem: string, intervalMinutes: number): Promise<{ message: string }> => {
|
||||
const response = await api.post(`/agents/${agentId}/subsystems/${subsystem}/interval`, { interval_minutes: intervalMinutes });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const updateApi = {
|
||||
|
||||
@@ -367,4 +367,36 @@ export interface RateLimitSummary {
|
||||
total_requests_per_minute: number;
|
||||
most_active_endpoint: string;
|
||||
average_utilization: number;
|
||||
}
|
||||
|
||||
// Subsystem types
|
||||
export interface AgentSubsystem {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
subsystem: 'updates' | 'storage' | 'system' | 'docker';
|
||||
enabled: boolean;
|
||||
interval_minutes: number;
|
||||
auto_run: boolean;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SubsystemConfig {
|
||||
enabled?: boolean;
|
||||
interval_minutes?: number;
|
||||
auto_run?: boolean;
|
||||
}
|
||||
|
||||
export interface SubsystemStats {
|
||||
subsystem: string;
|
||||
enabled: boolean;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
interval_minutes: number;
|
||||
auto_run: boolean;
|
||||
run_count: number;
|
||||
last_status: string;
|
||||
last_duration: number;
|
||||
}
|
||||
Reference in New Issue
Block a user