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:
Fimeg
2025-10-17 15:28:22 -04:00
parent 61294ba514
commit 2ade509b63
65 changed files with 7342 additions and 424 deletions

View File

@@ -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>

View File

@@ -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" />

View 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;

View File

@@ -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',

View 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'] });
},
});
};

View File

@@ -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'] });
},
});
};

View File

@@ -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 }> => {

View File

@@ -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';

View File

@@ -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">
{(() => {

View 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;

View 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;

View File

@@ -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;

View File

@@ -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