Update README with current features and screenshots
- Cross-platform support (Windows/Linux) with Windows Updates and Winget - Added dependency confirmation workflow and refresh token authentication - New screenshots: History, Live Operations, Windows Agent Details - Local CLI features with terminal output and cache system - Updated known limitations - Proxmox integration is broken - Organized docs to docs/ folder and updated .gitignore - Probably introduced a dozen bugs with Windows agents - stay tuned
This commit is contained in:
@@ -7,7 +7,8 @@ import Dashboard from '@/pages/Dashboard';
|
||||
import Agents from '@/pages/Agents';
|
||||
import Updates from '@/pages/Updates';
|
||||
import Docker from '@/pages/Docker';
|
||||
import Logs from '@/pages/Logs';
|
||||
import LiveOperations from '@/pages/LiveOperations';
|
||||
import History from '@/pages/History';
|
||||
import Settings from '@/pages/Settings';
|
||||
import Login from '@/pages/Login';
|
||||
|
||||
@@ -94,7 +95,8 @@ const App: React.FC = () => {
|
||||
<Route path="/updates" element={<Updates />} />
|
||||
<Route path="/updates/:id" element={<Updates />} />
|
||||
<Route path="/docker" element={<Docker />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/live" element={<LiveOperations />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Filter, Package, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { Search, Package, Clock } from 'lucide-react';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
@@ -24,8 +24,7 @@ export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
agent: agentId,
|
||||
type: 'system', // Only show system updates in AgentUpdates
|
||||
agent_id: agentId, // Fix: use correct parameter name expected by backend
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
};
|
||||
|
||||
@@ -157,8 +156,8 @@ export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) {
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 mb-2">
|
||||
<span>Type: {update.package_type}</span>
|
||||
{update.repository_source && (
|
||||
<span>Source: {update.repository_source}</span>
|
||||
{update.metadata?.repository_source && (
|
||||
<span>Source: {update.metadata.repository_source}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
|
||||
470
aggregator-web/src/components/HistoryTimeline.tsx
Normal file
470
aggregator-web/src/components/HistoryTimeline.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Package,
|
||||
Computer,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { logApi } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
update_package_id?: string;
|
||||
action: string;
|
||||
result: string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exit_code: number;
|
||||
duration_seconds: number;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
interface HistoryTimelineProps {
|
||||
agentId?: string; // Optional - if provided, filter to specific agent
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TimelineGroup {
|
||||
date: string;
|
||||
entries: HistoryEntry[];
|
||||
}
|
||||
|
||||
const HistoryTimeline: React.FC<HistoryTimelineProps> = ({ agentId, className }) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('all');
|
||||
const [resultFilter, setResultFilter] = useState('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
|
||||
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
||||
|
||||
// Query parameters for API
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
agent_id: agentId || '',
|
||||
action: actionFilter !== 'all' ? actionFilter : '',
|
||||
result: resultFilter !== 'all' ? resultFilter : '',
|
||||
search: searchQuery,
|
||||
});
|
||||
|
||||
// Fetch history data
|
||||
const { data: historyData, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['history', queryParams],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const params: any = {
|
||||
page: queryParams.page,
|
||||
page_size: queryParams.page_size,
|
||||
};
|
||||
|
||||
if (queryParams.agent_id) {
|
||||
params.agent_id = queryParams.agent_id;
|
||||
}
|
||||
|
||||
if (queryParams.action) {
|
||||
params.action = queryParams.action;
|
||||
}
|
||||
|
||||
if (queryParams.result) {
|
||||
params.result = queryParams.result;
|
||||
}
|
||||
|
||||
const response = await logApi.getAllLogs(params);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch history:', error);
|
||||
toast.error('Failed to fetch history');
|
||||
return { logs: [], total: 0, page: 1, page_size: 50 };
|
||||
}
|
||||
},
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Group entries by date
|
||||
const groupEntriesByDate = (entries: HistoryEntry[]): TimelineGroup[] => {
|
||||
const groups: { [key: string]: HistoryEntry[] } = {};
|
||||
|
||||
entries.forEach(entry => {
|
||||
const date = new Date(entry.executed_at);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
let dateKey: string;
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
dateKey = 'Today';
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
dateKey = 'Yesterday';
|
||||
} else {
|
||||
dateKey = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(entry);
|
||||
});
|
||||
|
||||
return Object.entries(groups).map(([date, entries]) => ({
|
||||
date,
|
||||
entries: entries.sort((a, b) =>
|
||||
new Date(b.executed_at).getTime() - new Date(a.executed_at).getTime()
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const timelineGroups = groupEntriesByDate(historyData?.logs || []);
|
||||
|
||||
// Toggle entry expansion
|
||||
const toggleEntry = (entryId: string) => {
|
||||
const newExpanded = new Set(expandedEntries);
|
||||
if (newExpanded.has(entryId)) {
|
||||
newExpanded.delete(entryId);
|
||||
} else {
|
||||
newExpanded.add(entryId);
|
||||
}
|
||||
setExpandedEntries(newExpanded);
|
||||
};
|
||||
|
||||
// Toggle date expansion
|
||||
const toggleDate = (date: string) => {
|
||||
const newExpanded = new Set(expandedDates);
|
||||
if (newExpanded.has(date)) {
|
||||
newExpanded.delete(date);
|
||||
} else {
|
||||
newExpanded.add(date);
|
||||
}
|
||||
setExpandedDates(newExpanded);
|
||||
};
|
||||
|
||||
// Get action icon
|
||||
const getActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case 'install':
|
||||
case 'upgrade':
|
||||
return <Package className="h-4 w-4" />;
|
||||
case 'scan':
|
||||
return <Search className="h-4 w-4" />;
|
||||
case 'dry_run':
|
||||
return <Terminal className="h-4 w-4" />;
|
||||
default:
|
||||
return <Activity className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get result icon
|
||||
const getResultIcon = (result: string) => {
|
||||
switch (result) {
|
||||
case 'success':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'running':
|
||||
return <RefreshCw className="h-4 w-4 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (result: string) => {
|
||||
switch (result) {
|
||||
case 'success':
|
||||
return 'text-green-700 bg-green-100 border-green-200';
|
||||
case 'failed':
|
||||
return 'text-red-700 bg-red-100 border-red-200';
|
||||
case 'running':
|
||||
return 'text-blue-700 bg-blue-100 border-blue-200';
|
||||
default:
|
||||
return 'text-gray-700 bg-gray-100 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* Header with search and filters */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-5 w-5 text-gray-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{agentId ? 'Agent History' : 'Universal Audit Log'}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<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={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by action or result..."
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{(actionFilter !== 'all' || resultFilter !== 'all') && (
|
||||
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">
|
||||
{[actionFilter, resultFilter].filter(f => f !== 'all').length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Actions</option>
|
||||
<option value="install">Install</option>
|
||||
<option value="upgrade">Upgrade</option>
|
||||
<option value="scan">Scan</option>
|
||||
<option value="dry_run">Dry Run</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Result
|
||||
</label>
|
||||
<select
|
||||
value={resultFilter}
|
||||
onChange={(e) => setResultFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Results</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="running">Running</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{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 history...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{!isLoading && timelineGroups.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No history found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || actionFilter !== 'all' || resultFilter !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No activities have been recorded yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{timelineGroups.map((group) => (
|
||||
<div key={group.date} className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Date header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => toggleDate(group.date)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{expandedDates.has(group.date) ? (
|
||||
<ChevronDown className="h-4 w-4 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-gray-600" />
|
||||
)}
|
||||
<h4 className="font-medium text-gray-900">{group.date}</h4>
|
||||
<span className="text-sm text-gray-500">
|
||||
({group.entries.length} events)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline entries */}
|
||||
{expandedDates.has(group.date) && (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{group.entries.map((entry) => (
|
||||
<div key={entry.id} className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Timeline icon */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{getResultIcon(entry.result)}
|
||||
</div>
|
||||
|
||||
{/* Entry content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => toggleEntry(entry.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getActionIcon(entry.action)}
|
||||
<span className="font-medium text-gray-900 capitalize">
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border",
|
||||
getStatusColor(entry.result)
|
||||
)}>
|
||||
{entry.result}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>{formatRelativeTime(entry.executed_at)}</span>
|
||||
<span>{formatDuration(entry.duration_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="mt-1 flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Computer className="h-3 w-3" />
|
||||
<span>Agent: {entry.agent_id}</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedEntries.has(entry.id) && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Exit Code:</span>
|
||||
<span className="ml-2">{entry.exit_code}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Duration:</span>
|
||||
<span className="ml-2">{formatDuration(entry.duration_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{(entry.stdout || entry.stderr) && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-2 flex items-center space-x-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>Output</span>
|
||||
</h5>
|
||||
{entry.stdout && (
|
||||
<div className="bg-gray-900 text-green-400 p-3 rounded-md font-mono text-xs overflow-x-auto">
|
||||
<pre className="whitespace-pre-wrap">{entry.stdout}</pre>
|
||||
</div>
|
||||
)}
|
||||
{entry.stderr && (
|
||||
<div className="bg-gray-900 text-red-400 p-3 rounded-md font-mono text-xs overflow-x-auto mt-2">
|
||||
<pre className="whitespace-pre-wrap">{entry.stderr}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{historyData && historyData.total > historyData.page_size && (
|
||||
<div className="flex items-center justify-between bg-white px-4 py-3 border border-gray-200 rounded-lg shadow-sm">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {((historyData.page - 1) * historyData.page_size) + 1} to{' '}
|
||||
{Math.min(historyData.page * historyData.page_size, historyData.total)} of{' '}
|
||||
{historyData.total} results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setQueryParams(prev => ({ ...prev, page: Math.max(1, prev.page - 1) }))}
|
||||
disabled={historyData.page === 1}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {historyData.page} of {Math.ceil(historyData.total / historyData.page_size)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setQueryParams(prev => ({ ...prev, page: prev.page + 1 }))}
|
||||
disabled={historyData.page >= Math.ceil(historyData.total / historyData.page_size)}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryTimeline;
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
LayoutDashboard,
|
||||
Computer,
|
||||
Package,
|
||||
FileText,
|
||||
Activity,
|
||||
History,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
@@ -58,10 +59,16 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
current: location.pathname.startsWith('/docker'),
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
href: '/logs',
|
||||
icon: FileText,
|
||||
current: location.pathname === '/logs',
|
||||
name: 'Live Operations',
|
||||
href: '/live',
|
||||
icon: Activity,
|
||||
current: location.pathname === '/live',
|
||||
},
|
||||
{
|
||||
name: 'History',
|
||||
href: '/history',
|
||||
icon: History,
|
||||
current: location.pathname === '/history',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
|
||||
57
aggregator-web/src/hooks/useCommands.ts
Normal file
57
aggregator-web/src/hooks/useCommands.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { updateApi, logApi } from '@/lib/api';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
interface ActiveCommand {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
agent_hostname: string;
|
||||
command_type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
sent_at?: string;
|
||||
completed_at?: string;
|
||||
package_name: string;
|
||||
package_type: string;
|
||||
}
|
||||
|
||||
export const useActiveCommands = (): UseQueryResult<{ commands: ActiveCommand[]; count: number }, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['activeCommands'],
|
||||
queryFn: () => updateApi.getActiveCommands(),
|
||||
refetchInterval: 5000, // Auto-refresh every 5 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const useRecentCommands = (limit?: number): UseQueryResult<{ commands: ActiveCommand[]; count: number; limit: number }, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['recentCommands', limit],
|
||||
queryFn: () => updateApi.getRecentCommands(limit),
|
||||
});
|
||||
};
|
||||
|
||||
export const useRetryCommand = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.retryCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate active and recent commands queries
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelCommand = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.cancelCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate active and recent commands queries
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import type { UpdatePackage, ListQueryParams, UpdateApprovalRequest, UpdateListResponse } from '@/types';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
@@ -19,26 +19,88 @@ export const useUpdate = (id: string, enabled: boolean = true): UseQueryResult<U
|
||||
};
|
||||
|
||||
export const useApproveUpdate = (): UseMutationResult<void, Error, { id: string; scheduledAt?: string; }, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, scheduledAt }: { id: string; scheduledAt?: string }) =>
|
||||
updateApi.approveUpdate(id, scheduledAt),
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveMultipleUpdates = (): UseMutationResult<void, Error, UpdateApprovalRequest, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request),
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRejectUpdate = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.rejectUpdate,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInstallUpdate = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.installUpdate,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRetryCommand = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.retryCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate logs and active operations queries
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelCommand = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.cancelCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate logs and active operations queries
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -113,6 +113,40 @@ export const updateApi = {
|
||||
installUpdate: async (id: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/install`);
|
||||
},
|
||||
|
||||
// Get update logs
|
||||
getUpdateLogs: async (id: string, limit?: number): Promise<{ logs: any[]; count: number }> => {
|
||||
const response = await api.get(`/updates/${id}/logs`, {
|
||||
params: limit ? { limit } : undefined
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Retry a failed, timed_out, or cancelled command
|
||||
retryCommand: async (commandId: string): Promise<{ message: string; command_id: string; new_id: string }> => {
|
||||
const response = await api.post(`/commands/${commandId}/retry`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Cancel a pending or sent command
|
||||
cancelCommand: async (commandId: string): Promise<{ message: string }> => {
|
||||
const response = await api.post(`/commands/${commandId}/cancel`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get active commands for live command control
|
||||
getActiveCommands: async (): Promise<{ commands: any[]; count: number }> => {
|
||||
const response = await api.get('/commands/active');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get recent commands for retry functionality
|
||||
getRecentCommands: async (limit?: number): Promise<{ commands: any[]; count: number; limit: number }> => {
|
||||
const response = await api.get('/commands/recent', {
|
||||
params: limit ? { limit } : undefined
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const statsApi = {
|
||||
@@ -123,6 +157,41 @@ export const statsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const logApi = {
|
||||
// Get all logs with filtering for universal log view
|
||||
getAllLogs: async (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
agent_id?: string;
|
||||
action?: string;
|
||||
result?: string;
|
||||
since?: string;
|
||||
}): Promise<{ logs: any[]; total: number; page: number; page_size: number }> => {
|
||||
const response = await api.get('/logs', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get active operations for live status view
|
||||
getActiveOperations: async (): Promise<{ operations: any[]; count: number }> => {
|
||||
const response = await api.get('/logs/active');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get active commands for live command control
|
||||
getActiveCommands: async (): Promise<{ commands: any[]; count: number }> => {
|
||||
const response = await api.get('/commands/active');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get recent commands for retry functionality
|
||||
getRecentCommands: async (limit?: number): Promise<{ commands: any[]; count: number; limit: number }> => {
|
||||
const response = await api.get('/commands/recent', {
|
||||
params: limit ? { limit } : undefined
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
// Simple login (using API key or token)
|
||||
login: async (credentials: { token: string }): Promise<{ token: string }> => {
|
||||
|
||||
@@ -110,6 +110,10 @@ export const getStatusColor = (status: string): string => {
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'pending':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'checking_dependencies':
|
||||
return 'text-blue-500 bg-blue-100';
|
||||
case 'pending_dependencies':
|
||||
return 'text-orange-600 bg-orange-100';
|
||||
case 'approved':
|
||||
case 'scheduled':
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Computer,
|
||||
@@ -15,21 +15,41 @@ import {
|
||||
GitBranch,
|
||||
Clock,
|
||||
Trash2,
|
||||
History as HistoryIcon,
|
||||
Download,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
|
||||
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
||||
import HistoryTimeline from '@/components/HistoryTimeline';
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [osFilter, setOsFilter] = useState<string>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'history'>('overview');
|
||||
|
||||
// Debounce search query to avoid API calls on every keystroke
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 300); // 300ms delay
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
// Helper function to get system metadata from agent
|
||||
const getSystemMetadata = (agent: any) => {
|
||||
@@ -106,7 +126,7 @@ const Agents: React.FC = () => {
|
||||
|
||||
// Fetch agents list
|
||||
const { data: agentsData, isPending, error } = useAgents({
|
||||
search: searchQuery || undefined,
|
||||
search: debouncedSearchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
});
|
||||
|
||||
@@ -117,6 +137,10 @@ const Agents: React.FC = () => {
|
||||
const scanMultipleMutation = useScanMultipleAgents();
|
||||
const unregisterAgentMutation = useUnregisterAgent();
|
||||
|
||||
// Active commands for live status
|
||||
const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands();
|
||||
const cancelCommandMutation = useCancelCommand();
|
||||
|
||||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
|
||||
@@ -189,6 +213,58 @@ const Agents: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle command cancellation
|
||||
const handleCancelCommand = async (commandId: string) => {
|
||||
try {
|
||||
await cancelCommandMutation.mutateAsync(commandId);
|
||||
toast.success('Command cancelled successfully');
|
||||
refetchActiveCommands();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Get agent-specific active commands
|
||||
const getAgentActiveCommands = () => {
|
||||
if (!selectedAgent || !activeCommandsData?.commands) return [];
|
||||
return activeCommandsData.commands.filter(cmd => cmd.agent_id === selectedAgent.id);
|
||||
};
|
||||
|
||||
// Helper function to get command display info
|
||||
const getCommandDisplayInfo = (command: any) => {
|
||||
const actionMap: { [key: string]: { icon: React.ReactNode; label: string } } = {
|
||||
'scan': { icon: <RefreshCw className="h-4 w-4" />, label: 'System scan' },
|
||||
'install_updates': { icon: <Package className="h-4 w-4" />, label: `Installing ${command.package_name || 'packages'}` },
|
||||
'dry_run_update': { icon: <Search className="h-4 w-4" />, label: `Checking dependencies for ${command.package_name || 'packages'}` },
|
||||
'confirm_dependencies': { icon: <CheckCircle className="h-4 w-4" />, label: `Installing confirmed dependencies` },
|
||||
};
|
||||
|
||||
return actionMap[command.command_type] || {
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
label: command.command_type.replace('_', ' ')
|
||||
};
|
||||
};
|
||||
|
||||
// Get command status
|
||||
const getCommandStatus = (command: any) => {
|
||||
switch (command.status) {
|
||||
case 'pending':
|
||||
return { text: 'Pending', color: 'text-amber-600 bg-amber-50 border-amber-200' };
|
||||
case 'sent':
|
||||
return { text: 'Sent to agent', color: 'text-blue-600 bg-blue-50 border-blue-200' };
|
||||
case 'running':
|
||||
return { text: 'Running', color: 'text-green-600 bg-green-50 border-green-200' };
|
||||
case 'completed':
|
||||
return { text: 'Completed', color: 'text-gray-600 bg-gray-50 border-gray-200' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', color: 'text-red-600 bg-red-50 border-red-200' };
|
||||
case 'timed_out':
|
||||
return { text: 'Timed out', color: 'text-red-600 bg-red-50 border-red-200' };
|
||||
default:
|
||||
return { text: command.status, color: 'text-gray-600 bg-gray-50 border-gray-200' };
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique OS types for filter
|
||||
const osTypes = [...new Set(agents.map(agent => agent.os_type))];
|
||||
|
||||
@@ -203,19 +279,53 @@ const Agents: React.FC = () => {
|
||||
>
|
||||
← Back to Agents
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{selectedAgent.hostname}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
System details and update management for this agent
|
||||
</p>
|
||||
|
||||
{/* New Compact Header Design */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between mb-4">
|
||||
<div className="flex-1 mb-4 sm:mb-0">
|
||||
{/* Main hostname with integrated agent info */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-3 sm:space-y-0 sm:space-x-3 mb-2">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">
|
||||
{selectedAgent.hostname}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-gray-500">[Agent ID:</span>
|
||||
<span className="font-mono text-xs text-gray-700 bg-gray-100 px-2 py-1 rounded break-all">
|
||||
{selectedAgent.id}
|
||||
</span>
|
||||
<span className="text-gray-500">|</span>
|
||||
<span className="text-gray-500">Version:</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium text-gray-900">
|
||||
{selectedAgent.current_version || 'Unknown'}
|
||||
</span>
|
||||
{selectedAgent.update_available === true && (
|
||||
<span className="flex items-center text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Update Available
|
||||
</span>
|
||||
)}
|
||||
{selectedAgent.update_available === false && selectedAgent.current_version && (
|
||||
<span className="flex items-center text-xs text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Up to Date
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500">]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-line with registration info only */}
|
||||
<div className="text-sm text-gray-600">
|
||||
<span>Registered {formatRelativeTime(selectedAgent.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
className="btn btn-primary sm:ml-4 w-full sm:w-auto"
|
||||
>
|
||||
{scanAgentMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
@@ -227,88 +337,162 @@ const Agents: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={cn(
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeTab === 'overview'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={cn(
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center space-x-2',
|
||||
activeTab === 'history'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
<HistoryIcon className="h-4 w-4" />
|
||||
<span>History</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Agent info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Agent Status Card */}
|
||||
{/* Main content area */}
|
||||
<div className="lg:col-span-2">
|
||||
{activeTab === 'overview' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Agent Status Card - Compact Timeline Style */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-medium text-gray-900">Agent Status</h2>
|
||||
<span className={cn('badge', getStatusColor(isOnline(selectedAgent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={cn(
|
||||
'w-3 h-3 rounded-full',
|
||||
isOnline(selectedAgent.last_seen) ? 'bg-green-500' : 'bg-gray-400'
|
||||
)}></div>
|
||||
<span className={cn('badge', getStatusColor(isOnline(selectedAgent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Agent Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Agent Information</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Agent ID</p>
|
||||
<p className="text-xs font-mono text-gray-700 break-all">
|
||||
{selectedAgent.id}
|
||||
</p>
|
||||
{/* Compact Timeline Display */}
|
||||
<div className="space-y-2 mb-3">
|
||||
{(() => {
|
||||
const agentCommands = getAgentActiveCommands();
|
||||
const activeCommands = agentCommands.filter(cmd =>
|
||||
cmd.status === 'running' || cmd.status === 'sent' || cmd.status === 'pending'
|
||||
);
|
||||
const completedCommands = agentCommands.filter(cmd =>
|
||||
cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out'
|
||||
).slice(0, 1); // Only show last completed
|
||||
|
||||
const displayCommands = [
|
||||
...activeCommands.slice(0, 2), // Max 2 active
|
||||
...completedCommands.slice(0, 1) // Max 1 completed
|
||||
].slice(0, 3); // Total max 3 entries
|
||||
|
||||
if (displayCommands.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-3 text-sm text-gray-500">
|
||||
No active operations
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Version</p>
|
||||
<p className="text-xs font-medium text-gray-900">
|
||||
{selectedAgent.agent_version || selectedAgent.version || 'Unknown'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return displayCommands.map((command, index) => {
|
||||
const displayInfo = getCommandDisplayInfo(command);
|
||||
const statusInfo = getCommandStatus(command);
|
||||
const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending';
|
||||
|
||||
return (
|
||||
<div key={command.id} className="flex items-start space-x-2 p-2 bg-gray-50 rounded border border-gray-200">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{displayInfo.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Registered</p>
|
||||
<p className="text-xs font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.created_at)}
|
||||
</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{isActive ? (
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
|
||||
statusInfo.color
|
||||
)}>
|
||||
{command.status === 'running' && <RefreshCw className="h-3 w-3 animate-spin mr-1" />}
|
||||
{command.status === 'pending' && <Clock className="h-3 w-3 mr-1" />}
|
||||
{isActive ? command.status.replace('_', ' ') : statusInfo.text}
|
||||
</span>
|
||||
<span className="ml-1">{displayInfo.label}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
|
||||
statusInfo.color
|
||||
)}>
|
||||
{command.status === 'completed' && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{command.status === 'failed' && <XCircle className="h-3 w-3 mr-1" />}
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
<span className="ml-1">{displayInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatRelativeTime(command.created_at)}
|
||||
</span>
|
||||
{isActive && (command.status === 'pending' || command.status === 'sent') && (
|
||||
<button
|
||||
onClick={() => handleCancelCommand(command.id)}
|
||||
disabled={cancelCommandMutation.isPending}
|
||||
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const meta = getSystemMetadata(selectedAgent);
|
||||
if (meta.installationTime !== 'Unknown') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Installation Time</p>
|
||||
<p className="text-xs font-medium text-gray-900">
|
||||
{formatRelativeTime(meta.installationTime)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span>Last Check-in</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.last_seen)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Basic Status Info */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-200">
|
||||
<span>Last seen: {formatRelativeTime(selectedAgent.last_seen)}</span>
|
||||
<span>Last scan: {selectedAgent.last_scan ? formatRelativeTime(selectedAgent.last_scan) : 'Never'}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Last Scan</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.last_scan
|
||||
? formatRelativeTime(selectedAgent.last_scan)
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-center mt-3 pt-3 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isPending}
|
||||
className="btn btn-primary w-full sm:w-auto text-sm"
|
||||
>
|
||||
{scanAgentMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,6 +628,12 @@ const Agents: React.FC = () => {
|
||||
{/* System Updates */}
|
||||
<AgentSystemUpdates agentId={selectedAgent.id} />
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<HistoryTimeline agentId={selectedAgent.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
@@ -603,7 +793,7 @@ const Agents: React.FC = () => {
|
||||
<Computer className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No agents found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter !== 'all' || osFilter !== 'all'
|
||||
{debouncedSearchQuery || statusFilter !== 'all' || osFilter !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No agents have registered with the server yet.'}
|
||||
</p>
|
||||
@@ -624,6 +814,7 @@ const Agents: React.FC = () => {
|
||||
</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Version</th>
|
||||
<th className="table-header">OS</th>
|
||||
<th className="table-header">Last Check-in</th>
|
||||
<th className="table-header">Last Scan</th>
|
||||
@@ -673,6 +864,25 @@ const Agents: React.FC = () => {
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-900">
|
||||
{agent.current_version || 'Unknown'}
|
||||
</span>
|
||||
{agent.update_available === true && (
|
||||
<span className="flex items-center text-xs text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-full">
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Update
|
||||
</span>
|
||||
)}
|
||||
{agent.update_available === false && agent.current_version && (
|
||||
<span className="flex items-center text-xs text-green-600 bg-green-50 px-1.5 py-0.5 rounded-full">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{(() => {
|
||||
|
||||
74
aggregator-web/src/pages/History.tsx
Normal file
74
aggregator-web/src/pages/History.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
History,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import HistoryTimeline from '@/components/HistoryTimeline';
|
||||
|
||||
const HistoryPage: React.FC = () => {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<History className="h-8 w-8 text-indigo-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">History & Audit Log</h1>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Complete chronological timeline of all system activities across all agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Activities</p>
|
||||
<p className="text-2xl font-bold text-gray-900">--</p>
|
||||
</div>
|
||||
<History className="h-8 w-8 text-indigo-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-green-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Successful</p>
|
||||
<p className="text-2xl font-bold text-green-600">--</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-600">--</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-blue-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Today</p>
|
||||
<p className="text-2xl font-bold text-blue-600">--</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<HistoryTimeline />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryPage;
|
||||
508
aggregator-web/src/pages/LiveOperations.tsx
Normal file
508
aggregator-web/src/pages/LiveOperations.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
Package,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
Terminal,
|
||||
User,
|
||||
Calendar,
|
||||
Search,
|
||||
Computer,
|
||||
Eye,
|
||||
RotateCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useUpdates } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useRetryCommand, useCancelCommand } from '@/hooks/useCommands';
|
||||
import { getStatusColor, formatRelativeTime, isOnline } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { logApi } from '@/lib/api';
|
||||
|
||||
interface LiveOperation {
|
||||
id: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
updateId: string;
|
||||
packageName: string;
|
||||
action: 'checking_dependencies' | 'installing' | 'pending_dependencies';
|
||||
status: 'running' | 'completed' | 'failed' | 'waiting';
|
||||
startTime: Date;
|
||||
duration?: number;
|
||||
progress?: string;
|
||||
logOutput?: string;
|
||||
error?: string;
|
||||
commandId: string;
|
||||
commandStatus: string;
|
||||
}
|
||||
|
||||
const LiveOperations: React.FC = () => {
|
||||
const [expandedOperation, setExpandedOperation] = useState<string | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Fetch active commands from API
|
||||
const { data: activeCommandsData, refetch: refetchCommands } = useActiveCommands();
|
||||
|
||||
// Retry and cancel mutations
|
||||
const retryMutation = useRetryCommand();
|
||||
const cancelMutation = useCancelCommand();
|
||||
|
||||
// Fetch agents for mapping
|
||||
const { data: agentsData } = useAgents();
|
||||
const agents = agentsData?.agents || [];
|
||||
|
||||
// Transform API data to LiveOperation format
|
||||
const activeOperations: LiveOperation[] = React.useMemo(() => {
|
||||
if (!activeCommandsData?.commands) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return activeCommandsData.commands.map((cmd: any) => {
|
||||
const agent = agents.find(a => a.id === cmd.agent_id);
|
||||
let action: LiveOperation['action'];
|
||||
let status: LiveOperation['status'];
|
||||
|
||||
// Map command status to operation status
|
||||
if (cmd.status === 'failed' || cmd.status === 'timed_out') {
|
||||
status = 'failed';
|
||||
} else if (cmd.status === 'pending') {
|
||||
status = 'waiting';
|
||||
} else if (cmd.status === 'completed') {
|
||||
status = 'completed';
|
||||
} else {
|
||||
status = 'running';
|
||||
}
|
||||
|
||||
// Map command type to action
|
||||
switch (cmd.command_type) {
|
||||
case 'dry_run_update':
|
||||
action = 'checking_dependencies';
|
||||
break;
|
||||
case 'install_updates':
|
||||
case 'confirm_dependencies':
|
||||
action = 'installing';
|
||||
break;
|
||||
default:
|
||||
action = 'checking_dependencies';
|
||||
}
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
agentId: cmd.agent_id,
|
||||
agentName: cmd.agent_hostname || 'Unknown Agent',
|
||||
updateId: cmd.id,
|
||||
packageName: cmd.package_name !== 'N/A' ? cmd.package_name : cmd.command_type,
|
||||
action,
|
||||
status,
|
||||
startTime: new Date(cmd.created_at),
|
||||
progress: getStatusText(cmd.command_type, cmd.status),
|
||||
commandId: cmd.id,
|
||||
commandStatus: cmd.status,
|
||||
};
|
||||
});
|
||||
}, [activeCommandsData, agents]);
|
||||
|
||||
// Manual refresh function
|
||||
const handleManualRefresh = () => {
|
||||
refetchCommands();
|
||||
};
|
||||
|
||||
// Handle retry command
|
||||
const handleRetryCommand = async (commandId: string) => {
|
||||
try {
|
||||
await retryMutation.mutateAsync(commandId);
|
||||
toast.success('Command retry initiated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to retry command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel command
|
||||
const handleCancelCommand = async (commandId: string) => {
|
||||
try {
|
||||
await cancelMutation.mutateAsync(commandId);
|
||||
toast.success('Command cancelled successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
function getStatusText(commandType: string, status: string): string {
|
||||
if (commandType === 'dry_run_update') {
|
||||
return status === 'pending' ? 'Pending dependency check...' : 'Checking for required dependencies...';
|
||||
}
|
||||
if (commandType === 'install_updates') {
|
||||
return status === 'pending' ? 'Pending installation...' : 'Installing package and dependencies...';
|
||||
}
|
||||
if (commandType === 'confirm_dependencies') {
|
||||
return status === 'pending' ? 'Pending dependency confirmation...' : 'Installing confirmed dependencies...';
|
||||
}
|
||||
return status === 'pending' ? 'Pending operation...' : 'Processing command...';
|
||||
}
|
||||
|
||||
function getActionIcon(action: LiveOperation['action']) {
|
||||
switch (action) {
|
||||
case 'checking_dependencies':
|
||||
return <Search className="h-4 w-4" />;
|
||||
case 'installing':
|
||||
return <Package className="h-4 w-4" />;
|
||||
case 'pending_dependencies':
|
||||
return <AlertTriangle className="h-4 w-4" />;
|
||||
default:
|
||||
return <Activity className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: LiveOperation['status']) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4" />;
|
||||
case 'waiting':
|
||||
return <Clock className="h-4 w-4" />;
|
||||
default:
|
||||
return <Activity className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getDuration(startTime: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - startTime.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const filteredOperations = activeOperations.filter(op => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
op.packageName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
op.agentName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || op.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center space-x-2">
|
||||
<Activity className="h-6 w-6" />
|
||||
<span>Live Operations</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Real-time monitoring of ongoing update operations
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
autoRefresh
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", autoRefresh && "animate-spin")} />
|
||||
<span>Auto Refresh</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>Refresh Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Active</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{activeOperations.length}</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-blue-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Running</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{activeOperations.filter(op => op.status === 'running').length}
|
||||
</p>
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 text-blue-400 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-amber-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Waiting</p>
|
||||
<p className="text-2xl font-bold text-amber-600">
|
||||
{activeOperations.filter(op => op.status === 'waiting').length}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-red-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{activeOperations.filter(op => op.status === 'failed').length}
|
||||
</p>
|
||||
</div>
|
||||
<XCircle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<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={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by package name or agent..."
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">1</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="waiting">Waiting</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operations list */}
|
||||
{filteredOperations.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 active operations</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All operations are completed. Check the Updates page to start new operations.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredOperations.map((operation) => (
|
||||
<div
|
||||
key={operation.id}
|
||||
className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Operation header */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getActionIcon(operation.action)}
|
||||
<span className="text-lg font-medium text-gray-900">
|
||||
{operation.packageName}
|
||||
</span>
|
||||
<span className={cn('badge', getStatusColor(operation.status))}>
|
||||
{getStatusIcon(operation.status)}
|
||||
<span className="ml-1">{operation.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 flex items-center space-x-1">
|
||||
<Computer className="h-4 w-4" />
|
||||
<span>{operation.agentName}</span>
|
||||
<span>•</span>
|
||||
<span>{getDuration(operation.startTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setExpandedOperation(expandedOperation === operation.id ? null : operation.id)}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>Details</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
expandedOperation === operation.id && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
{operation.progress}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedOperation === operation.id && (
|
||||
<div className="p-4 bg-gray-50 border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Operation Details</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Action:</span>
|
||||
<span className="font-medium capitalize">{operation.action.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Started:</span>
|
||||
<span className="font-medium">{formatRelativeTime(operation.startTime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Duration:</span>
|
||||
<span className="font-medium">{getDuration(operation.startTime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Agent:</span>
|
||||
<span className="font-medium">{operation.agentName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Quick Actions</h4>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => window.open(`/updates/${operation.updateId}`, '_blank')}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>View Update Details</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`/agents/${operation.agentId}`, '_blank')}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Computer className="h-4 w-4" />
|
||||
<span>View Agent</span>
|
||||
</button>
|
||||
|
||||
{/* Command control buttons */}
|
||||
{operation.commandStatus === 'pending' || operation.commandStatus === 'sent' ? (
|
||||
<button
|
||||
onClick={() => handleCancelCommand(operation.commandId)}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-red-100 text-red-700 rounded-md hover:bg-red-200 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span>{cancelMutation.isPending ? 'Cancelling...' : 'Cancel Command'}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Retry button for failed/timed_out commands */}
|
||||
{operation.commandStatus === 'failed' || operation.commandStatus === 'timed_out' ? (
|
||||
<button
|
||||
onClick={() => handleRetryCommand(operation.commandId)}
|
||||
disabled={retryMutation.isPending}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-md hover:bg-green-200 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span>{retryMutation.isPending ? 'Retrying...' : 'Retry Command'}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log output placeholder */}
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2 flex items-center space-x-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>Live Output</span>
|
||||
</h4>
|
||||
<div className="bg-gray-900 text-green-400 p-3 rounded-md font-mono text-xs min-h-32 max-h-48 overflow-y-auto">
|
||||
{operation.status === 'running' ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Waiting for log stream...</span>
|
||||
</div>
|
||||
) : operation.logOutput ? (
|
||||
<pre>{operation.logOutput}</pre>
|
||||
) : operation.error ? (
|
||||
<div className="text-red-400">Error: {operation.error}</div>
|
||||
) : (
|
||||
<div className="text-gray-500">No log output available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveOperations;
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Logs</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
View system logs and update history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center">
|
||||
<div className="text-gray-400 mb-2">📋</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Coming Soon</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Logs and history tracking will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Calendar,
|
||||
X,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useCancelCommand } from '@/hooks/useUpdates';
|
||||
import { useRecentCommands } from '@/hooks/useCommands';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateApi } from '@/lib/api';
|
||||
|
||||
|
||||
const Updates: React.FC = () => {
|
||||
@@ -28,19 +33,39 @@ const Updates: React.FC = () => {
|
||||
|
||||
// Get filters from URL params
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||
|
||||
// Debounce search query to avoid API calls on every keystroke
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 300); // 300ms delay
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get('page') || '1'));
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [showLogModal, setShowLogModal] = useState(false);
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [showDependencyModal, setShowDependencyModal] = useState(false);
|
||||
const [pendingDependencies, setPendingDependencies] = useState<string[]>([]);
|
||||
const [dependencyUpdateId, setDependencyUpdateId] = useState<string | null>(null);
|
||||
const [dependencyLoading, setDependencyLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'updates' | 'commands'>('updates');
|
||||
|
||||
// Store filters in URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (debouncedSearchQuery) params.set('search', debouncedSearchQuery);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
@@ -52,11 +77,11 @@ const Updates: React.FC = () => {
|
||||
if (newUrl !== window.location.href) {
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
|
||||
}, [debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
|
||||
|
||||
// Fetch updates list
|
||||
const { data: updatesData, isPending, error } = useUpdates({
|
||||
search: searchQuery || undefined,
|
||||
search: debouncedSearchQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
severity: severityFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
@@ -68,10 +93,15 @@ const Updates: React.FC = () => {
|
||||
// Fetch single update if ID is provided
|
||||
const { data: selectedUpdateData } = useUpdate(id || '', !!id);
|
||||
|
||||
// Fetch recent commands for retry functionality
|
||||
const { data: recentCommandsData } = useRecentCommands(50);
|
||||
|
||||
const approveMutation = useApproveUpdate();
|
||||
const rejectMutation = useRejectUpdate();
|
||||
const installMutation = useInstallUpdate();
|
||||
const bulkApproveMutation = useApproveMultipleUpdates();
|
||||
const retryMutation = useRetryCommand();
|
||||
const cancelMutation = useCancelCommand();
|
||||
|
||||
const updates = updatesData?.updates || [];
|
||||
const totalCount = updatesData?.total || 0;
|
||||
@@ -138,6 +168,79 @@ const Updates: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle retry command
|
||||
const handleRetryCommand = async (commandId: string) => {
|
||||
try {
|
||||
await retryMutation.mutateAsync(commandId);
|
||||
toast.success('Command retry initiated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to retry command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel command
|
||||
const handleCancelCommand = async (commandId: string) => {
|
||||
try {
|
||||
await cancelMutation.mutateAsync(commandId);
|
||||
toast.success('Command cancelled successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle dependency confirmation
|
||||
const handleConfirmDependencies = async (updateId: string) => {
|
||||
setDependencyLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/updates/${updateId}/confirm-dependencies`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to confirm dependencies');
|
||||
}
|
||||
|
||||
toast.success('Dependency installation confirmed');
|
||||
setShowDependencyModal(false);
|
||||
setPendingDependencies([]);
|
||||
setDependencyUpdateId(null);
|
||||
|
||||
// Refresh the update data
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
toast.error('Failed to confirm dependencies');
|
||||
console.error('Failed to confirm dependencies:', error);
|
||||
} finally {
|
||||
setDependencyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle dependency cancellation
|
||||
const handleCancelDependencies = async () => {
|
||||
setShowDependencyModal(false);
|
||||
setPendingDependencies([]);
|
||||
setDependencyUpdateId(null);
|
||||
toast('Dependency installation cancelled');
|
||||
};
|
||||
|
||||
// Handle viewing logs
|
||||
const handleViewLogs = async (updateId: string) => {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const result = await updateApi.getUpdateLogs(updateId, 50);
|
||||
setLogs(result.logs || []);
|
||||
setShowLogModal(true);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load installation logs');
|
||||
console.error('Failed to load logs:', error);
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique values for filters
|
||||
const statuses = [...new Set(updates.map((u: UpdatePackage) => u.status))];
|
||||
const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))];
|
||||
@@ -230,7 +333,14 @@ const Updates: React.FC = () => {
|
||||
{selectedUpdate.severity}
|
||||
</span>
|
||||
<span className={cn('badge', getStatusColor(selectedUpdate.status))}>
|
||||
{selectedUpdate.status}
|
||||
{selectedUpdate.status === 'checking_dependencies' ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Checking dependencies...</span>
|
||||
</div>
|
||||
) : (
|
||||
selectedUpdate.status
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
@@ -351,6 +461,54 @@ const Updates: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedUpdate.status === 'checking_dependencies' && (
|
||||
<div className="w-full btn btn-secondary opacity-75 cursor-not-allowed">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Checking Dependencies...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedUpdate.status === 'pending_dependencies' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Extract dependencies from metadata
|
||||
const deps = selectedUpdate.metadata?.dependencies || [];
|
||||
setPendingDependencies(Array.isArray(deps) ? deps : []);
|
||||
setDependencyUpdateId(selectedUpdate.id);
|
||||
setShowDependencyModal(true);
|
||||
}}
|
||||
className="w-full btn btn-warning"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
Review Dependencies
|
||||
</button>
|
||||
)}
|
||||
|
||||
{['installing', 'completed', 'failed'].includes(selectedUpdate.status) && (
|
||||
<button
|
||||
onClick={() => handleViewLogs(selectedUpdate.id)}
|
||||
disabled={logsLoading}
|
||||
className="w-full btn btn-ghost"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
{logsLoading ? 'Loading...' : 'View Log'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedUpdate.status === 'failed' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// This would need a way to find the associated command ID
|
||||
// For now, we'll show a message indicating this needs to be implemented
|
||||
toast.info('Retry functionality will be available in the command history view');
|
||||
}}
|
||||
className="w-full btn btn-warning"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Retry Update
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${selectedUpdate.agent_id}`)}
|
||||
className="w-full btn btn-ghost"
|
||||
@@ -362,6 +520,213 @@ const Updates: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependency Confirmation Modal */}
|
||||
{showDependencyModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-gray-200">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-lg">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Dependencies Required
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-md p-1"
|
||||
onClick={handleCancelDependencies}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-base font-medium text-gray-900">
|
||||
Additional packages are required
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
To install <span className="font-medium text-gray-900">{selectedUpdate?.package_name}</span>, the following additional packages will also be installed:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies List */}
|
||||
{pendingDependencies.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Required Dependencies:</h5>
|
||||
<ul className="space-y-2">
|
||||
{pendingDependencies.map((dep, index) => (
|
||||
<li key={index} className="flex items-center space-x-2 text-sm">
|
||||
<Package className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{dep}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Message */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 mr-2 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-800">
|
||||
<p className="font-medium">Please review the dependencies before proceeding.</p>
|
||||
<p className="mt-1">These additional packages will be installed alongside your requested package.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={() => handleConfirmDependencies(dependencyUpdateId!)}
|
||||
disabled={dependencyLoading}
|
||||
>
|
||||
{dependencyLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Approving & Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve & Install All
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={handleCancelDependencies}
|
||||
disabled={dependencyLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Modal */}
|
||||
{showLogModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl border border-gray-200">
|
||||
{/* Modern Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-lg">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Installation Logs - {selectedUpdate?.package_name}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-md p-1"
|
||||
onClick={() => setShowLogModal(false)}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal Content Area */}
|
||||
<div className="bg-gray-900 text-green-400 p-4 max-h-96 overflow-y-auto" style={{ fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace' }}>
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-green-400 mr-2" />
|
||||
<span className="text-green-400">Loading logs...</span>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
No installation logs available for this update.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="border-b border-gray-700 pb-3 last:border-b-0">
|
||||
<div className="flex items-center space-x-3 mb-2 text-xs">
|
||||
<span className="text-gray-500">
|
||||
{new Date(log.executedAt).toLocaleString()}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded font-medium",
|
||||
log.action === 'install' ? "bg-blue-900/50 text-blue-300" :
|
||||
log.action === 'configure' ? "bg-yellow-900/50 text-yellow-300" :
|
||||
log.action === 'cleanup' ? "bg-gray-700 text-gray-300" :
|
||||
"bg-gray-700 text-gray-300"
|
||||
)}>
|
||||
{log.action?.toUpperCase() || 'UNKNOWN'}
|
||||
</span>
|
||||
{log.exit_code !== undefined && (
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded font-medium",
|
||||
log.exit_code === 0 ? "bg-green-900/50 text-green-300" : "bg-red-900/50 text-red-300"
|
||||
)}>
|
||||
Exit: {log.exit_code}
|
||||
</span>
|
||||
)}
|
||||
{log.duration_seconds && (
|
||||
<span className="text-gray-500">
|
||||
{log.duration_seconds}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.stdout && (
|
||||
<div className="text-sm text-gray-300 whitespace-pre-wrap mb-2 font-mono">
|
||||
{log.stdout}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.stderr && (
|
||||
<div className="text-sm text-red-400 whitespace-pre-wrap font-mono">
|
||||
{log.stderr}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modern Footer */}
|
||||
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={() => setShowLogModal(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={() => {
|
||||
// Copy logs to clipboard functionality could be added here
|
||||
navigator.clipboard.writeText(logs.map(log =>
|
||||
`${log.action?.toUpperCase() || 'UNKNOWN'} - ${new Date(log.executedAt).toLocaleString()}\n${log.stdout || ''}\n${log.stderr || ''}`
|
||||
).join('\n\n'));
|
||||
// Could add toast notification here
|
||||
}}
|
||||
>
|
||||
Copy Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -376,6 +741,124 @@ const Updates: React.FC = () => {
|
||||
setCurrentPage(1); // Reset to first page when changing page size
|
||||
};
|
||||
|
||||
// Commands view
|
||||
if (activeTab === 'commands') {
|
||||
const commands = recentCommandsData?.commands || [];
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Command History</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Review and retry failed or cancelled commands
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('updates')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
← Back to Updates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commands list */}
|
||||
{commands.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No commands found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
No command history available yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">Command</th>
|
||||
<th className="table-header">Package</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Created</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{commands.map((command: any) => (
|
||||
<tr key={command.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{command.command_type.replace('_', ' ')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{command.package_name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{command.agent_hostname}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn(
|
||||
'badge',
|
||||
command.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
command.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
command.status === 'cancelled' ? 'bg-gray-100 text-gray-800' :
|
||||
command.status === 'pending' || command.status === 'sent' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
)}>
|
||||
{command.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(command.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
{(command.status === 'failed' || command.status === 'cancelled' || command.status === 'timed_out') && (
|
||||
<button
|
||||
onClick={() => handleRetryCommand(command.id)}
|
||||
disabled={retryMutation.isLoading}
|
||||
className="text-amber-600 hover:text-amber-800"
|
||||
title="Retry command"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(command.status === 'pending' || command.status === 'sent') && (
|
||||
<button
|
||||
onClick={() => handleCancelCommand(command.id)}
|
||||
disabled={cancelMutation.isLoading}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
title="Cancel command"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Updates list view
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
@@ -554,6 +1037,15 @@ const Updates: React.FC = () => {
|
||||
Approve Selected ({selectedUpdates.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Command History button */}
|
||||
<button
|
||||
onClick={() => setActiveTab('commands')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Command History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -650,7 +1142,7 @@ const Updates: React.FC = () => {
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No updates found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter || severityFilter || typeFilter || agentFilter
|
||||
{debouncedSearchQuery || statusFilter || severityFilter || typeFilter || agentFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All agents are up to date!'}
|
||||
</p>
|
||||
@@ -728,7 +1220,14 @@ const Updates: React.FC = () => {
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getStatusColor(update.status))}>
|
||||
{update.status}
|
||||
{update.status === 'checking_dependencies' ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Checking dependencies...</span>
|
||||
</div>
|
||||
) : (
|
||||
update.status
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
@@ -779,6 +1278,12 @@ const Updates: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{update.status === 'checking_dependencies' && (
|
||||
<div className="text-blue-500" title="Checking dependencies">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/updates/${update.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
@@ -835,16 +1340,7 @@ const Updates: React.FC = () => {
|
||||
|
||||
{/* Page numbers */}
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
const pageNum = totalPages <= 5 ? i + 1 : currentPage <= 3 ? i + 1 : currentPage >= totalPages - 2 ? totalPages - 4 + i : currentPage - 2 + i;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user