feat: machine binding and version enforcement
migration 017 adds machine_id to agents table middleware validates X-Machine-ID header on authed routes agent client sends machine ID with requests MIN_AGENT_VERSION config defaults 0.1.22 version utils added for comparison blocks config copying attacks via hardware fingerprint old agents get 426 upgrade required breaking: <0.1.22 agents rejected
This commit is contained in:
@@ -1,18 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
MonitorPlay,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Activity,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Play,
|
||||
Square,
|
||||
Database,
|
||||
Shield,
|
||||
Search,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Container,
|
||||
@@ -78,7 +69,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
return await agentApi.disableSubsystem(agentId, subsystem);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success(`${subsystemConfig[variables.subsystem]?.name || variables.subsystem} ${variables.enabled ? 'enabled' : 'disabled'}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
|
||||
},
|
||||
@@ -92,7 +83,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
mutationFn: async ({ subsystem, intervalMinutes }: { subsystem: string; intervalMinutes: number }) => {
|
||||
return await agentApi.setSubsystemInterval(agentId, subsystem, intervalMinutes);
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success(`Interval updated to ${variables.intervalMinutes} minutes`);
|
||||
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
|
||||
},
|
||||
@@ -106,7 +97,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
mutationFn: async ({ subsystem, autoRun }: { subsystem: string; autoRun: boolean }) => {
|
||||
return await agentApi.setSubsystemAutoRun(agentId, subsystem, autoRun);
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success(`Auto-run ${variables.autoRun ? 'enabled' : 'disabled'}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
|
||||
},
|
||||
@@ -120,7 +111,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
mutationFn: async (subsystem: string) => {
|
||||
return await agentApi.triggerSubsystem(agentId, subsystem);
|
||||
},
|
||||
onSuccess: (data, subsystem) => {
|
||||
onSuccess: (_, subsystem) => {
|
||||
toast.success(`${subsystemConfig[subsystem]?.name || subsystem} scan triggered`);
|
||||
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
|
||||
},
|
||||
@@ -155,12 +146,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
|
||||
{ value: 1440, label: '24 hours' },
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
Database,
|
||||
Search,
|
||||
Activity,
|
||||
Monitor,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
TrendingUp,
|
||||
Server,
|
||||
MemoryStick,
|
||||
} from 'lucide-react';
|
||||
import { formatBytes, formatRelativeTime } from '@/lib/utils';
|
||||
@@ -116,15 +107,7 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
|
||||
}));
|
||||
};
|
||||
|
||||
const getDiskTypeIcon = (diskType: string) => {
|
||||
switch (diskType.toLowerCase()) {
|
||||
case 'nvme': return <Database className="h-4 w-4 text-purple-500" />;
|
||||
case 'ssd': return <Server className="h-4 w-4 text-blue-500" />;
|
||||
case 'hdd': return <HardDrive className="h-4 w-4 text-gray-500" />;
|
||||
default: return <Monitor className="h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!agentData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Search,
|
||||
Package,
|
||||
Download,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
@@ -18,6 +19,7 @@ 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;
|
||||
@@ -52,7 +54,7 @@ export function AgentUpdatesEnhanced({ agentId }: AgentUpdatesEnhancedProps) {
|
||||
const [selectedSeverity, setSelectedSeverity] = useState('all');
|
||||
const [showLogsModal, setShowLogsModal] = useState(false);
|
||||
const [logsData, setLogsData] = useState<LogResponse | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
||||
const [expandedUpdates, setExpandedUpdates] = useState<Set<string>>(new Set());
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
|
||||
@@ -300,6 +302,15 @@ export function AgentUpdatesEnhanced({ agentId }: AgentUpdatesEnhancedProps) {
|
||||
)}
|
||||
</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 */}
|
||||
@@ -531,6 +542,17 @@ export function AgentUpdatesEnhanced({ agentId }: AgentUpdatesEnhancedProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Update Modal */}
|
||||
<AgentUpdatesModal
|
||||
isOpen={showUpdateModal}
|
||||
onClose={() => setShowUpdateModal(false)}
|
||||
selectedAgentIds={[agentId]}
|
||||
onAgentsUpdated={() => {
|
||||
setShowUpdateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
290
aggregator-web/src/components/AgentUpdatesModal.tsx
Normal file
290
aggregator-web/src/components/AgentUpdatesModal.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
Download,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Info,
|
||||
Users,
|
||||
Package,
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { agentApi, updateApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Agent } from '@/types';
|
||||
|
||||
interface AgentUpdatesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedAgentIds: string[];
|
||||
onAgentsUpdated: () => void;
|
||||
}
|
||||
|
||||
export function AgentUpdatesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedAgentIds,
|
||||
onAgentsUpdated,
|
||||
}: AgentUpdatesModalProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState('');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('');
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Fetch selected agents details
|
||||
const { data: agents = [] } = useQuery<Agent[]>({
|
||||
queryKey: ['agents-details', selectedAgentIds],
|
||||
queryFn: async (): Promise<Agent[]> => {
|
||||
const promises = selectedAgentIds.map(id => agentApi.getAgent(id));
|
||||
const results = await Promise.all(promises);
|
||||
return results;
|
||||
},
|
||||
enabled: isOpen && selectedAgentIds.length > 0,
|
||||
});
|
||||
|
||||
// Fetch available update packages
|
||||
const { data: packagesResponse, isLoading: packagesLoading } = useQuery({
|
||||
queryKey: ['update-packages'],
|
||||
queryFn: () => updateApi.getPackages(),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const packages = packagesResponse?.packages || [];
|
||||
|
||||
// Group packages by version
|
||||
const versions = [...new Set(packages.map(pkg => pkg.version))].sort((a, b) => b.localeCompare(a));
|
||||
const platforms = [...new Set(packages.map(pkg => pkg.platform))].sort();
|
||||
|
||||
// Filter packages based on selection
|
||||
const availablePackages = packages.filter(
|
||||
pkg => (!selectedVersion || pkg.version === selectedVersion) &&
|
||||
(!selectedPlatform || pkg.platform === selectedPlatform)
|
||||
);
|
||||
|
||||
// Get unique platform for selected agents (simplified - assumes all agents same platform)
|
||||
const agentPlatform = agents[0]?.os_type || 'linux';
|
||||
const agentArchitecture = agents[0]?.os_architecture || 'amd64';
|
||||
|
||||
// Update agents mutation
|
||||
const updateAgentsMutation = useMutation({
|
||||
mutationFn: async (packageId: string) => {
|
||||
const pkg = packages.find(p => p.id === packageId);
|
||||
if (!pkg) throw new Error('Package not found');
|
||||
|
||||
const updateData = {
|
||||
agent_ids: selectedAgentIds,
|
||||
version: pkg.version,
|
||||
platform: pkg.platform,
|
||||
};
|
||||
|
||||
return agentApi.updateMultipleAgents(updateData);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Update initiated for ${data.updated?.length || 0} agent(s)`);
|
||||
setIsUpdating(false);
|
||||
onAgentsUpdated();
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to update agents: ${error.message}`);
|
||||
setIsUpdating(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateAgents = async (packageId: string) => {
|
||||
setIsUpdating(true);
|
||||
updateAgentsMutation.mutate(packageId);
|
||||
};
|
||||
|
||||
const canUpdate = selectedAgentIds.length > 0 && availablePackages.length > 0 && !isUpdating;
|
||||
const hasUpdatingAgents = agents.some(agent => agent.is_updating);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-4xl transform overflow-hidden rounded-lg bg-white p-6 text-left shadow-xl transition-all">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Download className="h-6 w-6 text-primary-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Agent Updates</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Update {selectedAgentIds.length} agent{selectedAgentIds.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-2 text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="py-4">
|
||||
{/* Selected Agents */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Selected Agents
|
||||
</h4>
|
||||
<div className="max-h-32 overflow-y-auto space-y-2">
|
||||
{agents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||
<div className="flex items-center space-x-3">
|
||||
<CheckCircle className={cn(
|
||||
"h-4 w-4",
|
||||
agent.status === 'online' ? "text-green-500" : "text-gray-400"
|
||||
)} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{agent.hostname}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.os_type}/{agent.os_architecture} • Current: {agent.current_version || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{agent.is_updating && (
|
||||
<div className="flex items-center text-amber-600 text-xs">
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
Updating to {agent.updating_to_version}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasUpdatingAgents && (
|
||||
<div className="mt-2 text-xs text-amber-600 flex items-center">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Some agents are currently updating
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Package Selection */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center">
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Update Package Selection
|
||||
</h4>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Version</label>
|
||||
<select
|
||||
value={selectedVersion}
|
||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
|
||||
>
|
||||
<option value="">All Versions</option>
|
||||
{versions.map(version => (
|
||||
<option key={version} value={version}>{version}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Platform</label>
|
||||
<select
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
|
||||
>
|
||||
<option value="">All Platforms</option>
|
||||
{platforms.map(platform => (
|
||||
<option key={platform} value={platform}>{platform}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Packages */}
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{packagesLoading ? (
|
||||
<div className="text-center py-4 text-sm text-gray-500">
|
||||
Loading packages...
|
||||
</div>
|
||||
) : availablePackages.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-gray-500">
|
||||
No packages available for the selected criteria
|
||||
</div>
|
||||
) : (
|
||||
availablePackages.map((pkg) => (
|
||||
<div
|
||||
key={pkg.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-3 border rounded-lg cursor-pointer transition-colors",
|
||||
"hover:bg-gray-50 border-gray-200"
|
||||
)}
|
||||
onClick={() => handleUpdateAgents(pkg.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Package className="h-4 w-4 text-primary-600" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Version {pkg.version}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{pkg.platform} • {(pkg.file_size / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
<Hash className="h-3 w-3 inline mr-1" />
|
||||
{pkg.checksum.slice(0, 8)}...
|
||||
</div>
|
||||
<button
|
||||
disabled={!canUpdate}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs rounded-md font-medium transition-colors",
|
||||
canUpdate
|
||||
? "bg-primary-600 text-white hover:bg-primary-700"
|
||||
: "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
'Update'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Compatibility Info */}
|
||||
<div className="text-xs text-gray-500 flex items-start">
|
||||
<Info className="h-3 w-3 mr-1 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
Detected platform: <strong>{agentPlatform}/{agentArchitecture}</strong>.
|
||||
Only compatible packages will be shown.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isUpdating}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
@@ -7,14 +7,12 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
User,
|
||||
Clock,
|
||||
Activity,
|
||||
Copy,
|
||||
Hash,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Container,
|
||||
@@ -25,7 +23,6 @@ import { useRetryCommand } from '@/hooks/useCommands';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Highlight, themes } from 'prism-react-renderer';
|
||||
import { useEffect as useEffectHook } from 'react';
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string;
|
||||
@@ -41,6 +38,8 @@ interface HistoryEntry {
|
||||
exit_code?: number;
|
||||
duration_seconds?: number;
|
||||
created_at: string;
|
||||
metadata?: Record<string, string>;
|
||||
params?: Record<string, any>;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
@@ -76,9 +75,9 @@ const createPackageOperationSummary = (entry: HistoryEntry): string => {
|
||||
|
||||
// Extract duration if available
|
||||
let durationInfo = '';
|
||||
if (entry.logged_at) {
|
||||
if (entry.created_at) {
|
||||
try {
|
||||
const loggedTime = new Date(entry.logged_at).toLocaleTimeString('en-US', {
|
||||
const loggedTime = new Date(entry.created_at).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
@@ -444,9 +443,27 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback subject
|
||||
// Fallback subject - provide better action labels
|
||||
if (!subject) {
|
||||
subject = entry.package_name || 'system operation';
|
||||
// Map action to more readable labels
|
||||
const actionLabels: Record<string, string> = {
|
||||
'scan updates': 'Package Updates',
|
||||
'scan storage': 'Disk Usage',
|
||||
'scan system': 'System Metrics',
|
||||
'scan docker': 'Docker Images',
|
||||
'update agent': 'Agent Update',
|
||||
'dry run update': 'Update Dry Run',
|
||||
'confirm dependencies': 'Dependency Check',
|
||||
'install update': 'Update Installation',
|
||||
'collect specs': 'System Specifications',
|
||||
'enable heartbeat': 'Heartbeat Enable',
|
||||
'disable heartbeat': 'Heartbeat Disable',
|
||||
'reboot': 'System Reboot',
|
||||
'process command': 'Command Processing'
|
||||
};
|
||||
|
||||
// Prioritize metadata subsystem label for better descriptions
|
||||
subject = entry.metadata?.subsystem_label || entry.package_name || actionLabels[action] || action;
|
||||
}
|
||||
|
||||
// Build narrative sentence - system thought style
|
||||
@@ -495,6 +512,16 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
||||
} else {
|
||||
sentence = `Docker Image Scanner results`;
|
||||
}
|
||||
} else if (action === 'update agent') {
|
||||
if (isInProgress) {
|
||||
sentence = `Agent Update initiated to version ${subject}`;
|
||||
} else if (statusType === 'success') {
|
||||
sentence = `Agent updated to version ${subject}`;
|
||||
} else if (statusType === 'failed') {
|
||||
sentence = `Agent update failed for version ${subject}`;
|
||||
} else {
|
||||
sentence = `Agent update to version ${subject}`;
|
||||
}
|
||||
} else if (action === 'dry run update') {
|
||||
if (isInProgress) {
|
||||
sentence = `Dry run initiated for ${subject}`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
Agent,
|
||||
UpdatePackage,
|
||||
AgentUpdatePackage,
|
||||
DashboardStats,
|
||||
AgentListResponse,
|
||||
UpdateListResponse,
|
||||
@@ -160,6 +161,27 @@ export const agentApi = {
|
||||
const response = await api.post(`/agents/${agentId}/subsystems/${subsystem}/interval`, { interval_minutes: intervalMinutes });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update single agent
|
||||
updateAgent: async (agentId: string, updateData: {
|
||||
version: string;
|
||||
platform: string;
|
||||
scheduled?: string;
|
||||
}): Promise<{ message: string; update_id: string; download_url: string; signature: string; checksum: string; file_size: number; estimated_time: number }> => {
|
||||
const response = await api.post(`/agents/${agentId}/update`, updateData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update multiple agents (bulk)
|
||||
updateMultipleAgents: async (updateData: {
|
||||
agent_ids: string[];
|
||||
version: string;
|
||||
platform: string;
|
||||
scheduled?: string;
|
||||
}): Promise<{ message: string; updated: Array<{ agent_id: string; hostname: string; update_id: string; status: string }>; failed: string[]; total_agents: number; package_info: any }> => {
|
||||
const response = await api.post('/agents/bulk-update', updateData);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const updateApi = {
|
||||
@@ -185,6 +207,11 @@ export const updateApi = {
|
||||
await api.post(`/updates/${id}/approve`, { scheduled_at: scheduledAt });
|
||||
},
|
||||
|
||||
// Approve multiple updates
|
||||
approveMultiple: async (updateIds: string[]): Promise<void> => {
|
||||
await api.post('/updates/approve', { update_ids: updateIds });
|
||||
},
|
||||
|
||||
// Reject/cancel update
|
||||
rejectUpdate: async (id: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/reject`);
|
||||
@@ -250,6 +277,28 @@ export const updateApi = {
|
||||
const response = await api.delete(`/commands/failed${params.toString() ? '?' + params.toString() : ''}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available update packages
|
||||
getPackages: async (params?: {
|
||||
version?: string;
|
||||
platform?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ packages: AgentUpdatePackage[]; total: number; limit: number; offset: number }> => {
|
||||
const response = await api.get('/updates/packages', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Sign new update package
|
||||
signPackage: async (packageData: {
|
||||
version: string;
|
||||
platform: string;
|
||||
architecture: string;
|
||||
binary_path: string;
|
||||
}): Promise<{ message: string; package: UpdatePackage }> => {
|
||||
const response = await api.post('/updates/packages/sign', packageData);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const statsApi = {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Database,
|
||||
Settings,
|
||||
MonitorPlay,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
|
||||
@@ -38,6 +39,7 @@ import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
||||
import { AgentStorage } from '@/components/AgentStorage';
|
||||
import { AgentUpdatesEnhanced } from '@/components/AgentUpdatesEnhanced';
|
||||
import { AgentScanners } from '@/components/AgentScanners';
|
||||
import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
|
||||
import ChatTimeline from '@/components/ChatTimeline';
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
@@ -56,6 +58,7 @@ const Agents: React.FC = () => {
|
||||
const [showDurationDropdown, setShowDurationDropdown] = useState(false);
|
||||
const [heartbeatLoading, setHeartbeatLoading] = useState(false); // Loading state for heartbeat toggle
|
||||
const [heartbeatCommandId, setHeartbeatCommandId] = useState<string | null>(null); // Track specific heartbeat command
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false); // Update modal state
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
@@ -1142,18 +1145,27 @@ const Agents: React.FC = () => {
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedAgents.length > 0 && (
|
||||
<button
|
||||
onClick={handleScanSelected}
|
||||
disabled={scanMultipleMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanMultipleMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Selected ({selectedAgents.length})
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={handleScanSelected}
|
||||
disabled={scanMultipleMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanMultipleMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Selected ({selectedAgents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Update Selected ({selectedAgents.length})
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1358,6 +1370,20 @@ const Agents: React.FC = () => {
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedAgents([agent.id]);
|
||||
setShowUpdateModal(true);
|
||||
}}
|
||||
disabled={agent.is_updating}
|
||||
className={cn(
|
||||
"text-gray-400 hover:text-primary-600",
|
||||
agent.is_updating && "text-amber-600 animate-pulse"
|
||||
)}
|
||||
title={agent.is_updating ? "Agent is updating..." : "Update agent"}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
|
||||
disabled={unregisterAgentMutation.isPending}
|
||||
@@ -1382,6 +1408,18 @@ const Agents: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Updates Modal */}
|
||||
<AgentUpdatesModal
|
||||
isOpen={showUpdateModal}
|
||||
onClose={() => setShowUpdateModal(false)}
|
||||
selectedAgentIds={selectedAgents}
|
||||
onAgentsUpdated={() => {
|
||||
// Refresh agents data after update
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
setSelectedAgents([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface Agent {
|
||||
last_reboot_at?: string | null;
|
||||
reboot_reason?: string;
|
||||
metadata?: Record<string, any>;
|
||||
system_info?: Record<string, any>;
|
||||
is_updating?: boolean;
|
||||
updating_to_version?: string;
|
||||
// Note: ip_address not available from API yet
|
||||
}
|
||||
|
||||
@@ -57,9 +60,21 @@ export interface UpdatePackage {
|
||||
approved_at: string | null;
|
||||
scheduled_at: string | null;
|
||||
installed_at: string | null;
|
||||
created_at: string;
|
||||
recent_command_id?: string;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
// Agent Update Package (for agent binary updates)
|
||||
export interface AgentUpdatePackage {
|
||||
id: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
file_size: number;
|
||||
checksum: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Update specific types
|
||||
export interface DockerUpdateInfo {
|
||||
local_digest: string;
|
||||
|
||||
Reference in New Issue
Block a user