- Bump agent and server versions to 0.1.23 - Implement security metrics collection (bound agents, command processing, version compliance) - Add dismiss button for timed out commands in agent status - Add config sync endpoint for server->agent configuration updates - Add ignored updates workflow in AgentUpdatesEnhanced (approve/reject workflow) - Swap AgentScanners layout (subsystems top, security bottom) - Replace placeholder security data with database metrics - Add backpressure detection based on pending command ratios
588 lines
21 KiB
TypeScript
588 lines
21 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
Search,
|
|
Upload,
|
|
RefreshCw,
|
|
Terminal,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Check,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { formatRelativeTime, formatBytes } from '@/lib/utils';
|
|
import { updateApi, agentApi } from '@/lib/api';
|
|
import toast from 'react-hot-toast';
|
|
import { cn } from '@/lib/utils';
|
|
import type { UpdatePackage } from '@/types';
|
|
import { AgentUpdatesModal } from './AgentUpdatesModal';
|
|
|
|
interface AgentUpdatesEnhancedProps {
|
|
agentId: string;
|
|
}
|
|
|
|
interface AgentUpdateResponse {
|
|
updates: UpdatePackage[];
|
|
total: number;
|
|
}
|
|
|
|
interface CommandResponse {
|
|
command_id: string;
|
|
status: string;
|
|
message: string;
|
|
}
|
|
|
|
interface LogResponse {
|
|
stdout: string;
|
|
stderr: string;
|
|
exit_code: number;
|
|
duration_seconds: number;
|
|
result: string;
|
|
}
|
|
|
|
type StatusTab = 'pending' | 'approved' | 'installing' | 'installed' | 'ignored';
|
|
|
|
export function AgentUpdatesEnhanced({ agentId }: AgentUpdatesEnhancedProps) {
|
|
const [activeStatus, setActiveStatus] = useState<StatusTab>('pending');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(50);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedSeverity, setSelectedSeverity] = useState('all');
|
|
const [showLogsModal, setShowLogsModal] = useState(false);
|
|
const [logsData, setLogsData] = useState<LogResponse | null>(null);
|
|
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
|
const [expandedUpdates, setExpandedUpdates] = useState<Set<string>>(new Set());
|
|
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
// Fetch updates with status filter
|
|
const { data: updateData, isLoading, error, refetch } = useQuery<AgentUpdateResponse>({
|
|
queryKey: ['agent-updates', agentId, activeStatus, currentPage, pageSize, searchTerm, selectedSeverity],
|
|
queryFn: async () => {
|
|
const params = {
|
|
page: currentPage,
|
|
page_size: pageSize,
|
|
agent_id: agentId,
|
|
status: activeStatus,
|
|
...(searchTerm && { search: searchTerm }),
|
|
...(selectedSeverity !== 'all' && { severity: selectedSeverity }),
|
|
};
|
|
|
|
const response = await updateApi.getUpdates(params);
|
|
return response;
|
|
},
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
// Mutations
|
|
const approveMutation = useMutation({
|
|
mutationFn: async (updateId: string) => {
|
|
const response = await updateApi.approveUpdate(updateId);
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Update approved');
|
|
refetch();
|
|
queryClient.invalidateQueries({ queryKey: ['agent-updates'] });
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(`Failed to approve: ${error.message || 'Unknown error'}`);
|
|
},
|
|
});
|
|
|
|
const installMutation = useMutation({
|
|
mutationFn: async (updateId: string) => {
|
|
const response = await agentApi.installUpdate(agentId, updateId);
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Installation started');
|
|
setTimeout(() => {
|
|
refetch();
|
|
queryClient.invalidateQueries({ queryKey: ['active-commands'] });
|
|
}, 2000);
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(`Failed to install: ${error.message || 'Unknown error'}`);
|
|
},
|
|
});
|
|
|
|
const bulkApproveMutation = useMutation({
|
|
mutationFn: async (updateIds: string[]) => {
|
|
const response = await updateApi.approveMultiple(updateIds);
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success(`${selectedUpdates.length} updates approved`);
|
|
setSelectedUpdates([]);
|
|
refetch();
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(`Failed to approve: ${error.message || 'Unknown error'}`);
|
|
},
|
|
});
|
|
|
|
const rejectMutation = useMutation({
|
|
mutationFn: async (updateId: string) => {
|
|
const response = await updateApi.rejectUpdate(updateId);
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Update rejected');
|
|
refetch();
|
|
queryClient.invalidateQueries({ queryKey: ['agent-updates'] });
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(`Failed to reject: ${error.message || 'Unknown error'}`);
|
|
},
|
|
});
|
|
|
|
const getLogsMutation = useMutation({
|
|
mutationFn: async (commandId: string) => {
|
|
setIsLoadingLogs(true);
|
|
const response = await agentApi.getCommandLogs(agentId, commandId);
|
|
return response;
|
|
},
|
|
onSuccess: (data: LogResponse) => {
|
|
setLogsData(data);
|
|
setShowLogsModal(true);
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(`Failed to fetch logs: ${error.message || 'Unknown error'}`);
|
|
},
|
|
onSettled: () => {
|
|
setIsLoadingLogs(false);
|
|
},
|
|
});
|
|
|
|
const updates = updateData?.updates || [];
|
|
const totalCount = updateData?.total || 0;
|
|
const totalPages = Math.ceil(totalCount / pageSize);
|
|
|
|
const getSeverityColor = (severity: string) => {
|
|
switch (severity.toLowerCase()) {
|
|
case 'critical': return 'text-red-600 bg-red-50';
|
|
case 'important':
|
|
case 'high': return 'text-orange-600 bg-orange-50';
|
|
case 'moderate':
|
|
case 'medium': return 'text-yellow-600 bg-yellow-50';
|
|
case 'low':
|
|
case 'none': return 'text-blue-600 bg-blue-50';
|
|
default: return 'text-gray-600 bg-gray-50';
|
|
}
|
|
};
|
|
|
|
const handleSelectUpdate = (updateId: string, checked: boolean) => {
|
|
if (checked) {
|
|
setSelectedUpdates([...selectedUpdates, updateId]);
|
|
} else {
|
|
setSelectedUpdates(selectedUpdates.filter(id => id !== updateId));
|
|
}
|
|
};
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
|
if (checked) {
|
|
setSelectedUpdates(updates.map((update: UpdatePackage) => update.id));
|
|
} else {
|
|
setSelectedUpdates([]);
|
|
}
|
|
};
|
|
|
|
const handleApprove = async (updateId: string) => {
|
|
approveMutation.mutate(updateId);
|
|
};
|
|
|
|
const handleInstall = async (updateId: string) => {
|
|
installMutation.mutate(updateId);
|
|
};
|
|
|
|
const handleReject = async (updateId: string) => {
|
|
rejectMutation.mutate(updateId);
|
|
};
|
|
|
|
const handleBulkApprove = async () => {
|
|
if (selectedUpdates.length === 0) {
|
|
toast.error('Select at least one update');
|
|
return;
|
|
}
|
|
bulkApproveMutation.mutate(selectedUpdates);
|
|
};
|
|
|
|
const handleViewLogs = async (update: UpdatePackage) => {
|
|
const recentCommand = update.recent_command_id;
|
|
if (recentCommand) {
|
|
getLogsMutation.mutate(recentCommand);
|
|
} else {
|
|
toast.error('No recent command logs available for this package');
|
|
}
|
|
};
|
|
|
|
const toggleExpanded = (updateId: string) => {
|
|
const newExpanded = new Set(expandedUpdates);
|
|
if (newExpanded.has(updateId)) {
|
|
newExpanded.delete(updateId);
|
|
} else {
|
|
newExpanded.add(updateId);
|
|
}
|
|
setExpandedUpdates(newExpanded);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="animate-pulse space-y-2">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="p-3 bg-white rounded border border-gray-100">
|
|
<div className="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
|
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-4 bg-red-50 border border-red-200 rounded text-sm text-red-600">
|
|
Error loading updates: {(error as Error).message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Tabs */}
|
|
<div className="flex items-center space-x-1 border-b border-gray-200 text-sm">
|
|
{[
|
|
{ key: 'pending', label: 'Pending' },
|
|
{ key: 'approved', label: 'Approved' },
|
|
{ key: 'installing', label: 'Installing' },
|
|
{ key: 'installed', label: 'Installed' },
|
|
{ key: 'ignored', label: 'Ignored' },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveStatus(tab.key as StatusTab)}
|
|
className={cn(
|
|
'px-4 py-2 border-b-2 transition-colors',
|
|
activeStatus === tab.key
|
|
? 'border-gray-900 text-gray-900'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filters and Actions */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-3 text-sm">
|
|
<span className="text-gray-600">
|
|
{totalCount} update{totalCount !== 1 ? 's' : ''}
|
|
</span>
|
|
{['critical', 'high', 'medium', 'low'].map((severity) => {
|
|
const count = updates.filter(u => u.severity?.toLowerCase() === severity).length;
|
|
if (count === 0) return null;
|
|
return (
|
|
<span key={severity} className="text-gray-500">
|
|
<span className={cn(
|
|
'font-medium',
|
|
severity === 'critical' ? 'text-red-600' :
|
|
severity === 'high' ? 'text-orange-600' :
|
|
severity === 'medium' ? 'text-yellow-600' : 'text-blue-600'
|
|
)}>{count}</span> {severity}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{selectedUpdates.length > 0 && activeStatus === 'pending' && (
|
|
<button
|
|
onClick={handleBulkApprove}
|
|
disabled={bulkApproveMutation.isPending}
|
|
className="text-sm text-gray-600 hover:text-gray-900 flex items-center space-x-1"
|
|
>
|
|
{bulkApproveMutation.isPending ? (
|
|
<>
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
<span>Approving...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Check className="h-4 w-4" />
|
|
<span>Approve {selectedUpdates.length}</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* Update Agent Button */}
|
|
<button
|
|
onClick={() => setShowUpdateModal(true)}
|
|
className="text-sm text-primary-600 hover:text-primary-800 flex items-center space-x-1 border border-primary-300 px-2 py-1 rounded"
|
|
>
|
|
<Upload className="h-4 w-4" />
|
|
<span>Update Agent</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search and Filters */}
|
|
<div className="flex items-center space-x-3 text-sm">
|
|
<div className="flex-1 max-w-xs">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="Search packages..."
|
|
className="pl-9 pr-3 py-1.5 w-full border border-gray-300 rounded text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<select
|
|
value={selectedSeverity}
|
|
onChange={(e) => setSelectedSeverity(e.target.value)}
|
|
className="px-3 py-1.5 border border-gray-300 rounded text-sm"
|
|
>
|
|
<option value="all">All Severities</option>
|
|
<option value="critical">Critical</option>
|
|
<option value="high">High</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="low">Low</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Updates List */}
|
|
{updates.length === 0 ? (
|
|
<div className="text-center py-12 text-sm text-gray-500">
|
|
{activeStatus === 'installed' ? (
|
|
<div>
|
|
<p className="mb-2">Installed updates are shown in History</p>
|
|
<button
|
|
onClick={() => window.location.href = `/agents/${agentId}?tab=history`}
|
|
className="text-gray-600 hover:text-gray-900 underline"
|
|
>
|
|
View History
|
|
</button>
|
|
</div>
|
|
) : (
|
|
`No ${activeStatus} updates`
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-px">
|
|
{updates.map((update) => {
|
|
const isExpanded = expandedUpdates.has(update.id);
|
|
return (
|
|
<div key={update.id} className="bg-white border-b border-gray-100 last:border-0">
|
|
<div className="flex items-center p-2 gap-3">
|
|
{/* Checkbox for pending */}
|
|
{activeStatus === 'pending' && (
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedUpdates.includes(update.id)}
|
|
onChange={(e) => handleSelectUpdate(update.id, e.target.checked)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
/>
|
|
)}
|
|
|
|
{/* Main content */}
|
|
<div
|
|
className="flex-1 flex items-center justify-between gap-3 cursor-pointer"
|
|
onClick={() => toggleExpanded(update.id)}
|
|
>
|
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
|
<span className={cn('px-2 py-0.5 rounded text-xs font-medium', getSeverityColor(update.severity))}>
|
|
{update.severity.toUpperCase()}
|
|
</span>
|
|
<span className="text-sm text-gray-900 truncate">{update.package_name}</span>
|
|
<span className="text-xs text-gray-500">{update.current_version} → {update.available_version}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2 flex-shrink-0">
|
|
{activeStatus === 'pending' && (
|
|
<>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleApprove(update.id); }}
|
|
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleReject(update.id); }}
|
|
className="text-xs text-red-600 hover:text-red-800 px-2 py-1"
|
|
>
|
|
Reject
|
|
</button>
|
|
</>
|
|
)}
|
|
{activeStatus === 'approved' && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleInstall(update.id); }}
|
|
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
|
|
>
|
|
Install
|
|
</button>
|
|
)}
|
|
{activeStatus === 'ignored' && (
|
|
<span className="text-xs text-gray-500 px-2 py-1">
|
|
Rejected
|
|
</span>
|
|
)}
|
|
{update.recent_command_id && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleViewLogs(update); }}
|
|
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
|
|
>
|
|
Logs
|
|
</button>
|
|
)}
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 text-gray-400" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Details */}
|
|
{isExpanded && (
|
|
<div className="px-2 pb-3 ml-8">
|
|
<div className="bg-white/90 backdrop-blur-md rounded border border-gray-200 p-3 text-xs space-y-2">
|
|
{update.metadata?.description && (
|
|
<p className="text-gray-700">{update.metadata.description}</p>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-2 text-gray-600">
|
|
<div><span className="font-medium">Type:</span> {update.package_type}</div>
|
|
<div><span className="font-medium">Severity:</span> {update.severity}</div>
|
|
{update.metadata?.size_bytes && (
|
|
<div><span className="font-medium">Size:</span> {formatBytes(update.metadata.size_bytes)}</div>
|
|
)}
|
|
{update.last_discovered_at && (
|
|
<div><span className="font-medium">Discovered:</span> {formatRelativeTime(update.last_discovered_at)}</div>
|
|
)}
|
|
{update.approved_at && (
|
|
<div><span className="font-medium">Approved:</span> {formatRelativeTime(update.approved_at)}</div>
|
|
)}
|
|
{update.installed_at && (
|
|
<div><span className="font-medium">Installed:</span> {formatRelativeTime(update.installed_at)}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between text-sm text-gray-600">
|
|
<span>
|
|
{Math.min((currentPage - 1) * pageSize + 1, totalCount)} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount}
|
|
</span>
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
disabled={currentPage === 1}
|
|
className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span>Page {currentPage} of {totalPages}</span>
|
|
<button
|
|
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Logs Modal */}
|
|
{showLogsModal && logsData && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden">
|
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-gray-900 flex items-center space-x-2">
|
|
<Terminal className="h-4 w-4" />
|
|
<span>Installation Logs</span>
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowLogsModal(false)}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 overflow-y-auto max-h-[60vh] space-y-3 text-xs">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<span className="font-medium text-gray-700">Result:</span>
|
|
<span className={cn(
|
|
'ml-2 px-2 py-0.5 rounded',
|
|
logsData.result === 'success' ? 'bg-green-100 text-green-800' :
|
|
logsData.result === 'failed' ? 'bg-red-100 text-red-800' :
|
|
'bg-gray-100 text-gray-800'
|
|
)}>
|
|
{logsData.result || 'Unknown'}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-700">Exit Code:</span>
|
|
<span className="ml-2">{logsData.exit_code}</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-700">Duration:</span>
|
|
<span className="ml-2">{logsData.duration_seconds}s</span>
|
|
</div>
|
|
</div>
|
|
|
|
{logsData.stdout && (
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 mb-1">Standard Output</h4>
|
|
<pre className="bg-gray-50 border border-gray-200 rounded p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
|
{logsData.stdout}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{logsData.stderr && (
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 mb-1">Standard Error</h4>
|
|
<pre className="bg-red-50 border border-red-200 rounded p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
|
{logsData.stderr}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Agent Update Modal */}
|
|
<AgentUpdatesModal
|
|
isOpen={showUpdateModal}
|
|
onClose={() => setShowUpdateModal(false)}
|
|
selectedAgentIds={[agentId]}
|
|
onAgentsUpdated={() => {
|
|
setShowUpdateModal(false);
|
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|