- Fix GetLatestVersionByTypeAndArch to separate platform/architecture - Query now correctly uses platform='linux' and architecture='amd64' - Resolves UI showing 'no packages available' despite updates existing
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
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');
|
|
|
|
// For single agent updates, use individual update with nonce for security
|
|
if (selectedAgentIds.length === 1) {
|
|
const agentId = selectedAgentIds[0];
|
|
|
|
// Generate nonce for security
|
|
const nonceData = await agentApi.generateUpdateNonce(agentId, pkg.version);
|
|
console.log('[UI] Update nonce generated for single agent:', nonceData);
|
|
|
|
// Use individual update endpoint with nonce
|
|
return agentApi.updateAgent(agentId, {
|
|
version: pkg.version,
|
|
platform: pkg.platform,
|
|
nonce: nonceData.update_nonce
|
|
});
|
|
}
|
|
|
|
// For multiple agents, use bulk update
|
|
const updateData = {
|
|
agent_ids: selectedAgentIds,
|
|
version: pkg.version,
|
|
platform: pkg.platform,
|
|
};
|
|
|
|
return agentApi.updateMultipleAgents(updateData);
|
|
},
|
|
onSuccess: (data) => {
|
|
const count = selectedAgentIds.length;
|
|
const message = count === 1 ? 'Update initiated for agent' : `Update initiated for ${count} agents`;
|
|
toast.success(message);
|
|
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>
|
|
);
|
|
} |