Add screenshots and update gitignore for alpha release
- Fixed gitignore to allow Screenshots/*.png files - Added all screenshots for README documentation - Fixed gitignore to be less restrictive with image files - Includes dashboard, agent, updates, and docker screenshots
This commit is contained in:
@@ -1,16 +1,15 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
import { useAuthStore, useUIStore } from '@/lib/store';
|
||||
import Layout from '@/components/Layout';
|
||||
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 Settings from '@/pages/Settings';
|
||||
import Login from '@/pages/Login';
|
||||
import NotificationCenter from '@/components/NotificationCenter';
|
||||
|
||||
// Protected route component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -25,7 +24,7 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { isAuthenticated, token } = useAuthStore();
|
||||
const { theme } = useSettingsStore();
|
||||
const { theme } = useUIStore();
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
@@ -72,9 +71,7 @@ const App: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Notification center */}
|
||||
{isAuthenticated && <NotificationCenter />}
|
||||
|
||||
|
||||
{/* App routes */}
|
||||
<Routes>
|
||||
{/* Login route */}
|
||||
@@ -96,6 +93,7 @@ const App: React.FC = () => {
|
||||
<Route path="/agents/:id" element={<Agents />} />
|
||||
<Route path="/updates" element={<Updates />} />
|
||||
<Route path="/updates/:id" element={<Updates />} />
|
||||
<Route path="/docker" element={<Docker />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
238
aggregator-web/src/components/AgentUpdates.tsx
Normal file
238
aggregator-web/src/components/AgentUpdates.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Filter, Package, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
|
||||
interface AgentUpdatesProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface AgentUpdateResponse {
|
||||
updates: UpdatePackage[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { data: updateData, isLoading, error } = useQuery<AgentUpdateResponse>({
|
||||
queryKey: ['agent-updates', agentId, currentPage, pageSize, searchTerm],
|
||||
queryFn: async () => {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
agent: agentId,
|
||||
type: 'system', // Only show system updates in AgentUpdates
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
};
|
||||
|
||||
const response = await updateApi.getUpdates(params);
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
const updates = updateData?.updates || [];
|
||||
const totalCount = updateData?.total || 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity.toLowerCase()) {
|
||||
case 'critical': return 'text-red-600 bg-red-50';
|
||||
case 'important':
|
||||
case 'high': return 'text-orange-600 bg-orange-50';
|
||||
case 'moderate':
|
||||
case 'medium': return 'text-yellow-600 bg-yellow-50';
|
||||
case 'low':
|
||||
case 'none': return 'text-blue-600 bg-blue-50';
|
||||
default: return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const getPackageTypeIcon = (packageType: string) => {
|
||||
switch (packageType.toLowerCase()) {
|
||||
case 'system': return '📦';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="text-red-600 text-sm">Error loading updates: {(error as Error).message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">System Updates</h2>
|
||||
<div className="text-sm text-gray-500">
|
||||
{totalCount} update{totalCount !== 1 ? 's' : ''} available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<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"
|
||||
placeholder="Search packages..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page Size */}
|
||||
<div className="sm:w-32">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updates List */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{updates.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No updates found</p>
|
||||
<p className="text-sm mt-2">This agent is up to date!</p>
|
||||
</div>
|
||||
) : (
|
||||
updates.map((update) => (
|
||||
<div key={update.id} className="p-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{getPackageTypeIcon(update.package_type)}</span>
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||
{update.package_name}
|
||||
</h3>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getSeverityColor(update.severity)}`}>
|
||||
{update.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatRelativeTime(update.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-600">From:</span>
|
||||
<span className="font-mono bg-gray-100 px-1 py-0.5 rounded">
|
||||
{update.current_version || 'N/A'}
|
||||
</span>
|
||||
<span className="text-gray-600">→</span>
|
||||
<span className="font-mono bg-green-50 text-green-700 px-1 py-0.5 rounded">
|
||||
{update.available_version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
className="text-green-600 hover:text-green-800 text-sm font-medium"
|
||||
onClick={() => {
|
||||
// TODO: Implement install single update functionality
|
||||
console.log('Install update:', update.id);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
onClick={() => {
|
||||
// TODO: Implement view logs functionality
|
||||
console.log('View logs for update:', update.id);
|
||||
}}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} results
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-gray-700">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
Bell,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Container,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -25,8 +26,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const { sidebarOpen, setSidebarOpen, setActiveTab } = useUIStore();
|
||||
const { logout } = useAuthStore();
|
||||
const { notifications } = useRealtimeStore();
|
||||
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
@@ -49,6 +51,12 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: Package,
|
||||
current: location.pathname.startsWith('/updates'),
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
href: '/docker',
|
||||
icon: Container,
|
||||
current: location.pathname.startsWith('/docker'),
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
href: '/logs',
|
||||
@@ -78,6 +86,33 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Notification helper functions
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✅';
|
||||
case 'error':
|
||||
return '❌';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
default:
|
||||
return 'ℹ️';
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'border-green-200 bg-green-50';
|
||||
case 'error':
|
||||
return 'border-red-200 bg-red-50';
|
||||
case 'warning':
|
||||
return 'border-yellow-200 bg-yellow-50';
|
||||
default:
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
@@ -148,7 +183,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
{/* Top header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||||
@@ -157,7 +192,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="hidden md:block">
|
||||
<form onSubmit={handleSearch} className="hidden md:block flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
@@ -165,29 +200,113 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search updates..."
|
||||
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Header actions - right to left order */}
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100"
|
||||
title="Refresh"
|
||||
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
title="Refresh page"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<button className="relative text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100">
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsNotificationDropdownOpen(!isNotificationDropdownOpen)}
|
||||
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors relative"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-600 text-white text-xs rounded-full flex items-center justify-center font-medium">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
{isNotificationDropdownOpen && (
|
||||
<div className="absolute top-12 right-0 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clearNotifications}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsNotificationDropdownOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications list */}
|
||||
<div className="overflow-y-auto max-h-80">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Bell className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
'p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors',
|
||||
!notification.read && 'bg-blue-50 border-l-4 border-l-blue-500',
|
||||
getNotificationColor(notification.type)
|
||||
)}
|
||||
onClick={() => {
|
||||
markNotificationRead(notification.id);
|
||||
setIsNotificationDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-0.5 text-lg">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{notification.title}
|
||||
</p>
|
||||
{!notification.read && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
{formatRelativeTime(notification.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, X, Check, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Bell, X, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { useRealtimeStore } from '@/lib/store';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
@@ -36,7 +36,7 @@ const NotificationCenter: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<div className="fixed top-4 right-4 z-40">
|
||||
{/* Notification bell */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
|
||||
@@ -1,99 +1,39 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import { Agent, ListQueryParams } from '@/types';
|
||||
import { useAgentStore, useRealtimeStore } from '@/lib/store';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
|
||||
export const useAgents = (params?: ListQueryParams) => {
|
||||
const { setAgents, setLoading, setError, updateAgentStatus } = useAgentStore();
|
||||
import type { Agent, ListQueryParams, AgentListResponse, ScanRequest } from '@/types';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
export const useAgents = (params?: ListQueryParams): UseQueryResult<AgentListResponse, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['agents', params],
|
||||
queryFn: () => agentApi.getAgents(params),
|
||||
onSuccess: (data) => {
|
||||
setAgents(data.agents);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(handleApiError(error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
setLoading(false);
|
||||
},
|
||||
staleTime: 30 * 1000, // Consider data stale after 30 seconds
|
||||
refetchInterval: 60 * 1000, // Auto-refetch every minute
|
||||
});
|
||||
};
|
||||
|
||||
export const useAgent = (id: string, enabled: boolean = true) => {
|
||||
const { setSelectedAgent, setLoading, setError } = useAgentStore();
|
||||
|
||||
export const useAgent = (id: string, enabled: boolean = true): UseQueryResult<Agent, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['agent', id],
|
||||
queryFn: () => agentApi.getAgent(id),
|
||||
enabled: enabled && !!id,
|
||||
onSuccess: (data) => {
|
||||
setSelectedAgent(data);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(handleApiError(error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useScanAgent = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useScanAgent = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: agentApi.scanAgent,
|
||||
onSuccess: () => {
|
||||
// Invalidate agents query to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Scan Triggered',
|
||||
message: 'Agent scan has been triggered successfully.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Scan Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useScanMultipleAgents = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useScanMultipleAgents = (): UseMutationResult<void, Error, ScanRequest, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: agentApi.triggerScan,
|
||||
onSuccess: () => {
|
||||
// Invalidate agents query to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
});
|
||||
};
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Bulk Scan Triggered',
|
||||
message: 'Scan has been triggered for selected agents.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Bulk Scan Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
export const useUnregisterAgent = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: agentApi.unregisterAgent,
|
||||
});
|
||||
};
|
||||
170
aggregator-web/src/hooks/useDocker.ts
Normal file
170
aggregator-web/src/hooks/useDocker.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dockerApi } from '@/lib/api';
|
||||
import type { DockerContainer, DockerImage } from '@/types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Hook for fetching all Docker containers/images across all agents
|
||||
export const useDockerContainers = (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
agent?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: ['docker-containers', params],
|
||||
queryFn: async () => {
|
||||
const response = await dockerApi.getContainers(params || {});
|
||||
return response;
|
||||
},
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for fetching Docker containers for a specific agent
|
||||
export const useAgentDockerContainers = (agentId: string, params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: ['agent-docker-containers', agentId, params],
|
||||
queryFn: async () => {
|
||||
const response = await dockerApi.getAgentContainers(agentId, params || {});
|
||||
return response;
|
||||
},
|
||||
staleTime: 30000,
|
||||
enabled: !!agentId,
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for Docker statistics
|
||||
export const useDockerStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['docker-stats'],
|
||||
queryFn: async () => {
|
||||
const response = await dockerApi.getStats();
|
||||
return response;
|
||||
},
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for approving Docker updates
|
||||
export const useApproveDockerUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ containerId, imageId }: {
|
||||
containerId: string;
|
||||
imageId: string;
|
||||
}) => {
|
||||
const response = await dockerApi.approveUpdate(containerId, imageId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Docker update approved successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to approve Docker update');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for rejecting Docker updates
|
||||
export const useRejectDockerUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ containerId, imageId }: {
|
||||
containerId: string;
|
||||
imageId: string;
|
||||
}) => {
|
||||
const response = await dockerApi.rejectUpdate(containerId, imageId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Docker update rejected');
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to reject Docker update');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for installing Docker updates
|
||||
export const useInstallDockerUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ containerId, imageId }: {
|
||||
containerId: string;
|
||||
imageId: string;
|
||||
}) => {
|
||||
const response = await dockerApi.installUpdate(containerId, imageId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Docker update installation started');
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to install Docker update');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for bulk Docker operations
|
||||
export const useBulkDockerActions = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const approveMultiple = useMutation({
|
||||
mutationFn: async ({ updates }: {
|
||||
updates: Array<{ containerId: string; imageId: string }>;
|
||||
}) => {
|
||||
const response = await dockerApi.bulkApproveUpdates(updates);
|
||||
return response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.approved} Docker updates approved`);
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to approve Docker updates');
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMultiple = useMutation({
|
||||
mutationFn: async ({ updates }: {
|
||||
updates: Array<{ containerId: string; imageId: string }>;
|
||||
}) => {
|
||||
const response = await dockerApi.bulkRejectUpdates(updates);
|
||||
return response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.rejected} Docker updates rejected`);
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-docker-containers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['docker-stats'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to reject Docker updates');
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
approveMultiple,
|
||||
rejectMultiple,
|
||||
};
|
||||
};
|
||||
46
aggregator-web/src/hooks/useSettings.ts
Normal file
46
aggregator-web/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
|
||||
export interface TimezoneOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface TimezoneSettings {
|
||||
timezone: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function useTimezones() {
|
||||
return useQuery({
|
||||
queryKey: ['timezones'],
|
||||
queryFn: async (): Promise<TimezoneOption[]> => {
|
||||
const { data } = await api.get('/settings/timezones')
|
||||
return data.timezones
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTimezone() {
|
||||
return useQuery({
|
||||
queryKey: ['timezone'],
|
||||
queryFn: async (): Promise<TimezoneSettings> => {
|
||||
const { data } = await api.get('/settings/timezone')
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTimezone() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (timezone: string): Promise<TimezoneSettings> => {
|
||||
const { data } = await api.put('/settings/timezone', { timezone })
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timezone'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { statsApi } from '@/lib/api';
|
||||
import { DashboardStats } from '@/types';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
import type { DashboardStats } from '@/types';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
export const useDashboardStats = () => {
|
||||
export const useDashboardStats = (): UseQueryResult<DashboardStats, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: statsApi.getDashboardStats,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
staleTime: 15000, // Consider data stale after 15 seconds
|
||||
onError: (error) => {
|
||||
console.error('Failed to fetch dashboard stats:', handleApiError(error));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,173 +1,44 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import { UpdatePackage, ListQueryParams, UpdateApprovalRequest } from '@/types';
|
||||
import { useUpdateStore, useRealtimeStore } from '@/lib/store';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
|
||||
export const useUpdates = (params?: ListQueryParams) => {
|
||||
const { setUpdates, setLoading, setError } = useUpdateStore();
|
||||
import type { UpdatePackage, ListQueryParams, UpdateApprovalRequest, UpdateListResponse } from '@/types';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
export const useUpdates = (params?: ListQueryParams): UseQueryResult<UpdateListResponse, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['updates', params],
|
||||
queryFn: () => updateApi.getUpdates(params),
|
||||
onSuccess: (data) => {
|
||||
setUpdates(data.updates);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(handleApiError(error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdate = (id: string, enabled: boolean = true) => {
|
||||
const { setSelectedUpdate, setLoading, setError } = useUpdateStore();
|
||||
|
||||
export const useUpdate = (id: string, enabled: boolean = true): UseQueryResult<UpdatePackage, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['update', id],
|
||||
queryFn: () => updateApi.getUpdate(id),
|
||||
enabled: enabled && !!id,
|
||||
onSuccess: (data) => {
|
||||
setSelectedUpdate(data);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(handleApiError(error).message);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { updateUpdateStatus } = useUpdateStore();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useApproveUpdate = (): UseMutationResult<void, Error, { id: string; scheduledAt?: string; }, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, scheduledAt }: { id: string; scheduledAt?: string }) =>
|
||||
updateApi.approveUpdate(id, scheduledAt),
|
||||
onSuccess: (_, { id }) => {
|
||||
// Update local state
|
||||
updateUpdateStatus(id, 'approved');
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update', id] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Update Approved',
|
||||
message: 'The update has been approved successfully.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Approval Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveMultipleUpdates = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { bulkUpdateStatus } = useUpdateStore();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useApproveMultipleUpdates = (): UseMutationResult<void, Error, UpdateApprovalRequest, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request),
|
||||
onSuccess: (_, request) => {
|
||||
// Update local state
|
||||
bulkUpdateStatus(request.update_ids, 'approved');
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Updates Approved',
|
||||
message: `${request.update_ids.length} update(s) have been approved successfully.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Bulk Approval Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRejectUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { updateUpdateStatus } = useUpdateStore();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useRejectUpdate = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: updateApi.rejectUpdate,
|
||||
onSuccess: (_, id) => {
|
||||
// Update local state
|
||||
updateUpdateStatus(id, 'pending');
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update', id] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Update Rejected',
|
||||
message: 'The update has been rejected and moved back to pending status.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Rejection Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInstallUpdate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { updateUpdateStatus } = useUpdateStore();
|
||||
const { addNotification } = useRealtimeStore();
|
||||
|
||||
export const useInstallUpdate = (): UseMutationResult<void, Error, string, unknown> => {
|
||||
return useMutation({
|
||||
mutationFn: updateApi.installUpdate,
|
||||
onSuccess: (_, id) => {
|
||||
// Update local state
|
||||
updateUpdateStatus(id, 'installing');
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update', id] });
|
||||
|
||||
// Show success notification
|
||||
addNotification({
|
||||
type: 'info',
|
||||
title: 'Installation Started',
|
||||
message: 'The update installation has been started. This may take a few minutes.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Installation Failed',
|
||||
message: handleApiError(error).message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@apply border-gray-200;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -8,13 +8,21 @@ import {
|
||||
UpdateApprovalRequest,
|
||||
ScanRequest,
|
||||
ListQueryParams,
|
||||
ApiResponse,
|
||||
ApiError
|
||||
ApiError,
|
||||
DockerContainer,
|
||||
DockerImage,
|
||||
DockerContainerListResponse,
|
||||
DockerStats,
|
||||
DockerUpdateRequest,
|
||||
BulkDockerUpdateRequest
|
||||
} from '@/types';
|
||||
|
||||
// Base URL for API
|
||||
export const API_BASE_URL = (import.meta.env?.VITE_API_URL as string) || '/api/v1';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -66,6 +74,11 @@ export const agentApi = {
|
||||
scanAgent: async (id: string): Promise<void> => {
|
||||
await api.post(`/agents/${id}/scan`);
|
||||
},
|
||||
|
||||
// Unregister/remove agent
|
||||
unregisterAgent: async (id: string): Promise<void> => {
|
||||
await api.delete(`/agents/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const updateApi = {
|
||||
@@ -178,7 +191,7 @@ export const handleApiError = (error: any): ApiError => {
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
if (status && status >= 500) {
|
||||
return {
|
||||
message: 'Server error. Please try again later.',
|
||||
code: 'SERVER_ERROR',
|
||||
@@ -198,4 +211,75 @@ export const handleApiError = (error: any): ApiError => {
|
||||
};
|
||||
};
|
||||
|
||||
// Docker-specific API endpoints
|
||||
export const dockerApi = {
|
||||
// Get all Docker containers and images across all agents
|
||||
getContainers: async (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
agent?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<DockerContainerListResponse> => {
|
||||
const response = await api.get('/docker/containers', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get Docker containers for a specific agent
|
||||
getAgentContainers: async (agentId: string, params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<DockerContainerListResponse> => {
|
||||
const response = await api.get(`/agents/${agentId}/docker`, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get Docker statistics
|
||||
getStats: async (): Promise<DockerStats> => {
|
||||
const response = await api.get('/docker/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Approve Docker image update
|
||||
approveUpdate: async (containerId: string, imageId: string, scheduledAt?: string): Promise<void> => {
|
||||
await api.post(`/docker/containers/${containerId}/images/${imageId}/approve`, {
|
||||
scheduled_at: scheduledAt,
|
||||
});
|
||||
},
|
||||
|
||||
// Reject Docker image update
|
||||
rejectUpdate: async (containerId: string, imageId: string): Promise<void> => {
|
||||
await api.post(`/docker/containers/${containerId}/images/${imageId}/reject`);
|
||||
},
|
||||
|
||||
// Install Docker image update
|
||||
installUpdate: async (containerId: string, imageId: string): Promise<void> => {
|
||||
await api.post(`/docker/containers/${containerId}/images/${imageId}/install`);
|
||||
},
|
||||
|
||||
// Bulk approve Docker updates
|
||||
bulkApproveUpdates: async (updates: Array<{ containerId: string; imageId: string }>, scheduledAt?: string): Promise<{ approved: number }> => {
|
||||
const response = await api.post('/docker/updates/bulk-approve', {
|
||||
updates,
|
||||
scheduled_at: scheduledAt,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Bulk reject Docker updates
|
||||
bulkRejectUpdates: async (updates: Array<{ containerId: string; imageId: string }>): Promise<{ rejected: number }> => {
|
||||
const response = await api.post('/docker/updates/bulk-reject', {
|
||||
updates,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger Docker scan on agents
|
||||
triggerScan: async (agentIds?: string[]): Promise<void> => {
|
||||
await api.post('/docker/scan', { agent_ids: agentIds });
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -173,7 +173,7 @@ interface RealtimeState {
|
||||
}>;
|
||||
setConnected: (connected: boolean) => void;
|
||||
setLastUpdate: (timestamp: string) => void;
|
||||
addNotification: (notification: Omit<typeof RealtimeState.prototype.notifications[0], 'id' | 'timestamp' | 'read'>) => void;
|
||||
addNotification: (notification: Omit<RealtimeState['notifications'][0], 'id' | 'timestamp' | 'read'>) => void;
|
||||
markNotificationRead: (id: string) => void;
|
||||
clearNotifications: () => void;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,32 @@ export const formatDate = (dateString: string): string => {
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
if (!dateString) return 'Never';
|
||||
|
||||
let date: Date;
|
||||
try {
|
||||
// Handle various timestamp formats
|
||||
if (dateString.includes('T') && dateString.includes('Z')) {
|
||||
// ISO 8601 format
|
||||
date = new Date(dateString);
|
||||
} else if (dateString.includes(' ')) {
|
||||
// Database format like "2025-01-15 10:30:00"
|
||||
date = new Date(dateString.replace(' ', 'T') + 'Z');
|
||||
} else {
|
||||
// Try direct parsing
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
// Check if date is invalid
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn('Invalid date string:', dateString);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error parsing date:', dateString, error);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
@@ -35,7 +60,7 @@ export const formatRelativeTime = (dateString: string): string => {
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return formatDate(dateString);
|
||||
return formatDate(date.toISOString());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,7 +69,7 @@ export const isOnline = (lastCheckin: string): boolean => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - lastCheck.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
return diffMins < 10; // Consider online if checked in within 10 minutes
|
||||
return diffMins < 15; // Consider online if checked in within 15 minutes (allows for 5min check-in + buffer)
|
||||
};
|
||||
|
||||
// Size formatting utilities
|
||||
@@ -103,11 +128,14 @@ export const getSeverityColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'important':
|
||||
case 'high':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'moderate':
|
||||
case 'medium':
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
case 'low':
|
||||
case 'none':
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
@@ -194,7 +222,7 @@ export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -10,7 +10,7 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 10 * 1000, // 10 seconds
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,21 +5,22 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
Activity,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Globe,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Package,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
GitBranch,
|
||||
Clock,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents } from '@/hooks/useAgents';
|
||||
import { Agent } from '@/types';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AgentSystemUpdates } from '@/components/AgentUpdates';
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
@@ -30,8 +31,81 @@ const Agents: React.FC = () => {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
|
||||
// Helper function to get system metadata from agent
|
||||
const getSystemMetadata = (agent: any) => {
|
||||
const metadata = agent.metadata || {};
|
||||
|
||||
return {
|
||||
cpuModel: metadata.cpu_model || 'Unknown',
|
||||
cpuCores: metadata.cpu_cores || 'Unknown',
|
||||
memoryTotal: metadata.memory_total ? parseInt(metadata.memory_total) : 0,
|
||||
diskMount: metadata.disk_mount || 'Unknown',
|
||||
diskTotal: metadata.disk_total ? parseInt(metadata.disk_total) : 0,
|
||||
diskUsed: metadata.disk_used ? parseInt(metadata.disk_used) : 0,
|
||||
processes: metadata.processes || 'Unknown',
|
||||
uptime: metadata.uptime || 'Unknown',
|
||||
installationTime: metadata.installation_time || 'Unknown',
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to parse OS information
|
||||
const parseOSInfo = (agent: any) => {
|
||||
const osType = agent.os_type || '';
|
||||
const osVersion = agent.os_version || '';
|
||||
|
||||
// Extract platform and distribution
|
||||
let platform = osType;
|
||||
let distribution = '';
|
||||
let version = osVersion;
|
||||
|
||||
// Handle Linux distributions
|
||||
if (osType.toLowerCase().includes('linux')) {
|
||||
platform = 'Linux';
|
||||
// Try to extract distribution from version string
|
||||
if (osVersion.toLowerCase().includes('ubuntu')) {
|
||||
distribution = 'Ubuntu';
|
||||
version = osVersion.replace(/ubuntu/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('fedora')) {
|
||||
distribution = 'Fedora';
|
||||
version = osVersion.replace(/fedora/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('debian')) {
|
||||
distribution = 'Debian';
|
||||
version = osVersion.replace(/debian/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('centos')) {
|
||||
distribution = 'CentOS';
|
||||
version = osVersion.replace(/centos/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('proxmox')) {
|
||||
distribution = 'Proxmox';
|
||||
version = osVersion.replace(/proxmox/i, '').trim();
|
||||
} else if (osVersion.toLowerCase().includes('arch')) {
|
||||
distribution = 'Arch Linux';
|
||||
version = osVersion.replace(/arch/i, '').trim();
|
||||
} else {
|
||||
// Try to get first word as distribution
|
||||
const words = osVersion.split(' ');
|
||||
distribution = words[0] || 'Unknown Distribution';
|
||||
version = words.slice(1).join(' ');
|
||||
}
|
||||
} else if (osType.toLowerCase().includes('windows')) {
|
||||
platform = 'Windows';
|
||||
distribution = osVersion; // Windows version info is all in one field
|
||||
version = '';
|
||||
} else if (osType.toLowerCase().includes('darwin') || osType.toLowerCase().includes('macos')) {
|
||||
platform = 'macOS';
|
||||
distribution = 'macOS';
|
||||
version = osVersion;
|
||||
}
|
||||
|
||||
// Truncate long version strings
|
||||
if (version.length > 30) {
|
||||
version = version.substring(0, 30) + '...';
|
||||
}
|
||||
|
||||
return { platform, distribution, version: version.trim() };
|
||||
};
|
||||
|
||||
// Fetch agents list
|
||||
const { data: agentsData, isLoading, error } = useAgents({
|
||||
const { data: agentsData, isPending, error } = useAgents({
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
});
|
||||
@@ -41,6 +115,7 @@ const Agents: React.FC = () => {
|
||||
|
||||
const scanAgentMutation = useScanAgent();
|
||||
const scanMultipleMutation = useScanMultipleAgents();
|
||||
const unregisterAgentMutation = useUnregisterAgent();
|
||||
|
||||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
@@ -93,6 +168,27 @@ const Agents: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle agent removal
|
||||
const handleRemoveAgent = async (agentId: string, hostname: string) => {
|
||||
if (!window.confirm(
|
||||
`Are you sure you want to remove agent "${hostname}"? This action cannot be undone and will remove the agent from the system.`
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unregisterAgentMutation.mutateAsync(agentId);
|
||||
toast.success(`Agent "${hostname}" removed successfully`);
|
||||
|
||||
// Navigate back to agents list if we're on the agent detail page
|
||||
if (id && id === agentId) {
|
||||
navigate('/agents');
|
||||
}
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique OS types for filter
|
||||
const osTypes = [...new Set(agents.map(agent => agent.os_type))];
|
||||
|
||||
@@ -109,19 +205,19 @@ const Agents: React.FC = () => {
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{selectedAgent.hostname}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Agent details and system information
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
System details and update management for this agent
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
disabled={scanAgentMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanAgentMutation.isLoading ? (
|
||||
{scanAgentMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
@@ -134,36 +230,84 @@ const Agents: React.FC = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Agent info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Status card */}
|
||||
{/* Agent Status Card */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Status</h2>
|
||||
<span className={cn('badge', getStatusColor(selectedAgent.status))}>
|
||||
{selectedAgent.status}
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-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 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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.last_checkin)}
|
||||
</p>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.last_scan
|
||||
? formatRelativeTime(selectedAgent.last_scan)
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,47 +316,134 @@ const Agents: React.FC = () => {
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic System Info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Operating System</p>
|
||||
<p className="text-sm text-gray-600">Platform</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.os_type} {selectedAgent.os_version}
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
return osInfo.platform;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Distribution</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
return osInfo.distribution;
|
||||
})()}
|
||||
</p>
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(selectedAgent);
|
||||
if (osInfo.version) {
|
||||
return (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Version: {osInfo.version}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Architecture</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.architecture}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">IP Address</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.ip_address}
|
||||
{selectedAgent.os_architecture || selectedAgent.architecture}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hardware Specs */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Agent Version</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.version}
|
||||
</p>
|
||||
</div>
|
||||
{(() => {
|
||||
const meta = getSystemMetadata(selectedAgent);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<Cpu className="h-4 w-4 mr-1" />
|
||||
CPU
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{meta.cpuModel}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{meta.cpuCores} cores
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Registered</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedAgent.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{meta.memoryTotal > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<MemoryStick className="h-4 w-4 mr-1" />
|
||||
Memory
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatBytes(meta.memoryTotal)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.diskTotal > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<HardDrive className="h-4 w-4 mr-1" />
|
||||
Disk ({meta.diskMount})
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)}
|
||||
</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${Math.round((meta.diskUsed / meta.diskTotal) * 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.processes !== 'Unknown' && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<GitBranch className="h-4 w-4 mr-1" />
|
||||
Running Processes
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{meta.processes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.uptime !== 'Unknown' && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
Uptime
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{meta.uptime}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* System Updates */}
|
||||
<AgentSystemUpdates agentId={selectedAgent.id} />
|
||||
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
@@ -221,25 +452,25 @@ const Agents: React.FC = () => {
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
className="w-full btn btn-primary"
|
||||
>
|
||||
{scanAgentMutation.isLoading ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Trigger Scan
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/updates?agent=${selectedAgent.id}`)}
|
||||
className="w-full btn btn-secondary"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
View Updates
|
||||
View All Updates
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveAgent(selectedAgent.id, selectedAgent.hostname)}
|
||||
disabled={unregisterAgentMutation.isPending}
|
||||
className="w-full btn btn-danger"
|
||||
>
|
||||
{unregisterAgentMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Remove Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,10 +529,10 @@ const Agents: React.FC = () => {
|
||||
{selectedAgents.length > 0 && (
|
||||
<button
|
||||
onClick={handleScanSelected}
|
||||
disabled={scanMultipleMutation.isLoading}
|
||||
disabled={scanMultipleMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanMultipleMutation.isLoading ? (
|
||||
{scanMultipleMutation.isPending ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
@@ -351,7 +582,7 @@ const Agents: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Agents table */}
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
@@ -401,7 +632,7 @@ const Agents: React.FC = () => {
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredAgents.map((agent) => (
|
||||
<tr key={agent.id} className="hover:bg-gray-50">
|
||||
<tr key={agent.id} className="hover:bg-gray-50 group">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -425,30 +656,46 @@ const Agents: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.ip_address}
|
||||
{agent.metadata && (() => {
|
||||
const meta = getSystemMetadata(agent);
|
||||
const parts = [];
|
||||
if (meta.cpuCores !== 'Unknown') parts.push(`${meta.cpuCores} cores`);
|
||||
if (meta.memoryTotal > 0) parts.push(formatBytes(meta.memoryTotal));
|
||||
if (parts.length > 0) return parts.join(' • ');
|
||||
return 'System info available';
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getStatusColor(agent.status))}>
|
||||
{agent.status}
|
||||
<span className={cn('badge', getStatusColor(isOnline(agent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{agent.os_type}
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(agent);
|
||||
return osInfo.distribution || agent.os_type;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.architecture}
|
||||
{(() => {
|
||||
const osInfo = parseOSInfo(agent);
|
||||
if (osInfo.version) {
|
||||
return `${osInfo.version} • ${agent.os_architecture || agent.architecture}`;
|
||||
}
|
||||
return `${agent.os_architecture || agent.architecture}`;
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(agent.last_checkin)}
|
||||
{formatRelativeTime(agent.last_seen)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isOnline(agent.last_checkin) ? 'Online' : 'Offline'}
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
@@ -462,12 +709,20 @@ const Agents: React.FC = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleScanAgent(agent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
disabled={scanAgentMutation.isPending}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="Trigger scan"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
|
||||
disabled={unregisterAgentMutation.isPending}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
title="Remove agent"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
|
||||
@@ -7,17 +7,14 @@ import {
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { useDashboardStats } from '@/hooks/useStats';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { data: stats, isLoading, error } = useDashboardStats();
|
||||
const { data: stats, isPending, error } = useDashboardStats();
|
||||
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="animate-pulse">
|
||||
|
||||
@@ -1,32 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
import { useTimezones, useTimezone, useUpdateTimezone } from '../hooks/useSettings';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
|
||||
|
||||
const { data: timezones, isLoading: isLoadingTimezones } = useTimezones();
|
||||
const { data: currentTimezone, isLoading: isLoadingCurrentTimezone } = useTimezone();
|
||||
const updateTimezone = useUpdateTimezone();
|
||||
|
||||
const [selectedTimezone, setSelectedTimezone] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentTimezone?.timezone) {
|
||||
setSelectedTimezone(currentTimezone.timezone);
|
||||
}
|
||||
}, [currentTimezone]);
|
||||
|
||||
const handleTimezoneChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newTimezone = e.target.value;
|
||||
setSelectedTimezone(newTimezone);
|
||||
|
||||
try {
|
||||
await updateTimezone.mutateAsync(newTimezone);
|
||||
} catch (error) {
|
||||
console.error('Failed to update timezone:', error);
|
||||
// Revert on error
|
||||
if (currentTimezone?.timezone) {
|
||||
setSelectedTimezone(currentTimezone.timezone);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<div className="px-4 sm:px-6 lg:px-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Configure your dashboard preferences
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-600">Configure your RedFlag dashboard preferences</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Dashboard Settings</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Configure how the dashboard behaves and displays information
|
||||
</p>
|
||||
{/* Timezone Settings */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Timezone Settings</h2>
|
||||
<p className="text-gray-600">Configure the timezone used for displaying timestamps</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Display Timezone
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="timezone"
|
||||
value={selectedTimezone}
|
||||
onChange={handleTimezoneChange}
|
||||
disabled={isLoadingTimezones || isLoadingCurrentTimezone || updateTimezone.isPending}
|
||||
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoadingTimezones ? (
|
||||
<option>Loading timezones...</option>
|
||||
) : (
|
||||
timezones?.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
|
||||
{/* Custom dropdown arrow */}
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateTimezone.isPending && (
|
||||
<p className="mt-2 text-sm text-yellow-600">Updating timezone...</p>
|
||||
)}
|
||||
|
||||
{updateTimezone.isSuccess && (
|
||||
<p className="mt-2 text-sm text-green-600">Timezone updated successfully!</p>
|
||||
)}
|
||||
|
||||
{updateTimezone.isError && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
Failed to update timezone. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
This setting affects how timestamps are displayed throughout the dashboard, including agent
|
||||
last check-in times, scan times, and update timestamps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Settings */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Dashboard Settings</h2>
|
||||
<p className="text-gray-600">Configure how the dashboard behaves and displays information</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Auto Refresh */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Auto Refresh</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-gray-600">
|
||||
Automatically refresh dashboard data at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
@@ -51,7 +150,7 @@ const Settings: React.FC = () => {
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
disabled={!autoRefresh}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value={10000}>10 seconds</option>
|
||||
<option value={30000}>30 seconds</option>
|
||||
@@ -65,6 +164,26 @@ const Settings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Future Settings Sections */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 opacity-60">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-400">Additional Settings</h2>
|
||||
<p className="text-gray-500">More configuration options coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm text-gray-500">
|
||||
<div>• Notification preferences</div>
|
||||
<div>• Agent monitoring settings</div>
|
||||
<div>• Data retention policies</div>
|
||||
<div>• API access tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,23 +4,23 @@ import {
|
||||
Package,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Computer,
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates';
|
||||
import { UpdatePackage } from '@/types';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils';
|
||||
import { useUpdateStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
|
||||
const Updates: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -34,6 +34,8 @@ const Updates: React.FC = () => {
|
||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get('page') || '1'));
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
|
||||
// Store filters in URL
|
||||
useEffect(() => {
|
||||
@@ -43,20 +45,24 @@ const Updates: React.FC = () => {
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
if (agentFilter) params.set('agent', agentFilter);
|
||||
if (currentPage > 1) params.set('page', currentPage.toString());
|
||||
if (pageSize !== 100) params.set('page_size', pageSize.toString());
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
if (newUrl !== window.location.href) {
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter]);
|
||||
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]);
|
||||
|
||||
// Fetch updates list
|
||||
const { data: updatesData, isLoading, error } = useUpdates({
|
||||
const { data: updatesData, isPending, error } = useUpdates({
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
severity: severityFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
agent_id: agentFilter || undefined,
|
||||
agent: agentFilter || undefined,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
|
||||
// Fetch single update if ID is provided
|
||||
@@ -68,7 +74,13 @@ const Updates: React.FC = () => {
|
||||
const bulkApproveMutation = useApproveMultipleUpdates();
|
||||
|
||||
const updates = updatesData?.updates || [];
|
||||
const selectedUpdate = selectedUpdateData || updates.find(u => u.id === id);
|
||||
const totalCount = updatesData?.total || 0;
|
||||
const selectedUpdate = selectedUpdateData || updates.find((u: UpdatePackage) => u.id === id);
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const hasNextPage = currentPage < totalPages;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
|
||||
// Handle update selection
|
||||
const handleSelectUpdate = (updateId: string, checked: boolean) => {
|
||||
@@ -81,7 +93,7 @@ const Updates: React.FC = () => {
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUpdates(updates.map(update => update.id));
|
||||
setSelectedUpdates(updates.map((update: UpdatePackage) => update.id));
|
||||
} else {
|
||||
setSelectedUpdates([]);
|
||||
}
|
||||
@@ -127,10 +139,74 @@ const Updates: React.FC = () => {
|
||||
};
|
||||
|
||||
// Get unique values for filters
|
||||
const statuses = [...new Set(updates.map(u => u.status))];
|
||||
const severities = [...new Set(updates.map(u => u.severity))];
|
||||
const types = [...new Set(updates.map(u => u.package_type))];
|
||||
const agents = [...new Set(updates.map(u => u.agent_id))];
|
||||
const statuses = [...new Set(updates.map((u: UpdatePackage) => u.status))];
|
||||
const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))];
|
||||
const types = [...new Set(updates.map((u: UpdatePackage) => u.package_type))];
|
||||
const agents = [...new Set(updates.map((u: UpdatePackage) => u.agent_id))];
|
||||
|
||||
// Quick filter functions
|
||||
const handleQuickFilter = (filter: string) => {
|
||||
switch (filter) {
|
||||
case 'critical':
|
||||
setSeverityFilter('critical');
|
||||
setStatusFilter('pending');
|
||||
break;
|
||||
case 'pending':
|
||||
setStatusFilter('pending');
|
||||
setSeverityFilter('');
|
||||
break;
|
||||
case 'approved':
|
||||
setStatusFilter('approved');
|
||||
setSeverityFilter('');
|
||||
break;
|
||||
default:
|
||||
// Clear all filters
|
||||
setStatusFilter('');
|
||||
setSeverityFilter('');
|
||||
setTypeFilter('');
|
||||
setAgentFilter('');
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Group updates
|
||||
const groupUpdates = (updates: UpdatePackage[], groupBy: string) => {
|
||||
const groups: Record<string, UpdatePackage[]> = {};
|
||||
|
||||
updates.forEach(update => {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case 'severity':
|
||||
key = update.severity;
|
||||
break;
|
||||
case 'type':
|
||||
key = update.package_type;
|
||||
break;
|
||||
case 'status':
|
||||
key = update.status;
|
||||
break;
|
||||
default:
|
||||
key = 'all';
|
||||
}
|
||||
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(update);
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
// Get total statistics from API (not just current page)
|
||||
const totalStats = {
|
||||
total: totalCount,
|
||||
pending: updatesData?.stats?.pending_updates || 0,
|
||||
approved: updatesData?.stats?.approved_updates || 0,
|
||||
critical: updatesData?.stats?.critical_updates || 0,
|
||||
high: updatesData?.stats?.high_updates || 0,
|
||||
};
|
||||
|
||||
// Update detail view
|
||||
if (id && selectedUpdate) {
|
||||
@@ -246,7 +322,7 @@ const Updates: React.FC = () => {
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApproveUpdate(selectedUpdate.id)}
|
||||
disabled={approveMutation.isLoading}
|
||||
disabled={approveMutation.isPending}
|
||||
className="w-full btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
@@ -255,7 +331,7 @@ const Updates: React.FC = () => {
|
||||
|
||||
<button
|
||||
onClick={() => handleRejectUpdate(selectedUpdate.id)}
|
||||
disabled={rejectMutation.isLoading}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="w-full btn btn-secondary"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
@@ -267,7 +343,7 @@ const Updates: React.FC = () => {
|
||||
{selectedUpdate.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleInstallUpdate(selectedUpdate.id)}
|
||||
disabled={installMutation.isLoading}
|
||||
disabled={installMutation.isPending}
|
||||
className="w-full btn btn-primary"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
@@ -290,15 +366,150 @@ const Updates: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Pagination handlers
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(1); // Reset to first page when changing page size
|
||||
};
|
||||
|
||||
// Updates list view
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Updates</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Review and approve available updates for your agents
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Updates</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Review and approve available updates for your agents
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {updates.length} of {totalCount} updates
|
||||
</div>
|
||||
{totalCount > 100 && (
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||
className="mt-1 text-sm border border-gray-300 rounded px-3 py-1"
|
||||
>
|
||||
<option value={50}>50 per page</option>
|
||||
<option value={100}>100 per page</option>
|
||||
<option value={200}>200 per page</option>
|
||||
<option value={500}>500 per page</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards - Show total counts across all updates */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 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 Updates</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalStats.total}</p>
|
||||
</div>
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-orange-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{totalStats.pending}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-orange-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">Approved</p>
|
||||
<p className="text-2xl font-bold text-green-600">{totalStats.approved}</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">Critical</p>
|
||||
<p className="text-2xl font-bold text-red-600">{totalStats.critical}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-yellow-200 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">High Priority</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{totalStats.high}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => handleQuickFilter('all')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
!statusFilter && !severityFilter && !typeFilter && !agentFilter
|
||||
? "bg-primary-100 border-primary-300 text-primary-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
All Updates
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('critical')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'pending' && severityFilter === 'critical'
|
||||
? "bg-red-100 border-red-300 text-red-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-1 inline" />
|
||||
Critical
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('pending')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'pending' && !severityFilter
|
||||
? "bg-orange-100 border-orange-300 text-orange-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1 inline" />
|
||||
Pending Approval
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('approved')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
statusFilter === 'approved' && !severityFilter
|
||||
? "bg-green-100 border-green-300 text-green-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1 inline" />
|
||||
Approved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
@@ -336,7 +547,7 @@ const Updates: React.FC = () => {
|
||||
{selectedUpdates.length > 0 && (
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={bulkApproveMutation.isLoading}
|
||||
disabled={bulkApproveMutation.isPending}
|
||||
className="btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
@@ -359,7 +570,7 @@ const Updates: React.FC = () => {
|
||||
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 Status</option>
|
||||
{statuses.map(status => (
|
||||
{statuses.map((status: string) => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -375,7 +586,7 @@ const Updates: React.FC = () => {
|
||||
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 Severities</option>
|
||||
{severities.map(severity => (
|
||||
{severities.map((severity: string) => (
|
||||
<option key={severity} value={severity}>{severity}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -391,7 +602,7 @@ const Updates: React.FC = () => {
|
||||
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 Types</option>
|
||||
{types.map(type => (
|
||||
{types.map((type: string) => (
|
||||
<option key={type} value={type}>{type.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -407,7 +618,7 @@ const Updates: React.FC = () => {
|
||||
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 Agents</option>
|
||||
{agents.map(agentId => (
|
||||
{agents.map((agentId: string) => (
|
||||
<option key={agentId} value={agentId}>{agentId}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -418,7 +629,7 @@ const Updates: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Updates table */}
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
@@ -469,7 +680,7 @@ const Updates: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{updates.map((update) => (
|
||||
{updates.map((update: UpdatePackage) => (
|
||||
<tr key={update.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
@@ -540,7 +751,7 @@ const Updates: React.FC = () => {
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApproveUpdate(update.id)}
|
||||
disabled={approveMutation.isLoading}
|
||||
disabled={approveMutation.isPending}
|
||||
className="text-success-600 hover:text-success-800"
|
||||
title="Approve"
|
||||
>
|
||||
@@ -548,7 +759,7 @@ const Updates: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectUpdate(update.id)}
|
||||
disabled={rejectMutation.isLoading}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
title="Reject"
|
||||
>
|
||||
@@ -560,7 +771,7 @@ const Updates: React.FC = () => {
|
||||
{update.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleInstallUpdate(update.id)}
|
||||
disabled={installMutation.isLoading}
|
||||
disabled={installMutation.isPending}
|
||||
className="text-primary-600 hover:text-primary-800"
|
||||
title="Install"
|
||||
>
|
||||
@@ -582,6 +793,88 @@ const Updates: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={!hasPrevPage}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={!hasNextPage}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{' '}
|
||||
<span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span> of{' '}
|
||||
<span className="font-medium">{totalCount}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={!hasPrevPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 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;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
currentPage === pageNum
|
||||
? 'z-10 bg-primary-50 border-primary-500 text-primary-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={!hasNextPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,14 +11,18 @@ export interface Agent {
|
||||
hostname: string;
|
||||
os_type: string;
|
||||
os_version: string;
|
||||
architecture: string;
|
||||
status: 'online' | 'offline';
|
||||
last_checkin: string;
|
||||
os_architecture: string;
|
||||
architecture: string; // For backward compatibility
|
||||
agent_version: string;
|
||||
version: string; // For backward compatibility
|
||||
last_seen: string;
|
||||
last_checkin: string; // For backward compatibility
|
||||
last_scan: string | null;
|
||||
status: 'online' | 'offline';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: string;
|
||||
ip_address: string;
|
||||
metadata?: Record<string, any>;
|
||||
// Note: ip_address not available from API yet
|
||||
}
|
||||
|
||||
export interface AgentSpec {
|
||||
@@ -61,6 +65,97 @@ export interface DockerUpdateInfo {
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
// Docker-specific types for dedicated Docker module
|
||||
export interface DockerContainer {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
name: string;
|
||||
image_id: string;
|
||||
image_name: string;
|
||||
image_tag: string;
|
||||
status: 'running' | 'stopped' | 'paused' | 'restarting' | 'removing' | 'exited' | 'dead';
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
ports: DockerPort[];
|
||||
volumes: DockerVolume[];
|
||||
labels: Record<string, string>;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DockerImage {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
repository: string;
|
||||
tag: string;
|
||||
digest: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
last_pulled: string | null;
|
||||
update_available: boolean;
|
||||
current_version: string;
|
||||
available_version: string | null;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'up-to-date' | 'update-available' | 'update-approved' | 'update-scheduled' | 'update-installing' | 'update-failed';
|
||||
update_approved_at: string | null;
|
||||
update_scheduled_at: string | null;
|
||||
update_installed_at: string | null;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DockerPort {
|
||||
container_port: number;
|
||||
host_port: number | null;
|
||||
protocol: 'tcp' | 'udp';
|
||||
host_ip: string;
|
||||
}
|
||||
|
||||
export interface DockerVolume {
|
||||
name: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
mode: 'ro' | 'rw';
|
||||
driver: string;
|
||||
}
|
||||
|
||||
// Docker API response types
|
||||
export interface DockerContainerListResponse {
|
||||
containers: DockerContainer[];
|
||||
images: DockerImage[];
|
||||
total_containers: number;
|
||||
total_images: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
total_containers: number;
|
||||
running_containers: number;
|
||||
stopped_containers: number;
|
||||
total_images: number;
|
||||
images_with_updates: number;
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
medium_updates: number;
|
||||
low_updates: number;
|
||||
agents_with_docker: number;
|
||||
total_storage_used: number;
|
||||
}
|
||||
|
||||
// Docker action types
|
||||
export interface DockerUpdateRequest {
|
||||
image_id: string;
|
||||
scheduled_at?: string;
|
||||
}
|
||||
|
||||
export interface BulkDockerUpdateRequest {
|
||||
updates: Array<{
|
||||
container_id: string;
|
||||
image_id: string;
|
||||
}>;
|
||||
scheduled_at?: string;
|
||||
}
|
||||
|
||||
export interface AptUpdateInfo {
|
||||
package_name: string;
|
||||
current_version: string;
|
||||
@@ -122,6 +217,22 @@ export interface AgentListResponse {
|
||||
export interface UpdateListResponse {
|
||||
updates: UpdatePackage[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
stats?: UpdateStats;
|
||||
}
|
||||
|
||||
export interface UpdateStats {
|
||||
total_updates: number;
|
||||
pending_updates: number;
|
||||
approved_updates: number;
|
||||
updated_updates: number;
|
||||
failed_updates: number;
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
important_updates: number;
|
||||
moderate_updates: number;
|
||||
low_updates: number;
|
||||
}
|
||||
|
||||
export interface UpdateApprovalRequest {
|
||||
@@ -142,6 +253,7 @@ export interface ListQueryParams {
|
||||
severity?: string;
|
||||
type?: string;
|
||||
search?: string;
|
||||
agent?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
10
aggregator-web/src/vite-env.d.ts
vendored
Normal file
10
aggregator-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user