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:
Fimeg
2025-10-13 16:46:31 -04:00
commit 55b7d03010
57 changed files with 7326 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# API Configuration
VITE_API_URL=http://localhost:8080/api/v1
# Environment
VITE_NODE_ENV=development

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

112
aggregator-web/src/App.tsx Normal file
View 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;

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

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

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

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

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

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

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

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

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

View 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>,
)

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

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

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

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

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

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

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

View 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: [],
}

View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

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