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:
Fimeg
2025-10-16 09:16:05 -04:00
parent a7fad61de2
commit 61294ba514
36 changed files with 3088 additions and 443 deletions

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 5 * 60 * 1000, // 5 minutes
staleTime: 10 * 1000, // 10 seconds
},
},
})

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}