Session 4 complete - RedFlag update management platform
🚩 Private development - version retention only ✅ Complete web dashboard (React + TypeScript + TailwindCSS) ✅ Production-ready server backend (Go + Gin + PostgreSQL) ✅ Linux agent with APT + Docker scanning + local CLI tools ✅ JWT authentication and REST API ✅ Update discovery and approval workflow 🚧 Status: Alpha software - active development 📦 Purpose: Version retention during development ⚠️ Not for public use or deployment
This commit is contained in:
5
aggregator-web/.env.example
Normal file
5
aggregator-web/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:8080/api/v1
|
||||
|
||||
# Environment
|
||||
VITE_NODE_ENV=development
|
||||
40
aggregator-web/package.json
Normal file
40
aggregator-web/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "aggregator-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
6
aggregator-web/postcss.config.js
Normal file
6
aggregator-web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
112
aggregator-web/src/App.tsx
Normal file
112
aggregator-web/src/App.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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 Layout from '@/components/Layout';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import Agents from '@/pages/Agents';
|
||||
import Updates from '@/pages/Updates';
|
||||
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 }) => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { isAuthenticated, token } = useAuthStore();
|
||||
const { theme } = useSettingsStore();
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Check for existing token on app start
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('auth_token');
|
||||
if (storedToken && !token) {
|
||||
useAuthStore.getState().setToken(storedToken);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-gray-50 ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
{/* Toast notifications */}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: theme === 'dark' ? '#374151' : '#ffffff',
|
||||
color: theme === 'dark' ? '#ffffff' : '#000000',
|
||||
border: '1px solid',
|
||||
borderColor: theme === 'dark' ? '#4b5563' : '#e5e7eb',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#22c55e',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Notification center */}
|
||||
{isAuthenticated && <NotificationCenter />}
|
||||
|
||||
{/* App routes */}
|
||||
<Routes>
|
||||
{/* Login route */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/agents" element={<Agents />} />
|
||||
<Route path="/agents/:id" element={<Agents />} />
|
||||
<Route path="/updates" element={<Updates />} />
|
||||
<Route path="/updates/:id" element={<Updates />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
214
aggregator-web/src/components/Layout.tsx
Normal file
214
aggregator-web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Computer,
|
||||
Package,
|
||||
FileText,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
Bell,
|
||||
Search,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { sidebarOpen, setSidebarOpen, setActiveTab } = useUIStore();
|
||||
const { logout } = useAuthStore();
|
||||
const { notifications } = useRealtimeStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
current: location.pathname === '/' || location.pathname === '/dashboard',
|
||||
},
|
||||
{
|
||||
name: 'Agents',
|
||||
href: '/agents',
|
||||
icon: Computer,
|
||||
current: location.pathname.startsWith('/agents'),
|
||||
},
|
||||
{
|
||||
name: 'Updates',
|
||||
href: '/updates',
|
||||
icon: Package,
|
||||
current: location.pathname.startsWith('/updates'),
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
href: '/logs',
|
||||
icon: FileText,
|
||||
current: location.pathname === '/logs',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
current: location.pathname === '/settings',
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
localStorage.removeItem('auth_token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
// Navigate to updates page with search query
|
||||
navigate(`/updates?search=${encodeURIComponent(searchQuery.trim())}`);
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">🚩</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">RedFlag</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-6 px-3">
|
||||
<div className="space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
onClick={() => setActiveTab(item.name)}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
item.current
|
||||
? 'bg-primary-50 text-primary-700 border-r-2 border-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'mr-3 h-5 w-5 flex-shrink-0',
|
||||
item.current ? 'text-primary-700' : 'text-gray-400 group-hover:text-gray-500'
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col lg:pl-0">
|
||||
{/* 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">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="hidden md:block">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search 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"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
128
aggregator-web/src/components/NotificationCenter.tsx
Normal file
128
aggregator-web/src/components/NotificationCenter.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, X, Check, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { useRealtimeStore } from '@/lib/store';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
const NotificationCenter: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-5 h-5 text-success-600" />;
|
||||
case 'error':
|
||||
return <XCircle className="w-5 h-5 text-danger-600" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="w-5 h-5 text-warning-600" />;
|
||||
default:
|
||||
return <Info className="w-5 h-5 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'border-success-200 bg-success-50';
|
||||
case 'error':
|
||||
return 'border-danger-200 bg-danger-50';
|
||||
case 'warning':
|
||||
return 'border-warning-200 bg-warning-50';
|
||||
default:
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
{/* Notification bell */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<Bell className="w-5 h-5 text-gray-600" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-danger-600 text-white text-xs rounded-full flex items-center justify-center">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-12 right-0 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden">
|
||||
{/* 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"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</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)}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
99
aggregator-web/src/hooks/useAgents.ts
Normal file
99
aggregator-web/src/hooks/useAgents.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useQuery, useMutation, useQueryClient } 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();
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAgent = (id: string, enabled: boolean = true) => {
|
||||
const { setSelectedAgent, setLoading, setError } = useAgentStore();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
16
aggregator-web/src/hooks/useStats.ts
Normal file
16
aggregator-web/src/hooks/useStats.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { statsApi } from '@/lib/api';
|
||||
import { DashboardStats } from '@/types';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
|
||||
export const useDashboardStats = () => {
|
||||
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));
|
||||
},
|
||||
});
|
||||
};
|
||||
173
aggregator-web/src/hooks/useUpdates.ts
Normal file
173
aggregator-web/src/hooks/useUpdates.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useQuery, useMutation, useQueryClient } 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();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
114
aggregator-web/src/index.css
Normal file
114
aggregator-web/src/index.css
Normal file
@@ -0,0 +1,114 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 font-sans antialiased;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply badge bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply badge bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply badge bg-danger-100 text-danger-800;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply badge bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply min-w-full divide-y divide-gray-200;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply bg-gray-50 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply bg-white hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
@apply bg-gray-900 text-green-400 font-mono text-sm p-4 rounded-lg overflow-x-auto;
|
||||
}
|
||||
|
||||
.terminal-prompt {
|
||||
@apply text-blue-400;
|
||||
}
|
||||
|
||||
.terminal-command {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
@apply text-gray-300;
|
||||
}
|
||||
|
||||
.hierarchy-tree {
|
||||
@apply border-l-2 border-gray-200 ml-4 pl-4;
|
||||
}
|
||||
|
||||
.hierarchy-item {
|
||||
@apply flex items-center space-x-2 py-1;
|
||||
}
|
||||
|
||||
.hierarchy-toggle {
|
||||
@apply w-4 h-4 text-gray-500 hover:text-gray-700 cursor-pointer;
|
||||
}
|
||||
}
|
||||
201
aggregator-web/src/lib/api.ts
Normal file
201
aggregator-web/src/lib/api.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
Agent,
|
||||
UpdatePackage,
|
||||
DashboardStats,
|
||||
AgentListResponse,
|
||||
UpdateListResponse,
|
||||
UpdateApprovalRequest,
|
||||
ScanRequest,
|
||||
ListQueryParams,
|
||||
ApiResponse,
|
||||
ApiError
|
||||
} from '@/types';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor to handle errors
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear token and redirect to login
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// API endpoints
|
||||
export const agentApi = {
|
||||
// Get all agents
|
||||
getAgents: async (params?: ListQueryParams): Promise<AgentListResponse> => {
|
||||
const response = await api.get('/agents', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single agent
|
||||
getAgent: async (id: string): Promise<Agent> => {
|
||||
const response = await api.get(`/agents/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger scan on agents
|
||||
triggerScan: async (request: ScanRequest): Promise<void> => {
|
||||
await api.post('/agents/scan', request);
|
||||
},
|
||||
|
||||
// Trigger scan on single agent
|
||||
scanAgent: async (id: string): Promise<void> => {
|
||||
await api.post(`/agents/${id}/scan`);
|
||||
},
|
||||
};
|
||||
|
||||
export const updateApi = {
|
||||
// Get all updates
|
||||
getUpdates: async (params?: ListQueryParams): Promise<UpdateListResponse> => {
|
||||
const response = await api.get('/updates', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single update
|
||||
getUpdate: async (id: string): Promise<UpdatePackage> => {
|
||||
const response = await api.get(`/updates/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Approve updates
|
||||
approveUpdates: async (request: UpdateApprovalRequest): Promise<void> => {
|
||||
await api.post('/updates/approve', request);
|
||||
},
|
||||
|
||||
// Approve single update
|
||||
approveUpdate: async (id: string, scheduledAt?: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/approve`, { scheduled_at: scheduledAt });
|
||||
},
|
||||
|
||||
// Reject/cancel update
|
||||
rejectUpdate: async (id: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/reject`);
|
||||
},
|
||||
|
||||
// Install update immediately
|
||||
installUpdate: async (id: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/install`);
|
||||
},
|
||||
};
|
||||
|
||||
export const statsApi = {
|
||||
// Get dashboard statistics
|
||||
getDashboardStats: async (): Promise<DashboardStats> => {
|
||||
const response = await api.get('/stats/summary');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
// Simple login (using API key or token)
|
||||
login: async (credentials: { token: string }): Promise<{ token: string }> => {
|
||||
const response = await api.post('/auth/login', credentials);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Verify token
|
||||
verifyToken: async (): Promise<{ valid: boolean }> => {
|
||||
const response = await api.get('/auth/verify');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: async (): Promise<void> => {
|
||||
await api.post('/auth/logout');
|
||||
},
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
export const createQueryString = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => searchParams.append(key, v));
|
||||
} else {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
return searchParams.toString();
|
||||
};
|
||||
|
||||
// Error handling utility
|
||||
export const handleApiError = (error: any): ApiError => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
|
||||
if (status === 401) {
|
||||
return {
|
||||
message: 'Authentication required. Please log in.',
|
||||
code: 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return {
|
||||
message: 'Access denied. You do not have permission to perform this action.',
|
||||
code: 'FORBIDDEN',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
message: 'The requested resource was not found.',
|
||||
code: 'NOT_FOUND',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return {
|
||||
message: 'Too many requests. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return {
|
||||
message: 'Server error. Please try again later.',
|
||||
code: 'SERVER_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: data?.message || error.message || 'An error occurred',
|
||||
code: data?.code || 'UNKNOWN_ERROR',
|
||||
details: data?.details,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
code: 'UNKNOWN_ERROR',
|
||||
};
|
||||
};
|
||||
|
||||
export default api;
|
||||
241
aggregator-web/src/lib/store.ts
Normal file
241
aggregator-web/src/lib/store.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { Agent, UpdatePackage, FilterState } from '@/types';
|
||||
|
||||
// Auth store
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setToken: (token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
setToken: (token) => set({ token, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, isAuthenticated: false }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({ token: state.token, isAuthenticated: state.isAuthenticated }),
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// UI store for global state
|
||||
interface UIState {
|
||||
sidebarOpen: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
activeTab: string;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarOpen: true,
|
||||
theme: 'light',
|
||||
activeTab: 'dashboard',
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{
|
||||
name: 'ui-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Agent store
|
||||
interface AgentState {
|
||||
agents: Agent[];
|
||||
selectedAgent: Agent | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setAgents: (agents: Agent[]) => void;
|
||||
setSelectedAgent: (agent: Agent | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
updateAgentStatus: (agentId: string, status: Agent['status'], lastCheckin: string) => void;
|
||||
addAgent: (agent: Agent) => void;
|
||||
removeAgent: (agentId: string) => void;
|
||||
}
|
||||
|
||||
export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
agents: [],
|
||||
selectedAgent: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
updateAgentStatus: (agentId, status, lastCheckin) => {
|
||||
const { agents } = get();
|
||||
const updatedAgents = agents.map(agent =>
|
||||
agent.id === agentId
|
||||
? { ...agent, status, last_checkin: lastCheckin }
|
||||
: agent
|
||||
);
|
||||
set({ agents: updatedAgents });
|
||||
},
|
||||
|
||||
addAgent: (agent) => {
|
||||
const { agents } = get();
|
||||
set({ agents: [...agents, agent] });
|
||||
},
|
||||
|
||||
removeAgent: (agentId) => {
|
||||
const { agents } = get();
|
||||
set({ agents: agents.filter(agent => agent.id !== agentId) });
|
||||
},
|
||||
}));
|
||||
|
||||
// Updates store
|
||||
interface UpdateState {
|
||||
updates: UpdatePackage[];
|
||||
selectedUpdate: UpdatePackage | null;
|
||||
filters: FilterState;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setUpdates: (updates: UpdatePackage[]) => void;
|
||||
setSelectedUpdate: (update: UpdatePackage | null) => void;
|
||||
setFilters: (filters: Partial<FilterState>) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
updateUpdateStatus: (updateId: string, status: UpdatePackage['status']) => void;
|
||||
bulkUpdateStatus: (updateIds: string[], status: UpdatePackage['status']) => void;
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
updates: [],
|
||||
selectedUpdate: null,
|
||||
filters: {
|
||||
status: [],
|
||||
severity: [],
|
||||
type: [],
|
||||
search: '',
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
setUpdates: (updates) => set({ updates }),
|
||||
setSelectedUpdate: (update) => set({ selectedUpdate: update }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
setFilters: (newFilters) => {
|
||||
const { filters } = get();
|
||||
set({ filters: { ...filters, ...newFilters } });
|
||||
},
|
||||
|
||||
updateUpdateStatus: (updateId, status) => {
|
||||
const { updates } = get();
|
||||
const updatedUpdates = updates.map(update =>
|
||||
update.id === updateId
|
||||
? { ...update, status, updated_at: new Date().toISOString() }
|
||||
: update
|
||||
);
|
||||
set({ updates: updatedUpdates });
|
||||
},
|
||||
|
||||
bulkUpdateStatus: (updateIds, status) => {
|
||||
const { updates } = get();
|
||||
const updatedUpdates = updates.map(update =>
|
||||
updateIds.includes(update.id)
|
||||
? { ...update, status, updated_at: new Date().toISOString() }
|
||||
: update
|
||||
);
|
||||
set({ updates: updatedUpdates });
|
||||
},
|
||||
}));
|
||||
|
||||
// Real-time updates store
|
||||
interface RealtimeState {
|
||||
isConnected: boolean;
|
||||
lastUpdate: string | null;
|
||||
notifications: Array<{
|
||||
id: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
}>;
|
||||
setConnected: (connected: boolean) => void;
|
||||
setLastUpdate: (timestamp: string) => void;
|
||||
addNotification: (notification: Omit<typeof RealtimeState.prototype.notifications[0], 'id' | 'timestamp' | 'read'>) => void;
|
||||
markNotificationRead: (id: string) => void;
|
||||
clearNotifications: () => void;
|
||||
}
|
||||
|
||||
export const useRealtimeStore = create<RealtimeState>((set, get) => ({
|
||||
isConnected: false,
|
||||
lastUpdate: null,
|
||||
notifications: [],
|
||||
|
||||
setConnected: (isConnected) => set({ isConnected }),
|
||||
setLastUpdate: (lastUpdate) => set({ lastUpdate }),
|
||||
|
||||
addNotification: (notification) => {
|
||||
const { notifications } = get();
|
||||
const newNotification = {
|
||||
...notification,
|
||||
id: Math.random().toString(36).substring(7),
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
};
|
||||
set({ notifications: [newNotification, ...notifications] });
|
||||
},
|
||||
|
||||
markNotificationRead: (id) => {
|
||||
const { notifications } = get();
|
||||
const updatedNotifications = notifications.map(notification =>
|
||||
notification.id === id ? { ...notification, read: true } : notification
|
||||
);
|
||||
set({ notifications: updatedNotifications });
|
||||
},
|
||||
|
||||
clearNotifications: () => set({ notifications: [] }),
|
||||
}));
|
||||
|
||||
// Settings store
|
||||
interface SettingsState {
|
||||
autoRefresh: boolean;
|
||||
refreshInterval: number;
|
||||
notificationsEnabled: boolean;
|
||||
compactView: boolean;
|
||||
setAutoRefresh: (enabled: boolean) => void;
|
||||
setRefreshInterval: (interval: number) => void;
|
||||
setNotificationsEnabled: (enabled: boolean) => void;
|
||||
setCompactView: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
autoRefresh: true,
|
||||
refreshInterval: 30000, // 30 seconds
|
||||
notificationsEnabled: true,
|
||||
compactView: false,
|
||||
|
||||
setAutoRefresh: (autoRefresh) => set({ autoRefresh }),
|
||||
setRefreshInterval: (refreshInterval) => set({ refreshInterval }),
|
||||
setNotificationsEnabled: (notificationsEnabled) => set({ notificationsEnabled }),
|
||||
setCompactView: (compactView) => set({ compactView }),
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
247
aggregator-web/src/lib/utils.ts
Normal file
247
aggregator-web/src/lib/utils.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
// Utility function for combining class names
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Date formatting utilities
|
||||
export const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffMins < 60) {
|
||||
return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return formatDate(dateString);
|
||||
}
|
||||
};
|
||||
|
||||
export const isOnline = (lastCheckin: string): boolean => {
|
||||
const lastCheck = new Date(lastCheckin);
|
||||
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
|
||||
};
|
||||
|
||||
// Size formatting utilities
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Version comparison utilities
|
||||
export const versionCompare = (v1: string, v2: string): number => {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
const maxLength = Math.max(parts1.length, parts2.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const part1 = parts1[i] || 0;
|
||||
const part2 = parts2[i] || 0;
|
||||
|
||||
if (part1 > part2) return 1;
|
||||
if (part1 < part2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Status and severity utilities
|
||||
export const getStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'text-success-600 bg-success-100';
|
||||
case 'offline':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'pending':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'approved':
|
||||
case 'scheduled':
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
case 'installing':
|
||||
return 'text-indigo-600 bg-indigo-100';
|
||||
case 'installed':
|
||||
return 'text-success-600 bg-success-100';
|
||||
case 'failed':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
export const getSeverityColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'high':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'medium':
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
case 'low':
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
export const getPackageTypeIcon = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'apt':
|
||||
return '📦';
|
||||
case 'docker':
|
||||
return '🐳';
|
||||
case 'yum':
|
||||
case 'dnf':
|
||||
return '🐧';
|
||||
case 'windows':
|
||||
return '🪟';
|
||||
case 'winget':
|
||||
return '📱';
|
||||
default:
|
||||
return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and search utilities
|
||||
export const filterUpdates = (
|
||||
updates: any[],
|
||||
filters: {
|
||||
status: string[];
|
||||
severity: string[];
|
||||
type: string[];
|
||||
search: string;
|
||||
}
|
||||
): any[] => {
|
||||
return updates.filter(update => {
|
||||
// Status filter
|
||||
if (filters.status.length > 0 && !filters.status.includes(update.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Severity filter
|
||||
if (filters.severity.length > 0 && !filters.severity.includes(update.severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (filters.type.length > 0 && !filters.type.includes(update.package_type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
return (
|
||||
update.package_name.toLowerCase().includes(searchLower) ||
|
||||
update.current_version.toLowerCase().includes(searchLower) ||
|
||||
update.available_version.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Error handling utilities
|
||||
export const getErrorMessage = (error: any): string => {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error?.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error?.response?.data?.message) {
|
||||
return error.response.data.message;
|
||||
}
|
||||
|
||||
return 'An unexpected error occurred';
|
||||
};
|
||||
|
||||
// Debounce utility
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
};
|
||||
|
||||
// Local storage utilities
|
||||
export const storage = {
|
||||
get: (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set: (key: string, value: string): void => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Silent fail for storage issues
|
||||
}
|
||||
},
|
||||
|
||||
remove: (key: string): void => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Silent fail for storage issues
|
||||
}
|
||||
},
|
||||
|
||||
getJSON: <T = any>(key: string): T | null => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setJSON: (key: string, value: any): void => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Silent fail for storage issues
|
||||
}
|
||||
},
|
||||
};
|
||||
27
aggregator-web/src/main.tsx
Normal file
27
aggregator-web/src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
491
aggregator-web/src/pages/Agents.tsx
Normal file
491
aggregator-web/src/pages/Agents.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Computer,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
Activity,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Globe,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents } from '@/hooks/useAgents';
|
||||
import { Agent } from '@/types';
|
||||
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [osFilter, setOsFilter] = useState<string>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
|
||||
// Fetch agents list
|
||||
const { data: agentsData, isLoading, error } = useAgents({
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
});
|
||||
|
||||
// Fetch single agent if ID is provided
|
||||
const { data: selectedAgentData } = useAgent(id || '', !!id);
|
||||
|
||||
const scanAgentMutation = useScanAgent();
|
||||
const scanMultipleMutation = useScanMultipleAgents();
|
||||
|
||||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
|
||||
// Filter agents based on OS
|
||||
const filteredAgents = agents.filter(agent => {
|
||||
if (osFilter === 'all') return true;
|
||||
return agent.os_type.toLowerCase().includes(osFilter.toLowerCase());
|
||||
});
|
||||
|
||||
// Handle agent selection
|
||||
const handleSelectAgent = (agentId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedAgents([...selectedAgents, agentId]);
|
||||
} else {
|
||||
setSelectedAgents(selectedAgents.filter(id => id !== agentId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedAgents(filteredAgents.map(agent => agent.id));
|
||||
} else {
|
||||
setSelectedAgents([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle scan operations
|
||||
const handleScanAgent = async (agentId: string) => {
|
||||
try {
|
||||
await scanAgentMutation.mutateAsync(agentId);
|
||||
toast.success('Scan triggered successfully');
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanSelected = async () => {
|
||||
if (selectedAgents.length === 0) {
|
||||
toast.error('Please select at least one agent');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await scanMultipleMutation.mutateAsync({ agent_ids: selectedAgents });
|
||||
setSelectedAgents([]);
|
||||
toast.success(`Scan triggered for ${selectedAgents.length} 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))];
|
||||
|
||||
// Agent detail view
|
||||
if (id && selectedAgent) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/agents')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Agents
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{selectedAgent.hostname}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Agent details and system information
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleScanAgent(selectedAgent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanAgentMutation.isLoading ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Agent info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* 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}
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.last_scan
|
||||
? formatRelativeTime(selectedAgent.last_scan)
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System info */}
|
||||
<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="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Operating System</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedAgent.os_type} {selectedAgent.os_version}
|
||||
</p>
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agents 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">Agents</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Monitor and manage your connected agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search agents by hostname..."
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{(statusFilter !== 'all' || osFilter !== 'all') && (
|
||||
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">
|
||||
{[
|
||||
statusFilter !== 'all' ? statusFilter : null,
|
||||
osFilter !== 'all' ? osFilter : null,
|
||||
].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedAgents.length > 0 && (
|
||||
<button
|
||||
onClick={handleScanSelected}
|
||||
disabled={scanMultipleMutation.isLoading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{scanMultipleMutation.isLoading ? (
|
||||
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Selected ({selectedAgents.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Operating System
|
||||
</label>
|
||||
<select
|
||||
value={osFilter}
|
||||
onChange={(e) => setOsFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All OS</option>
|
||||
{osTypes.map(os => (
|
||||
<option key={os} value={os}>{os}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agents table */}
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="p-4 border-b border-gray-200">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-2">Failed to load agents</div>
|
||||
<p className="text-sm text-gray-600">Please check your connection and try again.</p>
|
||||
</div>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Computer className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No agents found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter !== 'all' || osFilter !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No agents have registered with the server yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.length === filteredAgents.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">OS</th>
|
||||
<th className="table-header">Last Check-in</th>
|
||||
<th className="table-header">Last Scan</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredAgents.map((agent) => (
|
||||
<tr key={agent.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.includes(agent.id)}
|
||||
onChange={(e) => handleSelectAgent(agent.id, e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Computer className="h-4 w-4 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{agent.hostname}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.ip_address}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getStatusColor(agent.status))}>
|
||||
{agent.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{agent.os_type}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{agent.architecture}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(agent.last_checkin)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isOnline(agent.last_checkin) ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{agent.last_scan
|
||||
? formatRelativeTime(agent.last_scan)
|
||||
: 'Never'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleScanAgent(agent.id)}
|
||||
disabled={scanAgentMutation.isLoading}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="Trigger scan"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="View details"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Agents;
|
||||
246
aggregator-web/src/pages/Dashboard.tsx
Normal file
246
aggregator-web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Computer,
|
||||
Package,
|
||||
CheckCircle,
|
||||
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();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-8"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center py-12">
|
||||
<XCircle className="mx-auto h-12 w-12 text-danger-500" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Failed to load dashboard</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Unable to fetch statistics from the server.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: 'Total Agents',
|
||||
value: stats.total_agents,
|
||||
icon: Computer,
|
||||
color: 'text-blue-600 bg-blue-100',
|
||||
link: '/agents',
|
||||
},
|
||||
{
|
||||
title: 'Online Agents',
|
||||
value: stats.online_agents,
|
||||
icon: CheckCircle,
|
||||
color: 'text-success-600 bg-success-100',
|
||||
link: '/agents?status=online',
|
||||
},
|
||||
{
|
||||
title: 'Pending Updates',
|
||||
value: stats.pending_updates,
|
||||
icon: Clock,
|
||||
color: 'text-warning-600 bg-warning-100',
|
||||
link: '/updates?status=pending',
|
||||
},
|
||||
{
|
||||
title: 'Failed Updates',
|
||||
value: stats.failed_updates,
|
||||
icon: XCircle,
|
||||
color: 'text-danger-600 bg-danger-100',
|
||||
link: '/updates?status=failed',
|
||||
},
|
||||
];
|
||||
|
||||
const severityBreakdown = [
|
||||
{ label: 'Critical', value: stats.critical_updates, color: 'bg-danger-600' },
|
||||
{ label: 'High', value: stats.high_updates, color: 'bg-warning-600' },
|
||||
{ label: 'Medium', value: stats.medium_updates, color: 'bg-blue-600' },
|
||||
{ label: 'Low', value: stats.low_updates, color: 'bg-gray-600' },
|
||||
];
|
||||
|
||||
const updateTypeBreakdown = Object.entries(stats.updates_by_type).map(([type, count]) => ({
|
||||
type: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
value: count,
|
||||
icon: type === 'apt' ? '📦' : type === 'docker' ? '🐳' : '📋',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Overview of your infrastructure and update status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{statCards.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Link
|
||||
key={stat.title}
|
||||
to={stat.link}
|
||||
className="group block p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 group-hover:text-gray-900">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900">
|
||||
{stat.value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${stat.color}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Severity breakdown */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Update Severity</h2>
|
||||
<AlertTriangle className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{severityBreakdown.some(item => item.value > 0) ? (
|
||||
<div className="space-y-3">
|
||||
{severityBreakdown.map((severity) => (
|
||||
<div key={severity.label} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full ${severity.color}`}></div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{severity.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-900 font-semibold">
|
||||
{severity.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Visual bar chart */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{severityBreakdown.map((severity) => (
|
||||
<div key={severity.label} className="relative">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-gray-600">{severity.label}</span>
|
||||
<span className="text-xs text-gray-900">{severity.value}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${severity.color}`}
|
||||
style={{
|
||||
width: `${stats.pending_updates > 0 ? (severity.value / stats.pending_updates) * 100 : 0}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="mx-auto h-8 w-8 text-success-500" />
|
||||
<p className="mt-2 text-sm text-gray-600">No pending updates</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update type breakdown */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Updates by Type</h2>
|
||||
<Package className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{updateTypeBreakdown.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{updateTypeBreakdown.map((type) => (
|
||||
<div key={type.type} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-2xl">{type.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{type.type}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-900 font-semibold">
|
||||
{type.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Package className="mx-auto h-8 w-8 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">No updates found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="mt-8 bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link
|
||||
to="/agents"
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Computer className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-medium text-gray-700">View All Agents</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/updates"
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Package className="h-5 w-5 text-warning-600" />
|
||||
<span className="text-sm font-medium text-gray-700">Manage Updates</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium text-gray-700">Refresh Data</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
132
aggregator-web/src/pages/Login.tsx
Normal file
132
aggregator-web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Shield } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setToken } = useAuthStore();
|
||||
const [token, setTokenInput] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token.trim()) {
|
||||
toast.error('Please enter your authentication token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.login({ token: token.trim() });
|
||||
setToken(response.token);
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
toast.success('Login successful');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
const apiError = handleApiError(error);
|
||||
toast.error(apiError.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-primary-600 rounded-xl flex items-center justify-center">
|
||||
<span className="text-white font-bold text-2xl">🚩</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to RedFlag
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Enter your authentication token to access the dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-gray-700">
|
||||
Authentication Token
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Enter your JWT token"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-6">
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Shield className="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">How to get your token:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
||||
<li>Check your RedFlag server configuration</li>
|
||||
<li>Look for the JWT secret in your server settings</li>
|
||||
<li>Generate a token using the server CLI</li>
|
||||
<li>Contact your administrator if you need access</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
RedFlag is a self-hosted update management platform
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
24
aggregator-web/src/pages/Logs.tsx
Normal file
24
aggregator-web/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Logs</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
View system logs and update history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center">
|
||||
<div className="text-gray-400 mb-2">📋</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Coming Soon</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Logs and history tracking will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
72
aggregator-web/src/pages/Settings.tsx
Normal file
72
aggregator-web/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="p-6 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">
|
||||
Automatically refresh dashboard data at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
autoRefresh ? 'bg-primary-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
autoRefresh ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Refresh Interval */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Refresh Interval</h3>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value={10000}>10 seconds</option>
|
||||
<option value={30000}>30 seconds</option>
|
||||
<option value={60000}>1 minute</option>
|
||||
<option value={300000}>5 minutes</option>
|
||||
<option value={600000}>10 minutes</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
How often to refresh dashboard data when auto-refresh is enabled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
591
aggregator-web/src/pages/Updates.tsx
Normal file
591
aggregator-web/src/pages/Updates.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Package,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Computer,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates';
|
||||
import { 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();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Get filters from URL params
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
|
||||
// Store filters in URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
if (agentFilter) params.set('agent', agentFilter);
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
if (newUrl !== window.location.href) {
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter]);
|
||||
|
||||
// Fetch updates list
|
||||
const { data: updatesData, isLoading, error } = useUpdates({
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
severity: severityFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
agent_id: agentFilter || undefined,
|
||||
});
|
||||
|
||||
// Fetch single update if ID is provided
|
||||
const { data: selectedUpdateData } = useUpdate(id || '', !!id);
|
||||
|
||||
const approveMutation = useApproveUpdate();
|
||||
const rejectMutation = useRejectUpdate();
|
||||
const installMutation = useInstallUpdate();
|
||||
const bulkApproveMutation = useApproveMultipleUpdates();
|
||||
|
||||
const updates = updatesData?.updates || [];
|
||||
const selectedUpdate = selectedUpdateData || updates.find(u => u.id === id);
|
||||
|
||||
// Handle update selection
|
||||
const handleSelectUpdate = (updateId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUpdates([...selectedUpdates, updateId]);
|
||||
} else {
|
||||
setSelectedUpdates(selectedUpdates.filter(id => id !== updateId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUpdates(updates.map(update => update.id));
|
||||
} else {
|
||||
setSelectedUpdates([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle update actions
|
||||
const handleApproveUpdate = async (updateId: string) => {
|
||||
try {
|
||||
await approveMutation.mutateAsync({ id: updateId });
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectUpdate = async (updateId: string) => {
|
||||
try {
|
||||
await rejectMutation.mutateAsync(updateId);
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallUpdate = async (updateId: string) => {
|
||||
try {
|
||||
await installMutation.mutateAsync(updateId);
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
if (selectedUpdates.length === 0) {
|
||||
toast.error('Please select at least one update');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await bulkApproveMutation.mutateAsync({ update_ids: selectedUpdates });
|
||||
setSelectedUpdates([]);
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
// 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))];
|
||||
|
||||
// Update detail view
|
||||
if (id && selectedUpdate) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/updates')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Updates
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<span className="text-2xl">{getPackageTypeIcon(selectedUpdate.package_type)}</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{selectedUpdate.package_name}
|
||||
</h1>
|
||||
<span className={cn('badge', getSeverityColor(selectedUpdate.severity))}>
|
||||
{selectedUpdate.severity}
|
||||
</span>
|
||||
<span className={cn('badge', getStatusColor(selectedUpdate.status))}>
|
||||
{selectedUpdate.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Update details and available actions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Update info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Version info */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Version Information</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Current Version</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedUpdate.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Available Version</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedUpdate.available_version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Additional Information</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Package Type</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{selectedUpdate.package_type.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Severity</p>
|
||||
<span className={cn('badge', getSeverityColor(selectedUpdate.severity))}>
|
||||
{selectedUpdate.severity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Discovered</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedUpdate.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Updated</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatRelativeTime(selectedUpdate.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedUpdate.metadata && Object.keys(selectedUpdate.metadata).length > 0 && (
|
||||
<div className="mt-6">
|
||||
<p className="text-sm text-gray-600 mb-2">Metadata</p>
|
||||
<pre className="bg-gray-50 p-3 rounded-md text-xs overflow-x-auto">
|
||||
{JSON.stringify(selectedUpdate.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selectedUpdate.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApproveUpdate(selectedUpdate.id)}
|
||||
disabled={approveMutation.isLoading}
|
||||
className="w-full btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve Update
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleRejectUpdate(selectedUpdate.id)}
|
||||
disabled={rejectMutation.isLoading}
|
||||
className="w-full btn btn-secondary"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Reject Update
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedUpdate.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleInstallUpdate(selectedUpdate.id)}
|
||||
disabled={installMutation.isLoading}
|
||||
className="w-full btn btn-primary"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Install Now
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${selectedUpdate.agent_id}`)}
|
||||
className="w-full btn btn-ghost"
|
||||
>
|
||||
<Computer className="h-4 w-4 mr-2" />
|
||||
View Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search updates by package name..."
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{[statusFilter, severityFilter, typeFilter, agentFilter].filter(Boolean).length > 0 && (
|
||||
<span className="bg-primary-100 text-primary-800 px-2 py-0.5 rounded-full text-xs">
|
||||
{[statusFilter, severityFilter, typeFilter, agentFilter].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedUpdates.length > 0 && (
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={bulkApproveMutation.isLoading}
|
||||
className="btn btn-success"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve Selected ({selectedUpdates.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
{statuses.map(status => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Severity
|
||||
</label>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
{severities.map(severity => (
|
||||
<option key={severity} value={severity}>{severity}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Package Type
|
||||
</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{types.map(type => (
|
||||
<option key={type} value={type}>{type.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Agent
|
||||
</label>
|
||||
<select
|
||||
value={agentFilter}
|
||||
onChange={(e) => setAgentFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Agents</option>
|
||||
{agents.map(agentId => (
|
||||
<option key={agentId} value={agentId}>{agentId}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Updates table */}
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="p-4 border-b border-gray-200">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-2">Failed to load updates</div>
|
||||
<p className="text-sm text-gray-600">Please check your connection and try again.</p>
|
||||
</div>
|
||||
) : updates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No updates found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter || severityFilter || typeFilter || agentFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All agents are up to date!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUpdates.length === updates.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header">Package</th>
|
||||
<th className="table-header">Type</th>
|
||||
<th className="table-header">Versions</th>
|
||||
<th className="table-header">Severity</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Discovered</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{updates.map((update) => (
|
||||
<tr key={update.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUpdates.includes(update.id)}
|
||||
onChange={(e) => handleSelectUpdate(update.id, e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-xl">{getPackageTypeIcon(update.package_type)}</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<button
|
||||
onClick={() => navigate(`/updates/${update.id}`)}
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{update.package_name}
|
||||
</button>
|
||||
</div>
|
||||
{update.metadata?.size_bytes && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatBytes(update.metadata.size_bytes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="text-xs font-medium text-gray-900 bg-gray-100 px-2 py-1 rounded">
|
||||
{update.package_type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-900">{update.current_version}</div>
|
||||
<div className="text-success-600">→ {update.available_version}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getSeverityColor(update.severity))}>
|
||||
{update.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getStatusColor(update.status))}>
|
||||
{update.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<button
|
||||
onClick={() => navigate(`/agents/${update.agent_id}`)}
|
||||
className="text-sm text-gray-900 hover:text-primary-600"
|
||||
title="View agent"
|
||||
>
|
||||
{update.agent_id.substring(0, 8)}...
|
||||
</button>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatRelativeTime(update.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
{update.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApproveUpdate(update.id)}
|
||||
disabled={approveMutation.isLoading}
|
||||
className="text-success-600 hover:text-success-800"
|
||||
title="Approve"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectUpdate(update.id)}
|
||||
disabled={rejectMutation.isLoading}
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
title="Reject"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{update.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleInstallUpdate(update.id)}
|
||||
disabled={installMutation.isLoading}
|
||||
className="text-primary-600 hover:text-primary-800"
|
||||
title="Install"
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/updates/${update.id}`)}
|
||||
className="text-gray-400 hover:text-primary-600"
|
||||
title="View details"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Updates;
|
||||
175
aggregator-web/src/types/index.ts
Normal file
175
aggregator-web/src/types/index.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// API Response types
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Agent types
|
||||
export interface Agent {
|
||||
id: string;
|
||||
hostname: string;
|
||||
os_type: string;
|
||||
os_version: string;
|
||||
architecture: string;
|
||||
status: 'online' | 'offline';
|
||||
last_checkin: string;
|
||||
last_scan: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: string;
|
||||
ip_address: string;
|
||||
}
|
||||
|
||||
export interface AgentSpec {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
cpu_cores: number;
|
||||
memory_mb: number;
|
||||
disk_gb: number;
|
||||
docker_version: string | null;
|
||||
kernel_version: string;
|
||||
metadata: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Update types
|
||||
export interface UpdatePackage {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
package_type: 'apt' | 'docker' | 'yum' | 'dnf' | 'windows' | 'winget';
|
||||
package_name: string;
|
||||
current_version: string;
|
||||
available_version: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
approved_at: string | null;
|
||||
scheduled_at: string | null;
|
||||
installed_at: string | null;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
// Update specific types
|
||||
export interface DockerUpdateInfo {
|
||||
local_digest: string;
|
||||
remote_digest: string;
|
||||
image_name: string;
|
||||
tag: string;
|
||||
registry: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
export interface AptUpdateInfo {
|
||||
package_name: string;
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
section: string;
|
||||
priority: string;
|
||||
repository: string;
|
||||
size_bytes: number;
|
||||
cves: string[];
|
||||
}
|
||||
|
||||
// Command types
|
||||
export interface Command {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
command_type: 'scan' | 'install' | 'update' | 'reboot';
|
||||
payload: Record<string, any>;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
executed_at: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
// Log types
|
||||
export interface UpdateLog {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
update_package_id: string | null;
|
||||
command_id: string | null;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
message: string;
|
||||
metadata: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Dashboard stats
|
||||
export interface DashboardStats {
|
||||
total_agents: number;
|
||||
online_agents: number;
|
||||
offline_agents: number;
|
||||
pending_updates: number;
|
||||
approved_updates: number;
|
||||
installed_updates: number;
|
||||
failed_updates: number;
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
medium_updates: number;
|
||||
low_updates: number;
|
||||
updates_by_type: Record<string, number>;
|
||||
}
|
||||
|
||||
// API request/response types
|
||||
export interface AgentListResponse {
|
||||
agents: Agent[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateListResponse {
|
||||
updates: UpdatePackage[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateApprovalRequest {
|
||||
update_ids: string[];
|
||||
scheduled_at?: string;
|
||||
}
|
||||
|
||||
export interface ScanRequest {
|
||||
agent_ids?: string[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
// Query parameters
|
||||
export interface ListQueryParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
severity?: string;
|
||||
type?: string;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// UI State types
|
||||
export interface FilterState {
|
||||
status: string[];
|
||||
severity: string[];
|
||||
type: string[];
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface PaginationState {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// WebSocket message types (for future real-time updates)
|
||||
export interface WebSocketMessage {
|
||||
type: 'agent_status' | 'update_discovered' | 'update_installed' | 'command_completed';
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Error types
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: any;
|
||||
}
|
||||
80
aggregator-web/tailwind.config.js
Normal file
80
aggregator-web/tailwind.config.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'Monaco', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
aggregator-web/tsconfig.json
Normal file
31
aggregator-web/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
aggregator-web/tsconfig.node.json
Normal file
10
aggregator-web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
aggregator-web/vite.config.ts
Normal file
22
aggregator-web/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user